init: 橘视厅酒店版会员系统前端

This commit is contained in:
zhaogd
2026-07-26 12:54:46 +08:00
commit 690568861b
243 changed files with 42311 additions and 0 deletions

59
src/views/common/404.vue Executable file
View File

@@ -0,0 +1,59 @@
<template>
<div class="site-wrapper site-page--not-found">
<div class="site-content__wrapper">
<div class="site-content">
<h2 class="not-found-title">400</h2>
<p class="not-found-desc">抱歉!您访问的页面<em>失联</em>啦 ...</p>
<el-button @click="$router.go(-1)">返回上一页</el-button>
<el-button type="primary" class="not-found-btn-gohome" @click="$router.push({ name: 'home' })">进入首页</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss">
.site-wrapper.site-page--not-found {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
.site-content__wrapper {
padding: 0;
margin: 0;
background-color: #fff;
}
.site-content {
position: fixed;
top: 15%;
left: 50%;
z-index: 2;
padding: 30px;
text-align: center;
transform: translate(-50%, 0);
}
.not-found-title {
margin: 20px 0 15px;
font-size: 10em;
font-weight: 400;
color: rgb(55, 71, 79);
}
.not-found-desc {
margin: 0 0 30px;
font-size: 26px;
text-transform: uppercase;
color: rgb(118, 131, 143);
> em {
font-style: normal;
color: #ee8145;
}
}
.not-found-btn-gohome {
margin-left: 30px;
}
}
</style>

30
src/views/common/home.vue Executable file
View File

@@ -0,0 +1,30 @@
<template>
<div class="home-redirect"></div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
defineOptions({ name: 'Home' })
const router = useRouter()
onMounted(() => {
try {
const routes = JSON.parse(sessionStorage.getItem('dynamicMenuRoutes') || '[]')
if (Array.isArray(routes) && routes.length > 0 && routes[0]?.name) {
router.replace({ name: routes[0].name })
}
} catch {
// 动态菜单尚未加载完成时保持空白,由路由守卫后续处理
}
})
</script>
<style scoped>
.home-redirect {
width: 100%;
min-height: 100%;
}
</style>

786
src/views/common/login.vue Executable file
View File

@@ -0,0 +1,786 @@
<template>
<div class="site-wrapper site-page--login" @mousemove="onMouseMove">
<div class="site-content__wrapper">
<div class="site-content">
<div class="brand-info">
<div class="relative z-20 flex items-end justify-center h-[500px]">
<div class="relative" style="width:550px;height:400px">
<div
class="purple-character absolute bottom-0 transition-all duration-300 ease-out"
:style="{
left: '70px',
width: '180px',
height: (isTyping || (dataForm.password.length > 0 && !showPassword)) ? '440px' : '400px',
backgroundColor: 'rgb(108, 63, 245)',
borderRadius: '10px 10px 0px 0px',
zIndex: 1,
transform: (dataForm.password.length > 0 && showPassword)
? 'skewX(0deg)'
: (isTyping || (dataForm.password.length > 0 && !showPassword))
? `skewX(${(calculatePosition(purpleRef)?.bodySkew || 0) - 12}deg) translateX(40px)`
: `skewX(${calculatePosition(purpleRef)?.bodySkew || 0}deg)`,
transformOrigin: 'center bottom'
}"
>
<div
class="absolute flex gap-8 transition-all duration-700 ease-in-out"
:style="{
left: (dataForm.password.length > 0 && showPassword) ? '20px' : isLookingAtEachOther ? '55px' : `${45 + (calculatePosition(purpleRef)?.faceX || 0)}px`,
top: (dataForm.password.length > 0 && showPassword) ? '35px' : isLookingAtEachOther ? '65px' : `${40 + (calculatePosition(purpleRef)?.faceY || 0)}px`
}"
>
<div
class="rounded-full flex items-center justify-center transition-all duration-150"
:style="{
width: '22px',
height: isPurpleBlinking ? '2px' : '22px',
backgroundColor: 'white',
overflow: 'hidden'
}"
>
<div
v-if="!isPurpleBlinking"
class="rounded-full login-eyes-dot"
:style="{
width: '11px',
height: '11px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? (isPurplePeeking ? '4px' : '-4px') : isLookingAtEachOther ? '3px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? (isPurplePeeking ? '5px' : '-4px') : isLookingAtEachOther ? '4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
</div>
<div
class="rounded-full flex items-center justify-center transition-all duration-150"
:style="{
width: '22px',
height: isPurpleBlinking ? '2px' : '22px',
backgroundColor: 'white',
overflow: 'hidden'
}"
>
<div
v-if="!isPurpleBlinking"
class="rounded-full login-eyes-dot"
:style="{
width: '11px',
height: '11px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? (isPurplePeeking ? '4px' : '-4px') : isLookingAtEachOther ? '3px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? (isPurplePeeking ? '5px' : '-4px') : isLookingAtEachOther ? '4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
</div>
</div>
</div>
<div
class="black-character absolute bottom-0 transition-all duration-300 ease-out"
:style="{
left: '240px',
width: '120px',
height: '310px',
backgroundColor: 'rgb(45, 45, 45)',
borderRadius: '8px 8px 0px 0px',
zIndex: 2,
transform: (dataForm.password.length > 0 && showPassword)
? 'skewX(0deg)'
: isLookingAtEachOther
? `skewX(${(calculatePosition(blackRef)?.bodySkew || 0) * 1.5 + 10}deg) translateX(20px)`
: (isTyping || (dataForm.password.length > 0 && !showPassword))
? `skewX(${(calculatePosition(blackRef)?.bodySkew || 0) * 1.5}deg)`
: `skewX(${calculatePosition(blackRef)?.bodySkew || 0}deg)`,
transformOrigin: 'center bottom'
}"
>
<div
class="absolute flex gap-6 transition-all duration-700 ease-in-out"
:style="{
left: (dataForm.password.length > 0 && showPassword) ? '10px' : isLookingAtEachOther ? '32px' : `${26 + (calculatePosition(blackRef)?.faceX || 0)}px`,
top: (dataForm.password.length > 0 && showPassword) ? '28px' : isLookingAtEachOther ? '12px' : `${32 + (calculatePosition(blackRef)?.faceY || 0)}px`
}"
>
<div
class="rounded-full flex items-center justify-center transition-all duration-150"
:style="{
width: '24px',
height: isBlackBlinking ? '2px' : '24px',
backgroundColor: 'white',
overflow: 'hidden'
}"
>
<div
v-if="!isBlackBlinking"
class="rounded-full login-eyes-dot"
:style="{
width: '12px',
height: '12px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-4px' : isLookingAtEachOther ? '0px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : isLookingAtEachOther ? '-4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
</div>
<div
class="rounded-full flex items-center justify-center transition-all duration-150"
:style="{
width: '24px',
height: isBlackBlinking ? '2px' : '24px',
backgroundColor: 'white',
overflow: 'hidden'
}"
>
<div
v-if="!isBlackBlinking"
class="rounded-full login-eyes-dot"
:style="{
width: '12px',
height: '12px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-4px' : isLookingAtEachOther ? '0px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : isLookingAtEachOther ? '-4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
</div>
</div>
</div>
<div
class="orange-character absolute bottom-0 transition-all duration-300 ease-out"
:style="{
left: '0px',
width: '240px',
height: '200px',
zIndex: 3,
backgroundColor: 'rgb(255, 155, 107)',
borderRadius: '120px 120px 0px 0px',
transform: (dataForm.password.length > 0 && showPassword) ? 'skewX(0deg)' : `skewX(${calculatePosition(orangeRef)?.bodySkew || 0}deg)`,
transformOrigin: 'center bottom'
}"
>
<div
class="absolute flex gap-8 transition-all duration-200 ease-out"
:style="{
left: (dataForm.password.length > 0 && showPassword) ? '50px' : `${82 + (calculatePosition(orangeRef)?.faceX || 0)}px`,
top: (dataForm.password.length > 0 && showPassword) ? '85px' : `${90 + (calculatePosition(orangeRef)?.faceY || 0)}px`
}"
>
<div
class="rounded-full login-eyes-dot"
:style="{
width: '14px',
height: '14px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-5px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
<div
class="rounded-full login-eyes-dot"
:style="{
width: '14px',
height: '14px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-5px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
</div>
</div>
<div
class="yellow-character absolute bottom-0 transition-all duration-300 ease-out"
:style="{
left: '310px',
width: '140px',
height: '230px',
backgroundColor: 'rgb(232, 215, 84)',
borderRadius: '70px 70px 0px 0px',
zIndex: 4,
transform: (dataForm.password.length > 0 && showPassword) ? 'skewX(0deg)' : `skewX(${calculatePosition(yellowRef)?.bodySkew || 0}deg)`,
transformOrigin: 'center bottom'
}"
>
<div
class="absolute flex gap-6 transition-all duration-200 ease-out"
:style="{
left: (dataForm.password.length > 0 && showPassword) ? '20px' : `${52 + (calculatePosition(yellowRef)?.faceX || 0)}px`,
top: (dataForm.password.length > 0 && showPassword) ? '35px' : `${40 + (calculatePosition(yellowRef)?.faceY || 0)}px`
}"
>
<div
class="rounded-full login-eyes-dot"
:style="{
width: '14px',
height: '14px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-5px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
<div
class="rounded-full login-eyes-dot"
:style="{
width: '14px',
height: '14px',
backgroundColor: 'rgb(45, 45, 45)',
transform: `translate(${(dataForm.password.length > 0 && showPassword) ? '-5px' : '0px'}, ${(dataForm.password.length > 0 && showPassword) ? '-4px' : '0px'})`,
transition: 'transform 0.1s ease-out'
}"
></div>
</div>
<div
class="absolute w-20 h-[4px] bg-[#2D2D2D] rounded-full transition-all duration-200 ease-out login-mouth-line"
:style="{
left: (dataForm.password.length > 0 && showPassword) ? '10px' : `${40 + (calculatePosition(yellowRef)?.faceX || 0)}px`,
top: (dataForm.password.length > 0 && showPassword) ? '88px' : `${88 + (calculatePosition(yellowRef)?.faceY || 0)}px`
}"
></div>
</div>
</div>
</div>
</div>
<div class="login-main">
<h3 class="login-title">橘视厅VIP会员酒店版系统</h3>
<h3 class="login-title">用户登录</h3>
<el-form :model="dataForm" :rules="dataRule" ref="dataFormRef" @keyup.enter="dataFormSubmit()" status-icon>
<el-form-item prop="userName">
<el-input
v-model="dataForm.userName"
placeholder="帐号"
@focus="handleInputFocus"
@blur="handleInputBlur"
></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="dataForm.password"
:type="showPassword ? 'text' : 'password'"
placeholder="密码"
@focus="handleInputFocus"
@blur="handleInputBlur"
>
<template #append>
<el-button @click="togglePasswordVisibility" class="password-toggle-btn">
<el-icon v-if="showPassword">
<View />
</el-icon>
<el-icon v-else>
<Hide />
</el-icon>
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item prop="captcha">
<el-row :gutter="20">
<el-col :span="14">
<el-input
v-model="dataForm.captcha"
placeholder="验证码"
@focus="handleInputFocus"
@blur="handleInputBlur"
>
</el-input>
</el-col>
<el-col :span="10" class="login-captcha">
<img :src="captchaPath" @click="getCaptcha()" alt="">
</el-col>
</el-row>
</el-form-item>
<div v-if="error" class="login-error-message">
{{ error }}
</div>
<el-form-item>
<el-button
class="login-btn-submit"
type="primary"
@click="dataFormSubmit()"
:loading="isLoading"
:disabled="isLoading"
>
{{ isLoading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { getUUID, setTokenData, clearLoginInfo } from '@/utils'
import { ElMessage } from 'element-plus'
import { login as loginApi, getCaptchaUrl } from '@/api/sys'
import { View, Hide } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { resetNearExpiryUnusedCardsPrompt } from '@/composables/useNearExpiryUnusedCards'
import type { FormInstance, FormRules } from 'element-plus'
interface LoginForm {
userName: string
password: string
uuid: string
captcha: string
}
interface Position {
faceX: number
faceY: number
bodySkew: number
}
interface CachedElements {
dots: NodeListOf<Element> | null
mouths: NodeListOf<Element> | null
}
const router = useRouter()
const userStore = useUserStore()
const mouseX = ref(0)
const mouseY = ref(0)
const dataForm = ref<LoginForm>({
userName: '',
password: '',
uuid: '',
captcha: ''
})
const dataRule = ref<FormRules<LoginForm>>({
userName: [
{ required: true, message: '帐号不能为空', trigger: 'blur' }
],
password: [
{ required: true, message: '密码不能为空', trigger: 'blur' }
],
captcha: [
{ required: true, message: '验证码不能为空', trigger: 'blur' }
]
})
const captchaPath = ref('')
const isPurpleBlinking = ref(false)
const isBlackBlinking = ref(false)
const isTyping = ref(false)
const isLookingAtEachOther = ref(false)
const isPurplePeeking = ref(false)
const showPassword = ref(false)
const isLoading = ref(false)
const error = ref('')
const animationFrameId = ref<number | null>(null)
const cachedElements = ref<CachedElements>({
dots: null,
mouths: null
})
const purpleRef = ref<HTMLElement | null>(null)
const blackRef = ref<HTMLElement | null>(null)
const yellowRef = ref<HTMLElement | null>(null)
const orangeRef = ref<HTMLElement | null>(null)
let purpleBlinkTimeout: ReturnType<typeof setTimeout> | null = null
let blackBlinkTimeout: ReturnType<typeof setTimeout> | null = null
let typingTimeout: ReturnType<typeof setTimeout> | null = null
let peekTimeout: ReturnType<typeof setTimeout> | null = null
// 初始化验证码
function getCaptcha(): void {
dataForm.value.uuid = getUUID()
captchaPath.value = getCaptchaUrl(dataForm.value.uuid)
}
// 初始化眨眼动画
function initBlinkingAnimations(): void {
schedulePurpleBlink()
scheduleBlackBlink()
}
// 紫色角色眨眼
function schedulePurpleBlink(): void {
const getRandomBlinkInterval = () => Math.random() * 4000 + 3000
purpleBlinkTimeout = setTimeout(() => {
isPurpleBlinking.value = true
setTimeout(() => {
isPurpleBlinking.value = false
schedulePurpleBlink()
}, 150)
}, getRandomBlinkInterval())
}
// 黑色角色眨眼
function scheduleBlackBlink(): void {
const getRandomBlinkInterval = () => Math.random() * 4000 + 3000
blackBlinkTimeout = setTimeout(() => {
isBlackBlinking.value = true
setTimeout(() => {
isBlackBlinking.value = false
scheduleBlackBlink()
}, 150)
}, getRandomBlinkInterval())
}
// 计算角色位置
function calculatePosition(ref: HTMLElement | null): Position {
if (!ref) return { faceX: 0, faceY: 0, bodySkew: 0 }
const rectCache = (ref as any)._rectCache
if (!rectCache || Date.now() - rectCache.timestamp > 100) {
;(ref as any)._rectCache = {
rect: ref.getBoundingClientRect(),
timestamp: Date.now()
}
}
const rect = (ref as any)._rectCache.rect
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 3
const deltaX = mouseX.value - centerX
const deltaY = mouseY.value - centerY
const faceX = Math.max(-15, Math.min(15, deltaX / 20))
const faceY = Math.max(-10, Math.min(10, deltaY / 30))
const bodySkew = Math.max(-6, Math.min(6, -deltaX / 120))
return { faceX, faceY, bodySkew }
}
// 更新角色位置
function updateCharacterPositions(): void {
// 角色位置更新逻辑
}
// 处理输入框焦点
function handleInputFocus(): void {
isTyping.value = true
isLookingAtEachOther.value = true
typingTimeout = setTimeout(() => {
isLookingAtEachOther.value = false
}, 800)
}
// 处理输入框失焦
function handleInputBlur(): void {
isTyping.value = false
isLookingAtEachOther.value = false
if (typingTimeout) {
clearTimeout(typingTimeout)
}
}
// 切换密码可见性
function togglePasswordVisibility(): void {
showPassword.value = !showPassword.value
if (showPassword.value && dataForm.value.password.length > 0) {
schedulePurplePeek()
}
}
// 紫色角色偷看密码
function schedulePurplePeek(): void {
if (!showPassword.value || dataForm.value.password.length === 0) {
isPurplePeeking.value = false
return
}
peekTimeout = setTimeout(() => {
isPurplePeeking.value = true
setTimeout(() => {
isPurplePeeking.value = false
}, 800)
}, Math.random() * 3000 + 2000)
}
// 鼠标移动事件
function onMouseMove(e: MouseEvent): void {
mouseX.value = e.clientX
mouseY.value = e.clientY
if (!animationFrameId.value) {
animationFrameId.value = requestAnimationFrame(() => {
updateEyeOffsets()
updateCharacterPositions()
animationFrameId.value = null
})
}
}
// 更新眼睛偏移
function updateEyeOffsets(): void {
if (!cachedElements.value.dots) {
const el = document.querySelector('.site-page--login')
if (el) {
cachedElements.value.dots = el.querySelectorAll('.login-eyes-dot')
cachedElements.value.mouths = el.querySelectorAll('.login-mouth-line')
}
}
const dots = cachedElements.value.dots
const mouths = cachedElements.value.mouths
if (!dots || !dots.length) return
const vw = window.innerWidth || 1
const vh = window.innerHeight || 1
const relX = (mouseX.value - vw / 2) / (vw / 2)
const relY = (mouseY.value - vh / 2) / (vh / 2)
const eyeMoveX = relX * 14
const eyeMoveY = relY * 7
dots.forEach((dot) => {
if (dot) {
;(dot as HTMLElement).style.transform = `translate(${eyeMoveX}px, ${eyeMoveY}px)`
}
})
if (mouths && mouths.length) {
const mouthX = relX * 10
const mouthY = relY * 3
mouths.forEach((m) => {
if (m) {
;(m as HTMLElement).style.transform = `translate(${mouthX}px, ${mouthY}px)`
}
})
}
}
const dataFormRef = ref<FormInstance | null>(null)
// 提交表单
function dataFormSubmit(): void {
dataFormRef.value?.validate((valid) => {
if (valid) {
isLoading.value = true
error.value = ''
loginApi({
username: dataForm.value.userName,
password: dataForm.value.password,
uuid: dataForm.value.uuid,
captcha: dataForm.value.captcha
}).then((body: Record<string, any>) => {
if (body && body.code === 0) {
const inner =
body.data && typeof body.data === 'object' && !Array.isArray(body.data)
? body.data
: body
setTokenData({
token: inner.token ?? body.token,
expire: inner.expire ?? body.expire,
refreshToken: inner.refreshToken ?? body.refreshToken,
refreshExpire: inner.refreshExpire ?? body.refreshExpire
})
resetNearExpiryUnusedCardsPrompt()
userStore.applyProfileFromAuth(inner, body)
router.replace({ name: 'home' })
} else {
getCaptcha()
error.value = body.msg || '登录失败'
ElMessage.error(body.msg)
}
}).catch(() => {
error.value = '网络错误,请稍后重试'
ElMessage.error('网络错误,请稍后重试')
}).finally(() => {
isLoading.value = false
})
}
})
}
onMounted(() => {
// 清除旧 token,防止残留的 refreshToken 导致请求拦截器在登录前自动刷新 token
clearLoginInfo()
getCaptcha()
initBlinkingAnimations()
purpleRef.value = document.querySelector('.purple-character')
blackRef.value = document.querySelector('.black-character')
yellowRef.value = document.querySelector('.yellow-character')
orangeRef.value = document.querySelector('.orange-character')
})
onBeforeUnmount(() => {
if (purpleBlinkTimeout) clearTimeout(purpleBlinkTimeout)
if (blackBlinkTimeout) clearTimeout(blackBlinkTimeout)
if (typingTimeout) clearTimeout(typingTimeout)
if (peekTimeout) clearTimeout(peekTimeout)
if (animationFrameId.value) cancelAnimationFrame(animationFrameId.value)
})
</script>
<style lang="scss">
.site-wrapper.site-page--login {
position: fixed;
inset: 0;
overflow: hidden;
background:
radial-gradient(circle at 0% 0%, #1f2937 0, #020617 55%, #000000 100%);
color: #e5e7eb;
&::before {
content: "";
position: fixed;
inset: -20%;
background:
radial-gradient(circle at 10% 20%, rgba(96, 165, 250, 0.16) 0, transparent 55%),
radial-gradient(circle at 80% 10%, rgba(244, 114, 182, 0.18) 0, transparent 55%),
radial-gradient(circle at 50% 90%, rgba(52, 211, 153, 0.12) 0, transparent 60%);
opacity: 0.85;
filter: blur(2px);
transform: translate3d(0, 12px, 0) scale(1.02);
pointer-events: none;
z-index: -1;
}
.site-content__wrapper {
position: absolute;
inset: 0;
padding: 0;
margin: 0;
overflow: hidden;
background-color: transparent;
display: flex;
align-items: center;
justify-content: center;
}
.site-content {
width: 100%;
max-width: 1120px;
padding: 40px 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 40px;
box-sizing: border-box;
}
.brand-info {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.login-main {
flex: 0 0 420px;
padding: 32px 32px 28px;
background: #ffffff;
border-radius: 24px;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.55);
color: #0f172a;
animation: login-card-in 0.9s cubic-bezier(0.19, 0.64, 0.34, 1.01) 0.1s forwards;
opacity: 0;
}
.login-title {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px;
color: #111827;
text-align: center;
background: linear-gradient(135deg, #4f46e5, #6366f1);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 2px 4px rgba(79, 70, 229, 0.2);
}
.login-title:nth-child(2) {
font-size: 20px;
margin-bottom: 24px;
background: linear-gradient(135deg, #f59e0b, #ef4444);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 2px 4px rgba(245, 158, 11, 0.2);
}
.login-subtitle {
font-size: 13px;
color: #6b7280;
margin-bottom: 20px;
}
.login-captcha {
overflow: hidden;
> img {
width: 100%;
cursor: pointer;
border-radius: 12px;
box-shadow: 0 0 0 1px #e5e7eb;
}
}
.login-btn-submit {
width: 100%;
margin-top: 12px;
height: 46px;
font-size: 15px;
font-weight: 600;
border-radius: 999px;
background: linear-gradient(135deg, #4f46e5, #6366f1);
border: none;
}
.login-btn-submit:hover {
background: linear-gradient(135deg, #4338ca, #4f46e5);
}
}
.login-error-message {
padding: 12px;
margin: 12px 0;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: #ef4444;
font-size: 14px;
animation: error-fade-in 0.3s ease-out;
}
@keyframes error-fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.el-input {
transition: all 0.3s ease;
}
.el-input:focus-within {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.2);
}
.el-button {
transition: all 0.3s ease;
}
.el-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
.el-button:active:not(:disabled) {
transform: translateY(0);
}
.login-captcha img {
transition: all 0.3s ease;
}
.login-captcha img:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
@keyframes login-card-in {
0% {
opacity: 0;
transform: translate3d(0, 20px, 0) scale(0.98);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
</style>

View File

@@ -0,0 +1,52 @@
/** 各卡类型可分配统计行 */
export interface ByTypeStatItem {
cardType: string
cardTypeName?: string
allocatableCount?: number
}
/** 周期卡 / 其他 分组区块 */
export interface ByTypeSection {
key: string
title: string
items: ByTypeStatItem[]
}
/** 卡流向明细表格行 */
export interface CardFlowRow {
bizDate: string
counterpartyOrgName: string
counterpartyOrgType: string
counterpartyOrgId?: number | string
outCount: number
inCount: number
}
/** 接口原始行(解析后写入 CardFlowRow) */
export interface RawFlowRow {
bizDate?: string
counterpartyOrgName?: string
counterpartyOrgType?: string
counterpartyOrgId?: number | string
outCount?: number
inCount?: number
}
/** 服务端组织类型枚举 -> 文案 */
export const ORG_TYPE_LABEL: Record<string, string> = {
SERVICE_PROVIDER: '服务商',
STORE: '门店',
CHANNEL: '渠道'
}
/** 周期卡:年 / 季 / 月 / 周;其余归入「其他」 */
export const PERIOD_CARD_TYPES = ['YEAR', 'QUARTER', 'MONTH', 'WEEK'] as const
export type PeriodCardType = (typeof PERIOD_CARD_TYPES)[number]
export const PERIOD_TYPE_ORDER: Record<PeriodCardType, number> = {
YEAR: 0,
QUARTER: 1,
MONTH: 2,
WEEK: 3
}

View File

@@ -0,0 +1,581 @@
<template>
<div class="mod-card-allocation-detail">
<el-card shadow="never" class="list-card">
<template #header><span>卡分配详情</span></template>
<el-form :inline="true" :model="queryForm" @keyup.enter="handleQuery" class="toolbar">
<el-form-item label="业务日区间" required>
<el-date-picker
v-model="queryForm.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 280px" />
</el-form-item>
<!-- 仅非渠道/非代理商账号显示渠道;渠道保留代理商与门店;代理商仅保留门店 -->
<el-form-item v-if="showChannelPicker" label="渠道" required>
<channel-select
v-model="channelId"
default-first
width="200px"
@loaded="onChannelSelectLoaded" />
</el-form-item>
<el-form-item v-if="showAgentPicker" label="代理商">
<channel-agent-tree-select
ref="channelAgentTreeSelectRef"
v-model="agentOrgId"
:channel-id="channelId"
width="220px"
/>
</el-form-item>
<el-form-item label="门店">
<agent-store-select
ref="agentStoreSelectRef"
v-model="storeId"
:channel-id="channelId"
:agent-org-id="agentOrgId"
width="220px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleQuery">查询</el-button>
<el-button :loading="exportingDetail" @click="handleExportDetail">导出</el-button>
</el-form-item>
</el-form>
<div class="allocation-detail-main">
<div v-if="byTypeGroupedSections.length > 0" class="by-type-availability" v-loading="loading">
<div class="by-type-availability-title">各卡类型可分配数量</div>
<div class="by-type-availability-sections">
<div
v-for="section in byTypeGroupedSections"
:key="section.key"
class="by-type-section">
<div class="by-type-section-head">
<span class="by-type-section-title">{{ section.title }}</span>
<span class="by-type-section-hint">{{ section.items.length }} 种</span>
</div>
<el-table
:data="section.items"
class="by-type-table"
size="small"
border
stripe
fit
style="width: 100%">
<el-table-column prop="cardTypeName" label="卡类型" min-width="120" show-overflow-tooltip />
<el-table-column prop="cardType" label="类型编码" width="110" align="center" />
<el-table-column label="可分配" width="120" align="right" header-align="center">
<template #default="{ row }">
<span class="by-type-table-num">{{ formatAllocatableCount(row.allocatableCount) }}</span>
<span class="by-type-table-unit"> 张</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
<div class="card-flow-table-wrap" v-loading="loading">
<div class="card-flow-table-title">卡流向明细</div>
<el-table
:data="cardFlowTableRows"
class="card-flow-table"
stripe
border
fit
show-summary
:summary-method="getCardFlowSummaries"
empty-text="暂无流向数据"
style="width: 100%">
<el-table-column label="业务日" min-width="120" header-align="center" align="center" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-biz-date">{{ formatBizDay(row.bizDate) }}</span>
</template>
</el-table-column>
<el-table-column label="对方组织" min-width="220" header-align="center" align="left" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-org-name">{{ formatCell(row.counterpartyOrgName) }}</span>
</template>
</el-table-column>
<el-table-column label="组织类型" width="130" header-align="center" align="center">
<template #default="{ row }">
{{ formatOrgType(row.counterpartyOrgType) }}
</template>
</el-table-column>
<el-table-column label="销售" width="110" align="right" header-align="center">
<template #default="{ row }">
<span class="cell-metric cell-metric--neutral">{{ formatFlowQty(row.outCount) }}</span>
</template>
</el-table-column>
<el-table-column label="回退" width="110" align="right" header-align="center">
<template #default="{ row }">
<span class="cell-metric cell-metric--neutral">{{ formatFlowQty(row.inCount) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import type { LocationQueryValue } from 'vue-router'
import type { AxiosError, AxiosResponse } from 'axios'
import { useChannelScopeFromRole } from '@/composables/useChannelScopeFromRole'
import { ElMessage } from 'element-plus'
import type { ApiResponse } from '@/types/api'
import {
getCardAllocationDetail,
exportCardAllocationDetail,
type CardAllocationDetailParams,
type CardAllocationDetailPayload
} from '@/api/cardAllocation'
import ChannelSelect from '@/components/channel-select/index.vue'
import ChannelAgentTreeSelect from '@/components/channel-agent-tree-select/index.vue'
import AgentStoreSelect from '@/components/agent-store-select/index.vue'
import {
ORG_TYPE_LABEL,
PERIOD_CARD_TYPES,
PERIOD_TYPE_ORDER,
type ByTypeSection,
type ByTypeStatItem,
type CardFlowRow,
type PeriodCardType,
type RawFlowRow
} from './detail.data'
defineOptions({ name: 'CardAllocationDetail' })
const route = useRoute()
function formatCell (val: unknown): string {
if (val == null || val === '') return '-'
if (typeof val === 'object') return JSON.stringify(val)
return String(val)
}
/** 业务日列:取 yyyy-MM-dd */
function formatBizDay (val: unknown): string {
if (val == null || val === '') return '-'
const s = String(val).trim()
const m = s.match(/^(\d{4}-\d{2}-\d{2})/)
return m ? m[1] : s
}
function sumNumberField<T> (rows: T[], pick: (row: T) => unknown): number {
if (!Array.isArray(rows) || !rows.length) return 0
let t = 0
for (const row of rows) {
const n = Number(pick(row))
if (!Number.isNaN(n)) t += n
}
return t
}
function formatSummaryInt (n: number): string {
if (Number.isNaN(n)) return '-'
return String(Math.trunc(n))
}
/** 服务端组织类型枚举 -> 文案 */
function formatOrgType (t: unknown): string {
if (t == null || t === '') return '-'
const k = String(t).trim().toUpperCase()
return ORG_TYPE_LABEL[k] ?? String(t)
}
function formatFlowQty (v: unknown): string {
const n = Number(v)
if (Number.isNaN(n)) return '-'
return String(Math.trunc(n))
}
function getCardFlowSummaries ({ data }: { data: CardFlowRow[] }): string[] {
const rows = data || []
return [
'合计',
'',
'',
formatSummaryInt(sumNumberField(rows, r => r.outCount)),
formatSummaryInt(sumNumberField(rows, r => r.inCount))
]
}
/** 仅从 data.cardFlowBreakdown 填充「卡流向明细」表格 */
function applyCardFlowTableFromPayload (payload: unknown): void {
if (payload == null || typeof payload !== 'object') {
cardFlowTableRows.value = []
return
}
const p = payload as CardAllocationDetailPayload
const raw = p.cardFlowBreakdown ?? p.card_flow_breakdown
if (!Array.isArray(raw)) {
cardFlowTableRows.value = []
return
}
cardFlowTableRows.value = raw.map((r: RawFlowRow) => ({
bizDate: String(r.bizDate ?? ''),
counterpartyOrgName: String(r.counterpartyOrgName ?? ''),
counterpartyOrgType: String(r.counterpartyOrgType ?? ''),
counterpartyOrgId: r.counterpartyOrgId,
outCount: Number(r.outCount ?? 0) || 0,
inCount: Number(r.inCount ?? 0) || 0
}))
}
/** 业务日默认:昨天(yyyy-MM-dd) */
function yesterdayStr (): string {
const d = new Date()
d.setDate(d.getDate() - 1)
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
const y = yesterdayStr()
const queryForm = reactive<{ dateRange: [string, string] }>({ dateRange: [y, y] })
/** 渠道 / 代理商 / 门店(与详情查询接口可选 orgId 一致:优先门店,其次代理商,再次渠道) */
const channelId = ref<string | number | null>(null)
const agentOrgId = ref<string | number | null>(null)
const storeId = ref<string | number | null>(null)
const channelAgentTreeSelectRef = ref<{ loadTree?: () => Promise<void> } | null>(null)
const agentStoreSelectRef = ref<{ loadStoreOptions?: () => Promise<void> } | null>(null)
const { showChannelPicker, showAgentPicker, ensureChannelFilterFromUserRole } = useChannelScopeFromRole({
channelId,
agentOrgId,
storeId,
channelAgentTreeSelectRef,
agentStoreSelectRef
})
/** 展示渠道下拉的账号须先选渠道才能查询;渠道/代理商自动带渠道不校验 */
function canRequestWithChannelRule (): boolean {
if (!showChannelPicker.value) return true
return channelId.value != null && channelId.value !== ''
}
function assertPlatformChannelSelectedForAction (): boolean {
if (canRequestWithChannelRule()) return true
ElMessage.warning('请选择渠道')
return false
}
/** 平台账号:渠道列表拉取并可能默认选首项后,再拉表(onMounted 时 channel 往往尚未写入) */
function onChannelSelectLoaded (): void {
nextTick(() => {
if (!showChannelPicker.value) return
if (!canRequestWithChannelRule()) return
const params = getAllocationRequestParams()
if (!params) return
fetchData()
})
}
const effectiveOrgId = computed(() => {
if (storeId.value != null && storeId.value !== '') return storeId.value
if (agentOrgId.value != null && agentOrgId.value !== '') return agentOrgId.value
if (channelId.value != null && channelId.value !== '') return channelId.value
return undefined
})
const loading = ref(false)
const exportingDetail = ref(false)
/** 卡流向:对方组织维度的出库/回退同一行 */
const cardFlowTableRows = ref<CardFlowRow[]>([])
/** 统计接口 byType:各卡类型可分配数量 */
const byTypeStats = ref<ByTypeStatItem[]>([])
const byTypeGroupedSections = computed<ByTypeSection[]>(() => {
const list = (byTypeStats.value || []).filter(Boolean) as ByTypeStatItem[]
const period: ByTypeStatItem[] = []
const rest: ByTypeStatItem[] = []
for (const item of list) {
if ((PERIOD_CARD_TYPES as readonly string[]).includes(item.cardType)) {
period.push(item)
} else {
rest.push(item)
}
}
period.sort(
(a, b) =>
(PERIOD_TYPE_ORDER[a.cardType as PeriodCardType] ?? 99) -
(PERIOD_TYPE_ORDER[b.cardType as PeriodCardType] ?? 99)
)
const sections: ByTypeSection[] = []
if (period.length) {
sections.push({ key: 'period', title: '周期卡', items: period })
}
if (rest.length) {
sections.push({ key: 'other', title: '其他', items: rest })
}
return sections
})
function formatAllocatableCount (n: unknown): string {
const v = Number(n)
if (Number.isNaN(v)) return '0'
return Math.trunc(v).toLocaleString('zh-CN')
}
function getDateRangeFromForm (): { startDate: string; endDate: string } | null {
const r = queryForm.dateRange
if (!Array.isArray(r) || r.length !== 2 || !r[0] || !r[1]) return null
return { startDate: r[0], endDate: r[1] }
}
/** 列表查询:日期区间;可选 orgId(门店 > 代理商 > 渠道) */
function getAllocationRequestParams (): CardAllocationDetailParams | null {
const range = getDateRangeFromForm()
if (!range) return null
const base: CardAllocationDetailParams = {
startDate: range.startDate,
endDate: range.endDate
}
const oid = effectiveOrgId.value
if (oid != null && oid !== '') {
base.orgId = oid
}
return base
}
function fetchData (): void {
const params = getAllocationRequestParams()
if (!params) {
ElMessage.warning('请选择业务日区间')
return
}
if (!assertPlatformChannelSelectedForAction()) return
loading.value = true
getCardAllocationDetail(params)
.then((res: AxiosResponse<ApiResponse<CardAllocationDetailPayload>>) => {
const { data } = res
if (data == null || Number(data.code) !== 0) {
cardFlowTableRows.value = []
byTypeStats.value = []
ElMessage.error(data?.msg || '请求失败')
return
}
const payload = (data.data != null ? data.data : data) as CardAllocationDetailPayload
const byType = Array.isArray(payload?.byType) ? payload.byType : []
byTypeStats.value = byType
applyCardFlowTableFromPayload(payload)
})
.catch(() => {
cardFlowTableRows.value = []
byTypeStats.value = []
})
.finally(() => {
loading.value = false
})
}
function handleQuery (): void {
fetchData()
}
function parseFilenameFromContentDisposition (header: unknown): string | null {
if (!header || typeof header !== 'string') return null
const utf8 = /filename\*=UTF-8''([^;\s]+)/i.exec(header)
if (utf8) {
try {
return decodeURIComponent(utf8[1].trim())
} catch (_) {}
}
const quoted = /filename="([^"]+)"/i.exec(header)
if (quoted) return quoted[1]
const plain = /filename=([^;\s]+)/i.exec(header)
if (plain) return plain[1].replace(/^["']|["']$/g, '')
return null
}
async function handleExportDetail (): Promise<void> {
const params = getAllocationRequestParams()
if (!params) {
ElMessage.warning('请选择业务日区间')
return
}
if (!assertPlatformChannelSelectedForAction()) return
exportingDetail.value = true
try {
const res = await exportCardAllocationDetail(params)
const blob = res.data
if (!(blob instanceof Blob)) {
ElMessage.error('导出失败')
return
}
const ct = blob.type || ''
if (ct.includes('json') || ct === 'text/plain') {
const text = await blob.text()
try {
const j = JSON.parse(text) as { msg?: string; message?: string }
ElMessage.error(j.msg || j.message || '导出失败')
} catch {
ElMessage.error(text || '导出失败')
}
return
}
const disp =
(res.headers['content-disposition'] ?? res.headers['Content-Disposition']) as string | undefined
const r = getDateRangeFromForm()
const suffix = r ? `${r.startDate}_${r.endDate}` : ''
const fallback = suffix ? `卡分配详情_${suffix}.xlsx` : '卡分配详情.xlsx'
const filename = parseFilenameFromContentDisposition(disp) || fallback
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('已开始下载')
} catch (e: unknown) {
const ax = e as AxiosError<Blob | ApiResponse>
const errData = ax.response?.data
if (errData instanceof Blob) {
try {
const text = await errData.text()
const j = JSON.parse(text) as { msg?: string; message?: string }
ElMessage.error(j.msg || j.message || '导出失败')
} catch {
ElMessage.error(ax.message || '导出失败')
}
} else {
const d = errData as ApiResponse | undefined
ElMessage.error(d?.msg ?? ax.message ?? '导出失败')
}
} finally {
exportingDetail.value = false
}
}
function parseQueryStr (q: LocationQueryValue | LocationQueryValue[] | undefined): string | null {
if (q == null || q === '') return null
const v = Array.isArray(q) ? q[0] : q
const t = String(v).trim()
return /^\d{4}-\d{2}-\d{2}$/.test(t) ? t : null
}
function bootstrapFromRoute (): void {
const start = parseQueryStr(route.query.startDate)
const end = parseQueryStr(route.query.endDate)
if (start && end) {
queryForm.dateRange = [start, end]
return
}
const legacy = parseQueryStr(route.query.bizDate)
if (legacy) {
queryForm.dateRange = [legacy, legacy]
}
}
onMounted(async () => {
bootstrapFromRoute()
await ensureChannelFilterFromUserRole()
if (canRequestWithChannelRule()) {
fetchData()
}
})
</script>
<style lang="scss" scoped>
.mod-card-allocation-detail {
.list-card { margin-top: 0; }
.toolbar { margin-bottom: 12px; }
.by-type-availability {
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 8px;
background: linear-gradient(180deg, #fbfdff 0%, #f7f9fc 100%);
border: 1px solid var(--el-border-color-lighter);
}
.by-type-availability-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-secondary);
margin-bottom: 12px;
}
.by-type-availability-sections {
display: flex;
flex-direction: column;
gap: 16px;
}
.by-type-section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
}
.by-type-section-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.by-type-section-hint {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.by-type-table-num {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--el-color-primary);
}
.by-type-table-unit {
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.by-type-table) {
--el-table-header-bg-color: var(--el-fill-color-light);
}
:deep(.by-type-table .el-table__header th) {
font-weight: 600;
font-size: 12px;
}
.allocation-detail-main {
flex: 1;
min-width: 0;
}
.card-flow-table-wrap {
margin-top: 4px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
background: var(--el-fill-color-blank);
}
.card-flow-table-title {
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-secondary);
border-bottom: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
}
:deep(.card-flow-table) {
--el-table-header-bg-color: var(--el-fill-color-light);
--el-table-header-text-color: var(--el-text-color-secondary);
}
.cell-biz-date {
font-variant-numeric: tabular-nums;
color: var(--el-text-color-regular);
letter-spacing: 0.02em;
}
.cell-org-name {
font-weight: 500;
color: var(--el-text-color-primary);
}
.cell-metric {
display: inline-block;
min-width: 2.5em;
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 14px;
}
.cell-metric--neutral {
color: var(--el-text-color-primary);
}
}
</style>

View File

@@ -0,0 +1,581 @@
<template>
<div class="mod-allocation-order-final-destination-stats">
<el-card shadow="never" class="stats-card">
<template #header><span>分配单卡最终去向统计</span></template>
<el-alert
type="info"
:closable="false"
show-icon
class="stats-tip">
先按流出/流入组织搜索分配单并选择批次,再查看该批次卡按当前所属组织(card.org_id)的最终分布。
</el-alert>
<el-form
:inline="true"
:model="queryForm"
@keyup.enter="handleSearchOrders"
class="stats-toolbar">
<card-flow-from-to-org-select
v-model:from-org-id="queryForm.fromOrgId"
v-model:to-org-id="queryForm.toOrgId"
:auto-default-from="false"
inline />
<el-form-item>
<el-button type="primary" :loading="searchLoading" @click="handleSearchOrders">搜索分配单</el-button>
</el-form-item>
</el-form>
<div class="order-list-wrap" v-loading="searchLoading">
<div class="section-title">分配单列表</div>
<el-table
:data="orderList"
border
stripe
fit
row-key="id"
empty-text="请先搜索分配单"
class="order-table"
style="width: 100%">
<el-table-column type="index" label="序号" width="70" align="center" header-align="center">
<template #default="{ $index }">
{{ (pageIndex - 1) * pageSize + $index + 1 }}
</template>
</el-table-column>
<el-table-column prop="orderNo" label="订单号" min-width="180" show-overflow-tooltip header-align="center" />
<el-table-column label="流出组织" min-width="160" show-overflow-tooltip header-align="center">
<template #default="{ row }">
{{ formatOrgCell(row.fromOrgName, row.fromOrgType) }}
</template>
</el-table-column>
<el-table-column label="流入组织" min-width="160" show-overflow-tooltip header-align="center">
<template #default="{ row }">
{{ formatOrgCell(row.toOrgName, row.toOrgType) }}
</template>
</el-table-column>
<el-table-column label="分配数量" width="110" align="right" header-align="center">
<template #default="{ row }">
{{ formatCount(row.totalCount) }}
</template>
</el-table-column>
<el-table-column label="状态" width="110" align="center" header-align="center">
<template #default="{ row }">
{{ formatOrderStatus(row.status) }}
</template>
</el-table-column>
<el-table-column label="创建时间" width="170" align="center" header-align="center">
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center" header-align="center" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
link
:loading="statsLoading && selectedOrderId === row.id"
@click="handleViewStats(row)">
查看流向
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="orderTotal > 0"
class="order-pagination"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="orderTotal"
layout="total, sizes, prev, pager, next, jumper"
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle" />
</div>
</el-card>
<el-dialog
v-model="statsDialogVisible"
:title="statsDialogTitle"
width="860px"
destroy-on-close
:close-on-click-modal="false"
class="stats-dialog"
@closed="onStatsDialogClosed">
<el-descriptions
:column="3"
border
size="small"
class="stats-summary"
v-loading="statsLoading">
<el-descriptions-item label="分配单 ID">{{ formatCell(statsSummary.allocationOrderId) }}</el-descriptions-item>
<el-descriptions-item label="订单号">{{ formatCell(statsSummary.orderNo) }}</el-descriptions-item>
<el-descriptions-item label="统计卡数">{{ formatCount(statsSummary.cardTotalCount) }}</el-descriptions-item>
<el-descriptions-item label="单据卡数">{{ formatCount(statsSummary.totalCountOnOrder) }}</el-descriptions-item>
</el-descriptions>
<div class="section-title">最终去向分布</div>
<el-table
:data="tableRows"
border
stripe
fit
v-loading="statsLoading"
show-summary
:summary-method="getSummaries"
empty-text="暂无统计数据"
class="stats-table"
style="width: 100%">
<el-table-column type="index" label="序号" width="70" align="center" header-align="center">
<template #default="{ $index }">
{{ (statsPageIndex - 1) * statsPageSize + $index + 1 }}
</template>
</el-table-column>
<el-table-column prop="orgId" label="组织 ID" width="110" align="center" header-align="center" />
<el-table-column prop="orgName" label="组织名称" min-width="200" show-overflow-tooltip header-align="center" />
<el-table-column label="组织类型" width="130" align="center" header-align="center">
<template #default="{ row }">
{{ formatOrgType(row.orgType) }}
</template>
</el-table-column>
<el-table-column label="卡数量" width="120" align="right" header-align="center">
<template #default="{ row }">
{{ formatCount(row.count) }}
</template>
</el-table-column>
<el-table-column label="占比" width="120" align="right" header-align="center">
<template #default="{ row }">
{{ formatPercent(row.count) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="statsDistTotal > 0"
class="stats-pagination"
:current-page="statsPageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="statsPageSize"
:total="statsDistTotal"
layout="total, sizes, prev, pager, next, jumper"
@size-change="statsSizeChangeHandle"
@current-change="statsCurrentChangeHandle" />
<template #footer>
<el-button @click="statsDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import type { AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
import type { ApiResponse } from '@/types/api'
import CardFlowFromToOrgSelect from '@/components/card-flow-from-to-org-select/index.vue'
import {
searchAllocationOrders,
getAllocationOrderFinalDestinationStats,
type AllocationOrderSearchItem,
type AllocationOrderSearchParams,
type AllocationOrderSearchPayload,
type AllocationOrderFinalDestinationStatItem,
type AllocationOrderFinalDestinationStatsPayload
} from '@/api/cardFlow'
defineOptions({ name: 'AllocationOrderFinalDestinationStats' })
const ORG_TYPE_LABEL: Record<string, string> = {
PLATFORM: '平台',
CHANNEL: '渠道',
SERVICE_PROVIDER: '代理商',
STORE: '门店'
}
const ORDER_STATUS_LABEL: Record<string, string> = {
COMPLETED: '已完成',
PENDING: '待处理',
CANCELLED: '已取消',
FAILED: '失败'
}
const searchLoading = ref(false)
const statsLoading = ref(false)
const orderList = ref<AllocationOrderSearchItem[]>([])
const orderTotal = ref(0)
const pageIndex = ref(1)
const pageSize = ref(10)
const statsDialogVisible = ref(false)
const selectedOrderId = ref<number | null>(null)
const statsPageIndex = ref(1)
const statsPageSize = ref(10)
const statsDistTotal = ref(0)
const tableRows = ref<AllocationOrderFinalDestinationStatItem[]>([])
const statsSummary = ref<Partial<AllocationOrderFinalDestinationStatsPayload>>({})
const queryForm = reactive<{
fromOrgId: string | number | null
toOrgId: string | number | null
}>({
fromOrgId: null,
toOrgId: null
})
const statsDialogTitle = computed(() => {
const orderNo = statsSummary.value.orderNo
return orderNo ? `卡最终去向 - ${orderNo}` : '卡最终去向'
})
function parsePositiveInt (val: unknown): number | null {
if (val == null || val === '') return null
const n = Number.parseInt(String(val).trim(), 10)
if (Number.isNaN(n) || n <= 0) return null
return n
}
function formatCell (val: unknown): string {
if (val == null || val === '') return '-'
return String(val)
}
function formatCount (val: unknown): string {
const n = Number(val)
if (Number.isNaN(n)) return '-'
return Math.trunc(n).toLocaleString('zh-CN')
}
function formatOrgType (t: unknown): string {
if (t == null || t === '') return '-'
const key = String(t).trim().toUpperCase()
return ORG_TYPE_LABEL[key] ?? String(t)
}
function formatOrgCell (name: unknown, type: unknown): string {
const n = formatCell(name)
const t = formatOrgType(type)
if (n === '-' && t === '-') return '-'
if (t === '-') return n
return `${n}(${t})`
}
function formatOrderStatus (status: unknown): string {
if (status == null || status === '') return '-'
const key = String(status).trim().toUpperCase()
return ORDER_STATUS_LABEL[key] ?? String(status)
}
function formatDateTime (val: unknown): string {
if (val == null || val === '') return '-'
const s = String(val).trim()
return s.replace('T', ' ').slice(0, 19)
}
function formatPercent (count: unknown): string {
const total = Number(statsSummary.value.cardTotalCount ?? statsSummary.value.totalCount ?? 0)
const n = Number(count)
if (Number.isNaN(n) || total <= 0) return '-'
return `${((n / total) * 100).toFixed(1)}%`
}
function getSummaries ({ data }: { data: AllocationOrderFinalDestinationStatItem[] }): string[] {
const rows = data || []
const pageSum = rows.reduce((sum, row) => {
const n = Number(row.count)
return sum + (Number.isNaN(n) ? 0 : n)
}, 0)
const total = Number(statsSummary.value.cardTotalCount ?? statsSummary.value.totalCount ?? 0)
const pagePercent = total > 0 ? `${((pageSum / total) * 100).toFixed(1)}%` : '-'
return ['本页合计', '', '', '', formatCount(pageSum), pagePercent]
}
function extractSearchPayload (
res: ApiResponse<AllocationOrderSearchPayload> & AllocationOrderSearchPayload
): { items: AllocationOrderSearchItem[], totalCount: number } | null {
const nested = res.data
const source = (nested != null && typeof nested === 'object' ? nested : res) as AllocationOrderSearchPayload
if (Array.isArray(source.page?.list)) {
return {
items: source.page.list,
totalCount: Number(source.page.totalCount ?? source.totalCount ?? source.page.list.length) || 0
}
}
if (Array.isArray(source.items)) {
return {
items: source.items,
totalCount: Number(source.totalCount ?? source.items.length) || 0
}
}
if (Array.isArray(res.items)) {
return {
items: res.items,
totalCount: Number(res.totalCount ?? res.items.length) || 0
}
}
return null
}
function extractStatsPayload (
res: ApiResponse<AllocationOrderFinalDestinationStatsPayload> & AllocationOrderFinalDestinationStatsPayload
): {
allocationOrderId: number
orderNo: string
fromOrgId: number
toOrgId: number
cardTotalCount: number
totalCountOnOrder?: number
items: AllocationOrderFinalDestinationStatItem[]
distTotal: number
} | null {
const nested = res.data
const source = (
nested != null && typeof nested === 'object' && nested.allocationOrderId != null
? nested
: res.allocationOrderId != null
? res
: null
) as AllocationOrderFinalDestinationStatsPayload | null
if (source == null || source.allocationOrderId == null) return null
let items: AllocationOrderFinalDestinationStatItem[] = []
let distTotal = 0
if (Array.isArray(source.page?.list)) {
items = source.page.list
distTotal = Number(source.page.totalCount ?? items.length) || 0
} else if (Array.isArray(source.items)) {
items = source.items
distTotal = Number(source.totalCount ?? source.items.length) || 0
}
const cardTotalCount = Number(source.cardTotalCount ?? source.totalCount ?? 0) || 0
return {
allocationOrderId: source.allocationOrderId,
orderNo: source.orderNo ?? '',
fromOrgId: source.fromOrgId ?? 0,
toOrgId: source.toOrgId ?? 0,
cardTotalCount,
totalCountOnOrder: source.totalCountOnOrder,
items,
distTotal
}
}
function buildSearchParams (): AllocationOrderSearchParams | null {
const fromOrgId = parsePositiveInt(queryForm.fromOrgId)
const toOrgId = parsePositiveInt(queryForm.toOrgId)
if (fromOrgId == null && toOrgId == null) return null
const params: AllocationOrderSearchParams = {
page: pageIndex.value,
limit: pageSize.value
}
if (fromOrgId != null) params.fromOrgId = fromOrgId
if (toOrgId != null) params.toOrgId = toOrgId
return params
}
function resetStats (): void {
selectedOrderId.value = null
statsPageIndex.value = 1
statsDistTotal.value = 0
tableRows.value = []
statsSummary.value = {}
statsDialogVisible.value = false
}
function onStatsDialogClosed (): void {
selectedOrderId.value = null
statsPageIndex.value = 1
statsDistTotal.value = 0
tableRows.value = []
statsSummary.value = {}
}
function fetchOrderList (): void {
const params = buildSearchParams()
if (!params) {
ElMessage.warning('流出组织与流入组织至少选择一个')
return
}
searchLoading.value = true
searchAllocationOrders(params)
.then((res: AxiosResponse<ApiResponse<AllocationOrderSearchPayload>>) => {
const { data } = res
if (data == null || Number(data.code) !== 0) {
orderList.value = []
orderTotal.value = 0
ElMessage.error(data?.msg || '搜索失败')
return
}
const payload = extractSearchPayload(data as ApiResponse<AllocationOrderSearchPayload> & AllocationOrderSearchPayload)
orderList.value = payload?.items ?? []
orderTotal.value = payload?.totalCount ?? 0
if (!orderList.value.length) {
ElMessage.info('未找到匹配的分配单')
}
})
.catch(() => {
orderList.value = []
orderTotal.value = 0
ElMessage.error('搜索失败')
})
.finally(() => {
searchLoading.value = false
})
}
function handleSearchOrders (): void {
pageIndex.value = 1
resetStats()
fetchOrderList()
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
resetStats()
fetchOrderList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
resetStats()
fetchOrderList()
}
function fetchStats (orderId: number, resetPage = false): void {
if (resetPage) {
statsPageIndex.value = 1
statsSummary.value = {}
statsDistTotal.value = 0
}
selectedOrderId.value = orderId
statsLoading.value = true
tableRows.value = []
getAllocationOrderFinalDestinationStats({
allocationOrderId: orderId,
page: statsPageIndex.value,
limit: statsPageSize.value
})
.then((res: AxiosResponse<ApiResponse<AllocationOrderFinalDestinationStatsPayload>>) => {
const { data } = res
if (data == null || Number(data.code) !== 0) {
statsDialogVisible.value = false
resetStats()
ElMessage.error(data?.msg || '统计失败')
return
}
const payload = extractStatsPayload(
data as ApiResponse<AllocationOrderFinalDestinationStatsPayload> & AllocationOrderFinalDestinationStatsPayload
)
if (!payload) {
statsDialogVisible.value = false
resetStats()
ElMessage.error('响应数据格式异常')
return
}
selectedOrderId.value = orderId
statsSummary.value = {
allocationOrderId: payload.allocationOrderId,
orderNo: payload.orderNo,
fromOrgId: payload.fromOrgId,
toOrgId: payload.toOrgId,
cardTotalCount: payload.cardTotalCount,
totalCountOnOrder: payload.totalCountOnOrder
}
statsDistTotal.value = payload.distTotal
tableRows.value = payload.items
})
.catch(() => {
statsDialogVisible.value = false
resetStats()
ElMessage.error('统计失败')
})
.finally(() => {
statsLoading.value = false
})
}
function statsSizeChangeHandle (val: number): void {
if (selectedOrderId.value == null) return
statsPageSize.value = val
statsPageIndex.value = 1
fetchStats(selectedOrderId.value)
}
function statsCurrentChangeHandle (val: number): void {
if (selectedOrderId.value == null) return
statsPageIndex.value = val
fetchStats(selectedOrderId.value)
}
function handleViewStats (row: AllocationOrderSearchItem): void {
if (row?.id == null) return
selectedOrderId.value = row.id
statsDialogVisible.value = true
fetchStats(Number(row.id), true)
}
</script>
<style lang="scss" scoped>
.mod-allocation-order-final-destination-stats {
.stats-card {
:deep(.el-card__header) {
font-weight: 600;
}
}
.stats-tip {
margin-bottom: 16px;
}
.stats-toolbar {
margin-bottom: 16px;
}
.section-title {
margin: 16px 0 10px;
font-size: 14px;
font-weight: 600;
color: #303133;
padding-left: 8px;
border-left: 4px solid #409eff;
}
.order-list-wrap {
margin-bottom: 8px;
}
.order-pagination {
margin-top: 12px;
justify-content: flex-end;
}
.stats-table {
margin-top: 4px;
}
}
.stats-dialog {
.section-title {
margin: 16px 0 10px;
font-size: 14px;
font-weight: 600;
color: #303133;
padding-left: 8px;
border-left: 4px solid #409eff;
}
.stats-summary {
margin-bottom: 16px;
}
.stats-pagination {
margin-top: 12px;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,452 @@
<template>
<div class="mod-card-flow">
<el-tabs v-model="activeTab">
<el-tab-pane label="分配/销售" name="allocate">
<el-card shadow="never">
<el-form :model="allocateForm" :rules="allocateRules" ref="allocateFormRef" label-width="140px" style="max-width: 520px;">
<el-form-item label="销售方" prop="allocateViaParentId">
<el-tree-select
v-model="allocateForm.allocateViaParentId"
:data="sellerTreeSelectData"
:props="{ label: 'label', value: 'id', disabled: 'disabled' }"
node-key="id"
placeholder="按组织分级选择(不含门店)"
filterable
default-expand-all
:render-after-expand="false"
check-strictly
style="width: 100%"
@focus="loadOrgOptions" />
</el-form-item>
<el-form-item label="购买方" prop="toChannelOrgId">
<el-select
v-model="allocateForm.toChannelOrgId"
:placeholder="toOrgPlaceholder"
:disabled="allocateForm.allocateViaParentId == null || allocateForm.allocateViaParentId === ''"
filterable
style="width: 100%"
@focus="loadOrgOptions">
<el-option
v-for="item in toOrgSelectOptions"
:key="item.id"
:label="`${item.name}(${item.typeLabel})`"
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="卡类型" prop="cardType">
<el-select v-model="allocateForm.cardType" placeholder="请选择" style="width: 100%">
<el-option label="年卡" value="YEAR" />
<el-option label="季卡" value="QUARTER" />
<el-option label="月卡" value="MONTH" />
<el-option label="周卡" value="WEEK" />
<el-option label="其他" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="分配数量" prop="count">
<el-input-number v-model="allocateForm.count" :min="1" :max="9999" controls-position="right" style="width: 160px" />
</el-form-item>
<el-form-item label="激活码(可选)" prop="activationCode">
<el-input v-model="allocateForm.activationCode" placeholder="分配到门店且不填时系统自动生成" style="width: 100%" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="allocateLoading" @click="submitAllocate">提交分配/销售</el-button>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
<el-tab-pane label="转合同" name="transfer">
<el-card shadow="never">
<el-form :model="transferForm" :rules="transferRules" ref="transferFormRef" label-width="140px" style="max-width: 520px;">
<el-form-item label="卡ID列表" prop="cardIds">
<el-input
v-model="transferForm.cardIdsStr"
type="textarea"
:rows="3"
placeholder="多个卡ID用逗号或换行分隔,如:1,2,3"
@blur="parseCardIds" />
</el-form-item>
<el-form-item label="目标门店" prop="toStoreOrgId">
<el-select
v-model="transferForm.toStoreOrgId"
placeholder="请选择目标门店"
filterable
style="width: 100%"
@focus="loadStoreOptions">
<el-option
v-for="item in storeOptions"
:key="item.id"
:label="`${item.name}(${item.code || item.id})`"
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="transferLoading" @click="submitTransfer">提交转合同</el-button>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onActivated } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { getOrgList } from '@/api/org'
import { allocateCard, transferCard } from '@/api/cardFlow'
import { treeDataTranslate } from '@/utils'
defineOptions({ name: 'CardFlow' })
const userStore = useUserStore()
const isPlatform = computed(() => userStore.isPlatform)
const activeTab = ref('allocate')
const orgListAll = ref([])
const storeOptions = ref([])
const TYPE_LABEL = { PLATFORM: '平台', CHANNEL: '渠道', SERVICE_PROVIDER: '代理商', STORE: '门店' }
const allocateForm = reactive({
allocateViaParentId: null,
toChannelOrgId: null,
cardType: 'YEAR',
count: 1,
activationCode: ''
})
const allocateRules = {
allocateViaParentId: [{ required: true, message: '请选择销售方(须为平台/渠道/代理商)', trigger: 'change' }],
toChannelOrgId: [{ required: true, message: '请选择购买方(直属下一级,且不能为门店)', trigger: 'change' }],
cardType: [{ required: true, message: '请选择卡类型', trigger: 'change' }],
count: [{ required: true, message: '请输入分配数量', trigger: 'blur' }]
}
const allocateLoading = ref(false)
const allocateFormRef = ref(null)
const transferForm = reactive({
cardIds: [],
cardIdsStr: '',
toStoreOrgId: null
})
const transferRules = {
cardIds: [{ required: true, message: '请输入卡ID', trigger: 'change', type: 'array', min: 1 }],
toStoreOrgId: [{ required: true, message: '请选择目标门店', trigger: 'change' }]
}
const transferLoading = ref(false)
const transferFormRef = ref(null)
function withTypeLabel (item) {
return {
...item,
typeLabel: TYPE_LABEL[String(item.type || '').toUpperCase()] || item.type
}
}
/** 销售方仅允许平台 / 渠道 / 代理商 */
function isSellerOrgRow (o) {
const t = String(o.type || '').toUpperCase()
return t === 'PLATFORM' || t === 'CHANNEL' || t === 'SERVICE_PROVIDER'
}
/** 购买方不允许为门店 */
function isStoreOrgRow (o) {
return String(o.type || '').toUpperCase() === 'STORE'
}
/** 列表中的平台组织 id */
const platformOrgId = computed(() => {
const hit = orgListAll.value.find((o) => String(o.type || '').toUpperCase() === 'PLATFORM')
return hit?.id ?? null
})
function parentKeyOfRow (o) {
const p = o.parentId
if (p == null || p === '' || String(p) === '0') return null
return String(p)
}
function collectSubtreeIncludingSelfRows (list, rootId) {
if (rootId == null || rootId === '') return []
const byParent = new Map()
for (const o of list) {
const pk = parentKeyOfRow(o)
if (!byParent.has(pk)) byParent.set(pk, [])
byParent.get(pk).push(o)
}
const out = []
const seen = new Set()
const q = [String(rootId)]
while (q.length) {
const id = q.shift()
if (seen.has(id)) continue
seen.add(id)
const node = list.find((n) => String(n.id) === id)
if (!node) continue
out.push(node)
const kids = byParent.get(id) || []
for (const k of kids) q.push(String(k.id))
}
return out
}
function mergeSubtreesFromTopRootsRows (list) {
const roots = list.filter((o) => parentKeyOfRow(o) == null)
const m = new Map()
for (const r of roots) {
for (const n of collectSubtreeIncludingSelfRows(list, r.id)) {
m.set(String(n.id), n)
}
}
return [...m.values()]
}
/** 可选销售方范围:非平台为当前组织子树;平台为平台节点子树(平台库存) */
const allocateScopeRootId = computed(() => {
if (!isPlatform.value) {
return userStore.orgId
}
const p = platformOrgId.value
if (p != null) return p
const roots = orgListAll.value.filter((o) => parentKeyOfRow(o) == null)
return roots.length === 1 ? roots[0].id : null
})
/** 从扁平列表中去掉门店,并把 parent 链路上连续的门店跳过 */
function flatListHidingStoresForSellerTree (flatList) {
if (!flatList || !flatList.length) return []
const storeIds = new Set(
flatList.filter((o) => isStoreOrgRow(o)).map((o) => String(o.id))
)
if (!storeIds.size) {
return flatList.map((n) => ({ ...n, parentId: n.parentId }))
}
const byId = new Map(flatList.map((o) => [String(o.id), o]))
function climbPastStores (pid) {
let p = pid
while (
p != null &&
p !== '' &&
String(p) !== '0' &&
storeIds.has(String(p))
) {
const parent = byId.get(String(p))
if (!parent) break
p = parent.parentId
}
if (p == null || p === '' || String(p) === '0') return undefined
return p
}
return flatList
.filter((o) => !isStoreOrgRow(o))
.map((o) => {
const newPid = climbPastStores(o.parentId)
return { ...o, parentId: newPid }
})
}
/** 销售方:树形分级展示;门店已从数据中移除 */
function decorateSellerTreeForCardFlow (nodes) {
if (!nodes || !Array.isArray(nodes)) return []
return nodes.map((node) => {
const wt = withTypeLabel(node)
const label = `${wt.name}(${wt.typeLabel})`
const disabled = !isSellerOrgRow(node)
const item = { ...wt, label, disabled }
if (node.children && node.children.length > 0) {
item.children = decorateSellerTreeForCardFlow(node.children)
}
return item
})
}
const sellerTreeSelectData = computed(() => {
const list = orgListAll.value
if (!list.length) return []
const scope = allocateScopeRootId.value
let ancestorNodes
if (scope != null && scope !== '') {
ancestorNodes = collectSubtreeIncludingSelfRows(list, scope)
} else {
ancestorNodes = mergeSubtreesFromTopRootsRows(list)
}
if (!ancestorNodes.length) return []
const withoutStores = flatListHidingStoresForSellerTree(ancestorNodes)
if (!withoutStores.length) return []
const tree = treeDataTranslate(withoutStores, 'id', 'parentId')
return decorateSellerTreeForCardFlow(tree)
})
function firstSelectableSellerIdInTree (nodes) {
if (!nodes || !Array.isArray(nodes)) return null
for (const n of nodes) {
if (!n.disabled) return n.id
const sub = firstSelectableSellerIdInTree(n.children)
if (sub != null) return sub
}
return null
}
const ancestorSellerIds = computed(() => {
const ids = []
function walk (nodes) {
if (!nodes) return
for (const n of nodes) {
if (!n.disabled) ids.push(String(n.id))
walk(n.children)
}
}
walk(sellerTreeSelectData.value)
return new Set(ids)
})
function directChildrenRows (parentKey) {
const list = orgListAll.value
if (!list.length || parentKey == null || parentKey === '') return []
return list.filter((o) => String(o.parentId) === String(parentKey))
}
const toOrgSelectOptions = computed(() =>
directChildrenRows(allocateForm.allocateViaParentId)
.filter((o) => !isStoreOrgRow(o))
.map(withTypeLabel)
)
const toOrgPlaceholder = computed(() =>
allocateForm.allocateViaParentId == null || allocateForm.allocateViaParentId === ''
? '请先选择销售方'
: '直属下一级(购买方),不含门店'
)
watch(() => allocateForm.allocateViaParentId, () => {
allocateForm.toChannelOrgId = null
})
watch([allocateScopeRootId, () => orgListAll.value.length], () => {
if (!orgListAll.value.length) return
const allowedA = ancestorSellerIds.value
if (!allocateForm.allocateViaParentId || !allowedA.has(String(allocateForm.allocateViaParentId))) {
let def = allocateScopeRootId.value
if ((def == null || def === '') && isPlatform.value) {
const roots = orgListAll.value.filter((o) => parentKeyOfRow(o) == null)
def = roots[0]?.id ?? null
}
if (!allowedA.has(String(def))) {
def = firstSelectableSellerIdInTree(sellerTreeSelectData.value)
}
allocateForm.allocateViaParentId = def
}
allocateForm.toChannelOrgId = null
})
function loadOrgOptions () {
if (orgListAll.value.length > 0) return
getOrgList({ limit: 500 }).then(({ data }) => {
const list = (data && data.code === 0 && data.page) ? data.page.list : (data?.list || [])
orgListAll.value = list || []
})
}
function loadStoreOptions () {
if (storeOptions.value.length > 0) return
getOrgList({ type: 'STORE', limit: 500 }).then(({ data }) => {
const list = (data && data.code === 0 && data.page) ? data.page.list : (data?.list || [])
storeOptions.value = list || []
})
}
function submitAllocate () {
if (userStore.isStore) {
ElMessage.warning('门店账号不支持分配/销售')
return
}
if (!isPlatform.value) {
const oid = userStore.orgId
if (oid == null || oid === '' || String(oid) === '0') {
ElMessage.warning('当前账号未关联组织')
return
}
}
allocateFormRef.value?.validate((valid) => {
if (!valid) return
const allowedA = ancestorSellerIds.value
if (!allowedA.has(String(allocateForm.allocateViaParentId))) {
ElMessage.warning('销售方须为权限范围内的平台/渠道/代理商')
return
}
const allowedTo = new Set(toOrgSelectOptions.value.map((o) => String(o.id)))
if (!allowedTo.has(String(allocateForm.toChannelOrgId))) {
ElMessage.warning('购买方须为所选销售方的直属下一级,且不能为门店')
return
}
const payload = {
toChannelOrgId: Number(allocateForm.toChannelOrgId),
cardType: allocateForm.cardType,
count: allocateForm.count
}
if (allocateForm.activationCode && String(allocateForm.activationCode).trim()) {
payload.activationCode = allocateForm.activationCode.trim()
}
allocateLoading.value = true
allocateCard(payload)
.then(({ data }) => {
if (data && data.code === 0) {
ElMessage.success(`操作成功,订单号:${data.orderNo || '-'},分配数量:${data.allocatedCount ?? '-'}`)
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
.catch(() => ElMessage.error('请求失败'))
.finally(() => { allocateLoading.value = false })
})
}
function parseCardIds () {
const str = String(transferForm.cardIdsStr || '').trim()
if (!str) {
transferForm.cardIds = []
return
}
const ids = str.split(/[\s,,、]+/).map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n > 0)
transferForm.cardIds = [...new Set(ids)]
}
function submitTransfer () {
parseCardIds()
if (transferForm.cardIds.length === 0) {
ElMessage.warning('请输入有效的卡ID')
return
}
transferFormRef.value?.validate((valid) => {
if (!valid) return
const payload = {
cardIds: transferForm.cardIds,
toStoreOrgId: transferForm.toStoreOrgId
}
transferLoading.value = true
transferCard(payload)
.then(({ data }) => {
if (data && data.code === 0) {
ElMessage.success('转合同成功')
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
.catch(() => ElMessage.error('请求失败'))
.finally(() => { transferLoading.value = false })
})
}
onActivated(() => {
loadOrgOptions()
loadStoreOptions()
allocateForm.allocateViaParentId = null
allocateForm.toChannelOrgId = null
})
</script>
<style lang="scss" scoped>
.mod-card-flow {
padding: 0;
}
</style>

View File

@@ -0,0 +1,460 @@
<template>
<div class="mod-card-import-batches">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.batchNo" placeholder="批次号(模糊)" clearable style="width: 180px" />
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.remark" placeholder="备注(模糊)" clearable style="width: 160px" />
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="批次状态" clearable style="width: 140px">
<el-option
v-for="opt in BATCH_STATUS_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getDataList()">查询</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
row-key="id"
highlight-current-row
class="batch-table"
@row-click="onBatchRowClick"
style="width: 100%">
<el-table-column header-align="center" align="center" width="70" label="序号">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="batchNo" header-align="center" align="center" min-width="160" label="批次号" show-overflow-tooltip>
<template #default="scope">
<span class="cell-link" @click.stop="openCardsDialog(scope.row)">
{{ scope.row.batchNo ?? scope.row.batch_no ?? '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="cardType" header-align="center" align="center" width="100" label="卡类型">
<template #default="scope">
<template v-if="cardTypeUi(scope.row)">
<el-tag :type="cardTypeUi(scope.row)!.tagType" size="small">{{ cardTypeUi(scope.row)!.label }}</el-tag>
</template>
<span v-else>{{ cardTypeNameOnly(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column prop="importCount" header-align="center" align="center" width="120" label="导入数量">
<template #default="scope">
{{ importCountDisplay(scope.row) }}
</template>
</el-table-column>
<el-table-column prop="remark" header-align="center" align="left" min-width="140" label="备注" show-overflow-tooltip>
<template #default="scope">
{{ remarkDisplay(scope.row) }}
</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="165" label="创建时间">
<template #default="scope">
{{ scope.row.createdAt || '-' }}
</template>
</el-table-column>
<el-table-column fixed="right" header-align="center" align="center" width="150" label="操作">
<template #default="scope">
<el-button
link
type="warning"
size="small"
:loading="rollbackSubmitting && rollbackSubmittingId === scope.row.id"
@click.stop="confirmRollbackInvalidBatch(scope.row)">
无效卡回退渠道
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<el-dialog
v-model="cardsDialogVisible"
:title="cardsDialogTitle"
width="min(1160px, 96vw)"
destroy-on-close
class="batch-cards-dialog"
align-center
@closed="onCardsDialogClosed">
<div class="batch-cards-dialog-body">
<el-form :inline="true" :model="cardFilterForm" @keyup.enter="getCardList()">
<el-form-item>
<el-select v-model="cardFilterForm.usageStatus" placeholder="使用状态" clearable style="width: 130px">
<el-option label="未使用" value="UNUSED" />
<el-option label="已绑定" value="BOUND" />
<el-option label="激活校验中" value="ACTIVATING" />
<el-option label="激活失败" value="ACTIVATION_FAILED" />
<el-option label="已激活" value="ACTIVE" />
<el-option label="待迁移" value="WAITING_MIGRATION" />
<el-option label="已过期" value="EXPIRED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getCardList()">查询</el-button>
</el-form-item>
</el-form>
<div class="card-table-wrap">
<el-table
:data="cardList"
border
v-loading="cardListLoading"
row-key="id"
max-height="480"
style="width: 100%">
<el-table-column header-align="center" align="center" width="60" label="序号">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="cardNo" header-align="center" align="center" min-width="120" label="卡号" show-overflow-tooltip />
<el-table-column prop="cardType" header-align="center" align="center" width="80" label="卡类型">
<template #default="scope">
<template v-if="cardTypeUi(scope.row)">
<el-tag :type="cardTypeUi(scope.row)!.tagType" size="small">{{ cardTypeUi(scope.row)!.label }}</el-tag>
</template>
<span v-else>{{ cardTypeNameOnly(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column prop="usageStatus" header-align="center" align="center" width="100" label="使用状态">
<template #default="scope">
<el-tag v-if="scope.row.usageStatus === 'UNUSED'" size="small">未使用</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'BOUND'" size="small" type="info">已绑定</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'ACTIVE'" size="small" type="success">已激活</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'EXPIRED'" size="small" type="danger">已过期</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'ACTIVATION_FAILED'" size="small" type="danger">激活失败</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'ACTIVATING'" size="small" type="danger">激活中</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'WAITING_MIGRATION'" size="small" type="danger">待迁移</el-tag>
<span v-else>{{ scope.row.usageStatus || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="orgName" header-align="center" align="center" width="110" label="所属组织" show-overflow-tooltip />
<el-table-column prop="expireAt" header-align="center" align="center" width="165" label="过期时间">
<template #default="scope">
{{ scope.row.expireAt || '-' }}
</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="165" label="创建时间">
<template #default="scope">
{{ scope.row.createdAt || '-' }}
</template>
</el-table-column>
</el-table>
<el-pagination
class="card-pagination"
@size-change="cardSizeChange"
@current-change="cardPageChange"
:current-page="cardPageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="cardPageSize"
:total="cardTotalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getImportBatchList, getImportBatchCards } from '@/api/card'
import type { CardInfo } from '@/api/card'
import { rollbackInvalidImportBatchToChannel } from '@/api/cardFlow'
import type { PageParams } from '@/types/api'
import type {
BatchStatusOption,
CardTypeUiItem,
CardFilterForm,
CurrentBatch,
DataForm,
ImportBatchCardsPayload,
ImportBatchListParams,
ImportBatchListPayload,
ImportBatchRow,
RollbackInvalidBatchPayload
} from './data'
defineOptions({ name: 'CardImportBatches' })
/** 与 card-detail / 卡列表 cardType 编码一致 */
const CARD_TYPE_UI: Record<string, CardTypeUiItem> = {
YEAR: { label: '年', tagType: undefined },
QUARTER: { label: '季', tagType: 'success' },
MONTH: { label: '月', tagType: 'warning' },
WEEK: { label: '周', tagType: 'info' },
OTHER: { label: '天', tagType: 'info' },
DAY: { label: '天', tagType: 'info' }
}
function cardTypeCode (row: ImportBatchRow | CardInfo): string {
const t = row.cardType
if (t == null || String(t).trim() === '') return ''
return String(t).trim()
}
function cardTypeUi (row: ImportBatchRow | CardInfo): CardTypeUiItem | null {
const code = cardTypeCode(row)
if (!code) return null
if (CARD_TYPE_UI[code]) return CARD_TYPE_UI[code]
return { label: code, tagType: 'info' }
}
function cardTypeNameOnly (row: ImportBatchRow | CardInfo): string {
const n = row.cardTypeName
if (n != null && String(n).trim() !== '') return String(n).trim()
return '-'
}
/** 与后端 import_batch.status 取值保持一致,可按需增删 */
const BATCH_STATUS_OPTIONS: BatchStatusOption[] = [
{ value: 'SUCCESS', label: '成功' },
{ value: 'FAILED', label: '失败' },
{ value: 'PROCESSING', label: '处理中' },
{ value: 'PENDING', label: '待处理' }
]
const dataForm = reactive<DataForm>({
batchNo: '',
remark: '',
status: ''
})
const dataList = ref<ImportBatchRow[]>([])
const pageIndex = ref(1)
const pageSize = ref(10)
const totalPage = ref(0)
const dataListLoading = ref(false)
const cardsDialogVisible = ref(false)
const currentBatch = ref<CurrentBatch>({ id: null, batchNo: '' })
const cardsDialogTitle = computed(() => {
const b = currentBatch.value
if (!b.id) return '批次内卡列表'
if (b.batchNo) return `批次内卡列表(${b.batchNo})`
return '批次内卡列表'
})
const cardFilterForm = reactive<CardFilterForm>({
usageStatus: ''
})
const cardList = ref<CardInfo[]>([])
const cardPageIndex = ref(1)
const cardPageSize = ref(10)
const cardTotalPage = ref(0)
const cardListLoading = ref(false)
const rollbackSubmitting = ref(false)
const rollbackSubmittingId = ref<number | string | null>(null)
function confirmRollbackInvalidBatch (row: ImportBatchRow): void {
const importBatchId = row.id
if (importBatchId == null || importBatchId === '') {
ElMessage.warning('缺少导入批次')
return
}
ElMessageBox.confirm('确定对该导入批次执行无效卡回退渠道?', '提示', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
.then(() => {
rollbackSubmitting.value = true
rollbackSubmittingId.value = importBatchId
return rollbackInvalidImportBatchToChannel({ importBatchId: Number(importBatchId) })
})
.then((res) => {
const data = res.data as RollbackInvalidBatchPayload
if (data && data.code === 0) {
ElMessage.success(data.msg || '操作成功')
getDataList()
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
.catch((err: unknown) => {
if (err === 'cancel' || err === 'close') return
ElMessage.error('请求失败')
})
.finally(() => {
rollbackSubmitting.value = false
rollbackSubmittingId.value = null
})
}
/** 与后端 import_batch 导入条数字段对齐 */
function importCountDisplay (row: ImportBatchRow): number | string {
const n: unknown =
row.totalCount ??
row.importCount ??
row.cardCount
if (n == null || n === '') return '-'
return n as number | string
}
/** 与后端 import_batch 备注字段对齐 */
function remarkDisplay (row: ImportBatchRow): string {
const t = row.remark ?? row.remarkNote ?? row.note ?? row.memo
if (t == null || String(t).trim() === '') return '-'
return String(t).trim()
}
function getDataList (): void {
dataListLoading.value = true
const params: ImportBatchListParams = {
page: pageIndex.value,
limit: pageSize.value,
batchNo: dataForm.batchNo || undefined,
remark: dataForm.remark || undefined,
status: dataForm.status || undefined
}
getImportBatchList(params)
.then(({ data }) => {
const body = data as unknown as ImportBatchListPayload
if (body && body.code === 0 && body.page) {
dataList.value = body.page.list || []
totalPage.value = body.page.totalCount || 0
} else if (body && Array.isArray(body.list)) {
dataList.value = body.list
totalPage.value = body.totalCount ?? body.list.length
} else {
dataList.value = []
totalPage.value = 0
}
})
.catch(() => {})
.finally(() => { dataListLoading.value = false })
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function resetCardFilters (): void {
cardFilterForm.usageStatus = ''
cardPageIndex.value = 1
cardPageSize.value = 10
}
function openCardsDialog (row: ImportBatchRow): void {
const id = row.id
const batchNo = row.batchNo ?? row.batch_no ?? ''
currentBatch.value = { id: id ?? null, batchNo: batchNo ? String(batchNo) : '' }
resetCardFilters()
cardsDialogVisible.value = true
getCardList()
}
function onBatchRowClick (row: ImportBatchRow): void {
openCardsDialog(row)
}
function getCardList (): void {
const bid = currentBatch.value.id
if (bid == null || bid === '') {
cardList.value = []
cardTotalPage.value = 0
return
}
cardListLoading.value = true
const params: PageParams = {
page: cardPageIndex.value,
limit: cardPageSize.value,
usageStatus: cardFilterForm.usageStatus || undefined
}
getImportBatchCards(bid, params)
.then(({ data }) => {
const body = data as unknown as ImportBatchCardsPayload
if (body && body.code === 0 && body.page) {
cardList.value = body.page.list || []
cardTotalPage.value = body.page.totalCount || 0
} else if (body && Array.isArray(body.list)) {
cardList.value = body.list
cardTotalPage.value = body.totalCount ?? body.list.length
} else {
cardList.value = []
cardTotalPage.value = 0
}
})
.catch(() => {})
.finally(() => { cardListLoading.value = false })
}
function cardSizeChange (val: number): void {
cardPageSize.value = val
cardPageIndex.value = 1
getCardList()
}
function cardPageChange (val: number): void {
cardPageIndex.value = val
getCardList()
}
function onCardsDialogClosed (): void {
currentBatch.value = { id: null, batchNo: '' }
cardList.value = []
cardTotalPage.value = 0
}
onMounted(() => {
getDataList()
})
</script>
<style lang="scss" scoped>
.mod-card-import-batches {
.table-page-wrap {
margin-top: 12px;
}
.batch-table {
:deep(.el-table__row) {
cursor: pointer;
}
}
.cell-link {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
.batch-cards-dialog-body {
.card-table-wrap {
margin-top: 8px;
}
.card-pagination {
margin-top: 12px;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,25 @@
<template>
<div class="mod-card-import">
<el-card shadow="never">
<template #header>
<span>批量导入卡</span>
</template>
<card-import-form
show-action-bar
@success="onImportSuccess" />
</el-card>
</div>
</template>
<script setup lang="ts">
import CardImportForm from './components/card-import-form.vue'
defineOptions({ name: 'CardImport' })
function goBack (): void {
}
function onImportSuccess (): void {
goBack()
}
</script>

302
src/views/modules/card/card.vue Executable file
View File

@@ -0,0 +1,302 @@
<template>
<div class="mod-card">
<el-card shadow="never" class="list-card">
<template #header>
<div class="card-header-inner">
<div class="card-header-title">
<span>卡列表</span>
<el-badge
v-if="nearExpiryUnusedCount > 0"
:value="nearExpiryUnusedCount"
:max="99"
class="near-expiry-badge">
<el-button
type="warning"
plain
size="small"
class="near-expiry-hint-btn"
@click="openNearExpiryUnusedCardsDialog">
<el-icon class="near-expiry-hint-btn__icon"><WarningFilled /></el-icon>
临期卡提醒
</el-button>
</el-badge>
<el-button
v-else
type="info"
plain
size="small"
class="near-expiry-hint-btn near-expiry-hint-btn--empty"
@click="openNearExpiryUnusedCardsDialog">
<el-icon class="near-expiry-hint-btn__icon"><WarningFilled /></el-icon>
临期卡提醒
</el-button>
</div>
</div>
</template>
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.cardNo" placeholder="卡号" clearable style="width: 160px" />
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.cardType" placeholder="卡类型" clearable style="width: 110px">
<el-option label="年" value="YEAR" />
<el-option label="季" value="QUARTER" />
<el-option label="月" value="MONTH" />
<el-option label="周" value="WEEK" />
<el-option label="天" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.usageStatus" placeholder="使用状态" clearable style="width: 120px">
<el-option label="未使用" value="UNUSED" />
<el-option label="已绑定" value="BOUND" />
<el-option label="激活校验中" value="ACTIVATING" />
<el-option label="激活失败" value="ACTIVATION_FAILED" />
<el-option label="已激活" value="ACTIVE" />
<el-option label="待迁移" value="WAITING_MIGRATION" />
<el-option label="已过期" value="EXPIRED" />
</el-select>
</el-form-item>
<el-form-item label="激活日期">
<el-date-picker
v-model="activationDateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
style="width: 280px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getDataList()">查询</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
row-key="id"
style="width: 100%">
<el-table-column header-align="center" align="center" width="70" label="序号">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="cardNo" header-align="center" align="center" min-width="120" label="卡号" show-overflow-tooltip />
<el-table-column prop="cardType" header-align="center" align="center" width="80" label="卡类型">
<template #default="scope">
<el-tag v-if="scope.row.cardType === 'YEAR'" size="small">年</el-tag>
<el-tag v-else-if="scope.row.cardType === 'QUARTER'" size="small" type="success">季</el-tag>
<el-tag v-else-if="scope.row.cardType === 'MONTH'" size="small" type="warning">月</el-tag>
<el-tag v-else-if="scope.row.cardType === 'WEEK'" size="small" type="info">周</el-tag>
<el-tag v-else size="small" type="info">天</el-tag>
</template>
</el-table-column>
<el-table-column prop="usageStatus" header-align="center" align="center" width="100" label="使用状态">
<template #default="scope">
<el-tag v-if="scope.row.usageStatus === 'UNUSED'" size="small">未使用</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'BOUND'" size="small" type="info">已绑定</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'ACTIVE'" size="small" type="success">已激活</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'EXPIRED'" size="small" type="danger">已过期</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'ACTIVATION_FAILED'" size="small" type="danger">激活失败</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'ACTIVATING'" size="small" type="danger">激活中</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'WAITING_MIGRATION'" size="small" type="danger">待迁移</el-tag>
<span v-else>{{ scope.row.usageStatus || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="orgName" header-align="center" align="center" width="110" label="所属组织" show-overflow-tooltip />
<el-table-column prop="expireAt" header-align="center" align="center" width="165" label="激活时间">
<template #default="scope">
{{ scope.row.cardDeviceBindingCreatedAt || scope.row.cardDeviceBindingCreatedAt || '-' }}
</template>
</el-table-column>
<el-table-column prop="expireAt" header-align="center" align="center" width="165" label="过期时间">
<template #default="scope">
{{ scope.row.expireAt || '-' }}
</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="165" label="创建时间">
<template #default="scope">
{{ scope.row.createdAt || '-' }}
</template>
</el-table-column>
<el-table-column fixed="right" header-align="center" align="center" width="140" label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="showDetail(scope.row.id)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
</el-card>
<card-detail v-model="detailVisible" :card-id="detailId" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { WarningFilled } from '@element-plus/icons-vue'
import CardDetail from './components/card-detail.vue'
import { getCardList } from '@/api/card'
import { useNearExpiryUnusedCards } from '@/composables/useNearExpiryUnusedCards'
import type { ApiResponse, PageResult } from '@/types/api'
import type { CardInfo, CardListParams } from '@/api/card'
defineOptions({ name: 'Card' })
interface DataForm {
cardNo: string
cardCode: string
cardType: string
inventoryStatus: string
usageStatus: string
}
/** 列表接口实际返回体(兼容 page / list 两种分页形态) */
type CardListPayload = ApiResponse<PageResult<CardInfo>> & {
page?: PageResult<CardInfo>
list?: CardInfo[]
totalCount?: number
}
const dataForm = reactive<DataForm>({
cardNo: '',
cardCode: '',
cardType: '',
inventoryStatus: '',
usageStatus: 'BOUND'
})
const dataList = ref<CardInfo[]>([])
const pageIndex = ref(1)
const pageSize = ref(10)
const totalPage = ref(0)
const dataListLoading = ref(false)
const detailVisible = ref(false)
const detailId = ref<number | null>(null)
const activationDateRange = ref<[string, string] | null>(null)
const {
nearExpiryUnusedCount,
fetchNearExpiryUnusedCards,
openNearExpiryUnusedCardsDialog
} = useNearExpiryUnusedCards()
function getDataList (): void {
dataListLoading.value = true
let beginActiveAt: string | undefined
let endActiveAt: string | undefined
if (Array.isArray(activationDateRange.value) && activationDateRange.value.length === 2) {
const [start, end] = activationDateRange.value
if (start) beginActiveAt = `${start} 00:00:00`
if (end) endActiveAt = `${end} 23:59:59`
}
const params: CardListParams = {
page: pageIndex.value,
limit: pageSize.value,
cardNo: dataForm.cardNo || undefined,
cardCode: dataForm.cardCode || undefined,
cardType: dataForm.cardType || undefined,
inventoryStatus: dataForm.inventoryStatus || undefined,
usageStatus: dataForm.usageStatus || undefined,
beginActiveAt: beginActiveAt || undefined,
endActiveAt: endActiveAt || undefined
}
getCardList(params)
.then((body) => {
const data = body as CardListPayload
if (data && data.code === 0 && data.page) {
dataList.value = data.page.list || []
totalPage.value = data.page.totalCount || 0
} else if (data && Array.isArray(data.list)) {
dataList.value = data.list
totalPage.value = data.totalCount ?? data.list.length
} else {
dataList.value = []
totalPage.value = 0
}
})
.catch(() => {})
.finally(() => { dataListLoading.value = false })
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function showDetail (id: number): void {
detailId.value = id
detailVisible.value = true
}
onMounted(() => {
getDataList()
void fetchNearExpiryUnusedCards()
})
</script>
<style lang="scss" scoped>
.mod-card {
.list-card {
margin-top: 0;
}
.card-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header-title {
display: flex;
align-items: center;
gap: 12px;
}
.near-expiry-badge {
:deep(.el-badge__content) {
top: 4px;
right: calc(1px + var(--el-badge-size) / 2);
}
}
.near-expiry-hint-btn {
&__icon {
margin-right: 4px;
}
&--empty {
opacity: 0.85;
}
}
.table-page-wrap {
margin-top: 12px;
}
.form-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<el-dialog
title="卡详情"
v-model="visible"
width="640px"
destroy-on-close
@closed="onClosed">
<div v-loading="loading">
<el-descriptions :column="1" border>
<el-descriptions-item label="序号">{{ info.id || '-' }}</el-descriptions-item>
<el-descriptions-item label="卡号">{{ info.cardNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="第三方引用">{{ info.thirdPartyRef || '-' }}</el-descriptions-item>
<el-descriptions-item label="卡类型">{{ cardTypeLabel }}</el-descriptions-item>
<el-descriptions-item label="第三方产品名称">{{ info.thirdPartyProductName || '-' }}</el-descriptions-item>
<el-descriptions-item label="库存状态">{{ inventoryStatusLabel }}</el-descriptions-item>
<el-descriptions-item label="分配场景">{{ allocationSceneLabel }}</el-descriptions-item>
<el-descriptions-item label="使用状态">{{ usageStatusLabel }}</el-descriptions-item>
<el-descriptions-item label="激活码预留池ID">{{ info.contractActivationCodeId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="所属组织ID">{{ info.orgId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="所属组织">{{ info.orgName || '-' }}</el-descriptions-item>
<el-descriptions-item label="分配单ID">{{ info.allocationOrderId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="导入批次ID">{{ info.importBatchId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="过期时间">{{ info.expireAt || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ info.createdAt || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ info.updatedAt || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { getCardInfo } from '@/api/card'
const props = withDefaults(
defineProps<{
modelValue?: boolean
cardId?: number | string | null
}>(),
{
modelValue: false,
cardId: null
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v)
})
const loading = ref(false)
/** 详情展示(兼容 snake_case) */
const info = ref<Record<string, unknown>>({})
const CARD_TYPE_MAP: Record<string, string> = { YEAR: '年', QUARTER: '季', MONTH: '月', WEEK: '周', OTHER: '天' }
const INVENTORY_STATUS_MAP: Record<string, string> = { IN_STOCK: '库存中', SOLD: '已归属' }
const ALLOCATION_SCENE_MAP: Record<string, string> = { NONE: '合同普通库存', ACTIVE_CODE: '激活码预留池', PRE_IMPORT: '预置导入绑定' }
const USAGE_STATUS_MAP: Record<string, string> = {
UNUSED: '未使用',
BOUND: '已绑定',
ACTIVATING: '激活校验中',
ACTIVATION_FAILED: '激活失败可重试',
ACTIVE: '已生效',
WAITING_MIGRATION: '待迁移',
EXPIRED: '已过期'
}
function mapLabel (m: Record<string, string>, key: unknown): string {
if (key == null || key === '') return '-'
const k = String(key)
return m[k] ?? k
}
const cardTypeLabel = computed(() => {
const t = info.value.cardType
return mapLabel(CARD_TYPE_MAP, t)
})
const inventoryStatusLabel = computed(() => {
const s = info.value.inventoryStatus
return mapLabel(INVENTORY_STATUS_MAP, s)
})
const allocationSceneLabel = computed(() => {
const s = info.value.allocationScene ?? info.value.allocation_scene
return mapLabel(ALLOCATION_SCENE_MAP, s)
})
const usageStatusLabel = computed(() => {
const s = info.value.usageStatus ?? info.value.usage_status
return mapLabel(USAGE_STATUS_MAP, s)
})
function loadDetail (): void {
if (props.cardId == null || props.cardId === '') return
loading.value = true
getCardInfo(props.cardId)
.then(({ data }) => {
const d = data as Record<string, unknown> & { code?: number; card?: Record<string, unknown>; data?: unknown }
const raw = (d && d.code === 0 && d.card) ? d.card : (d?.data ?? d ?? {})
info.value = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
})
.finally(() => { loading.value = false })
}
function onClosed (): void {
info.value = {}
}
watch(
() => [visible.value, props.cardId] as const,
([v, id]) => {
if (v && id != null && id !== '') loadDetail()
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
:deep(.el-descriptions__label) {
width: 140px;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<el-dialog
:model-value="modelValue"
title="批量导入卡"
width="900px"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="onClosed">
<card-import-form ref="formRef" @success="handleSuccess" />
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="formRef?.importLoading" :disabled="!formRef?.canSubmit" @click="formRef?.submitImport()">开始导入</el-button>
<el-button v-if="formRef?.importResult" type="primary" @click="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import CardImportForm from './card-import-form.vue'
defineOptions({ name: 'CardImportDialog' })
withDefaults(defineProps<{ modelValue?: boolean }>(), {
modelValue: false
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: []
}>()
const formRef = ref<InstanceType<typeof CardImportForm> | null>(null)
function onClosed (): void {
formRef.value?.reset?.()
}
function handleSuccess (): void {
emit('success')
}
function handleClose (): void {
emit('update:modelValue', false)
emit('success')
}
</script>

View File

@@ -0,0 +1,829 @@
<template>
<el-form :model="importForm" label-width="100px">
<el-form-item label="卡类型">
<el-select v-model="importForm.selectedCardType" placeholder="请选择" style="width: 160px" @change="onCardTypeChange">
<el-option label="年" value="YEAR" />
<el-option label="季" value="QUARTER" />
<el-option label="月" value="MONTH" />
<el-option label="周" value="WEEK" />
<el-option label="天" value="OTHER" />
</el-select>
<span class="form-tip">本批导入的卡将统一使用该类型</span>
</el-form-item>
<el-form-item label="卡列表">
<div class="card-items-toolbar">
<div class="toolbar-left">
<el-link type="primary" underline="never" @click="downloadImportTemplate">
<el-icon class="el-icon--left"><Download /></el-icon>
下载 Excel 模板
</el-link>
<el-upload
:auto-upload="false"
:show-file-list="false"
accept=".xlsx,.xls"
:on-change="onExcelChange"
style="display: inline-block; margin-left: 16px;">
<el-button type="primary" size="small">选择 Excel 导入</el-button>
</el-upload>
<el-button type="primary" size="small" class="ml-8px" @click="addCardItem">添加一行</el-button>
<el-button type="success" size="small" class="ml-8px" :disabled="importForm.items.length === 0" @click="exportToExcel">
<el-icon class="el-icon--left"><Download /></el-icon>
导出为 Excel
</el-button>
<el-button v-if="showActionBar" type="primary" size="small" class="ml-8px" :loading="importLoading" :disabled="!canSubmit" @click="submitImport">开始导入</el-button>
<el-select v-if="hasErrorRows" v-model="dataFilter" placeholder="数据筛选" size="small" style="width: 120px; margin-left: 8px;" @change="onDataFilterChange">
<el-option label="全部" value="all" />
<el-option label="正确数据" value="valid" />
<el-option label="错误数据" value="invalid" />
</el-select>
</div>
<el-button type="danger" size="small" plain :disabled="importForm.items.length === 0" @click="clearData">
清空数据
</el-button>
</div>
<el-alert
v-if="hasErrorRows"
type="error"
:title="`校验未通过,共 ${errorRowCount} 条存在错误`"
description="请根据表格内「原因」列修改后再导入"
show-icon
class="card-validate-alert" />
<el-table
:data="paginatedItems"
border
style="width: 100%; margin-top: 8px;"
:row-class-name="tableRowClassName">
<el-table-column type="index" label="#" width="50" align="center">
<template #default="{ $index }">
{{ (page - 1) * pageSize + $index + 1 }}
</template>
</el-table-column>
<el-table-column prop="cardNo" label="卡号" min-width="120">
<template #default="{ row }">
<el-input v-model="row.cardNo" placeholder="必填" size="small" :class="{ 'cell-error': row._validateStatus === 'validate_fail' }" />
</template>
</el-table-column>
<el-table-column prop="activationCode" label="激活码" min-width="120">
<template #default="{ row }">
<el-input v-model="row.activationCode" placeholder="必填" size="small" :class="{ 'cell-error': row._validateStatus === 'validate_fail' }" />
</template>
</el-table-column>
<el-table-column prop="businessCombo" label="业务组合" min-width="140">
<template #default="{ row }">
<el-input v-model="row.businessCombo" placeholder="选填" size="small" :class="{ 'cell-error': row._validateStatus === 'validate_fail' }" />
</template>
</el-table-column>
<el-table-column prop="cardDuration" label="卡密时长" min-width="100">
<template #default="{ row }">
<el-input v-model="row.cardDuration" placeholder="选填,如1天" size="small" :class="{ 'cell-error': row._validateStatus === 'validate_fail' }" />
</template>
</el-table-column>
<el-table-column prop="validityStart" label="有效期起始" min-width="160">
<template #default="{ row }">
<el-date-picker
v-model="row.validityStart"
type="datetime"
placeholder="必填"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm"
size="small"
style="width: 100%"
clearable />
</template>
</el-table-column>
<el-table-column prop="validityEnd" label="有效期截止" min-width="160">
<template #default="{ row }">
<el-date-picker
v-model="row.validityEnd"
type="datetime"
placeholder="必填"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm"
size="small"
style="width: 100%"
clearable />
</template>
</el-table-column>
<el-table-column prop="_validateMessage" label="原因" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span :class="{ 'text-danger': row._validateStatus === 'validate_fail', 'text-warning': row._validateStatus === 'validate_warn' }">{{ row._validateMessage || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="removeCardItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="filteredItems.length"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 8px;"
:hide-on-single-page="false" />
<div v-if="previewResult" class="card-import-result">
<span class="result-label">预导入结果:</span>
<span class="result-success">成功 {{ previewResult.successCount }} 条</span>
<span class="result-fail">失败 {{ previewResult.failCount }} 条</span>
</div>
<div v-if="importResult" class="card-import-result">
<span class="result-label">导入结果:</span>
<span class="result-success">成功 {{ importResult.successCount }} 条</span>
<span class="result-fail">失败 {{ importResult.failCount }} 条</span>
<span v-if="importResult.batchNo" class="result-batch">批次号:{{ importResult.batchNo }}</span>
<el-link
v-if="importResult.resultReportUrl"
type="primary"
:href="importResult.resultReportUrl"
target="_blank"
rel="noopener noreferrer"
class="result-report-link">
下载导入报告
</el-link>
</div>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadFile } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { importCard, importCardPreview, type CardImportData } from '@/api/card'
const CARD_TYPES: readonly string[] = ['YEAR', 'QUARTER', 'MONTH', 'WEEK', 'OTHER']
type ValidateRowStatus = 'success' | 'validate_fail' | 'validate_warn' | null | undefined
interface ValidityCheckResult {
valid: boolean
isError?: boolean
isWarning?: boolean
message?: string
}
interface CardImportRow {
cardNo: string
activationCode: string
businessCombo: string
cardDuration: string
validityStart: string | null
validityEnd: string | null
_validateStatus?: ValidateRowStatus
_validateMessage?: string
}
interface ValidateRowResult {
rowIndex: number
cardNo: string
status: string
message: string
}
interface ImportItemPayload {
cardNo?: string
cardType: string
activationCode?: string
businessCombo?: string
cardDuration?: string
validityStart?: string | null
validityEnd?: string | null
}
interface PreviewCounts {
successCount: number
failCount: number
}
interface ImportResultView {
successCount: number
failCount: number
batchNo?: string
resultReportUrl: string | null
}
interface BatchSummary {
successCount?: number
failCount?: number
batchNo?: string
resultReportUrl?: string | null
}
function validateValidityEnd (validityEnd: string | null | undefined): ValidityCheckResult {
if (!validityEnd) return { valid: true }
const expireDate = new Date(validityEnd)
const now = new Date()
const diffMs = expireDate.getTime() - now.getTime()
const diffDays = Math.floor(diffMs / (24 * 60 * 60 * 1000))
if (diffDays < 0) return { valid: false, isError: true, message: '有效期已过期' }
if (diffDays < 365) return { valid: true, isWarning: true, message: `临期:距今不足365天(剩余${diffDays}天),请确认` }
return { valid: true }
}
function checkValidityDurationMatch (diffDays: number, cardType: string): string | null {
const ranges: Record<string, { min: number; max: number; expect: string }> = {
YEAR: { min: 320, max: 400, expect: '约1年' },
QUARTER: { min: 80, max: 100, expect: '约3个月' },
MONTH: { min: 25, max: 35, expect: '约1个月' },
WEEK: { min: 5, max: 10, expect: '约1周' }
}
const r = ranges[cardType]
if (!r || (diffDays >= r.min && diffDays <= r.max)) return null
return `有效期${diffDays}天与${r.expect}不符,请确认`
}
defineOptions({ name: 'CardImportForm' })
withDefaults(defineProps<{ showActionBar?: boolean }>(), {
showActionBar: false
})
const emit = defineEmits<{
success: []
}>()
let xlsxModule: Record<string, unknown> | null = null
async function getXLSX (): Promise<Record<string, unknown>> {
if (!xlsxModule) {
const mod = await import('xlsx')
xlsxModule = (mod.default ?? mod) as Record<string, unknown>
}
return xlsxModule
}
function parseExcelFile (file: File): Promise<unknown[][]> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = async (e: ProgressEvent<FileReader>): Promise<void> => {
try {
const XLSX = await getXLSX()
const result = e.target?.result
if (!result || !(result instanceof ArrayBuffer)) {
reject(new Error('文件读取失败'))
return
}
const data = new Uint8Array(result)
const read = XLSX.read as (d: Uint8Array, opts: { type: string }) => { Sheets: Record<string, unknown>; SheetNames: string[] }
const utils = XLSX.utils as { sheet_to_json: (sheet: unknown, opts: unknown) => unknown[][] }
const workbook = read(data, { type: 'array' })
const firstSheet = workbook.Sheets[workbook.SheetNames[0]]
const rows = utils.sheet_to_json(firstSheet, { header: 1, defval: '' }) as unknown[][]
resolve(rows)
} catch (err) {
reject(err)
}
}
reader.onerror = () => reject(new Error('文件读取失败'))
reader.readAsArrayBuffer(file)
})
}
function getColumnIndex (headerRow: unknown, name: string): number {
if (!Array.isArray(headerRow)) return -1
return headerRow.findIndex(cell => String(cell).trim() === name)
}
function trimCell (v: unknown): string {
if (v == null) return ''
return String(v).trim()
}
function isValidDateString (str: string): boolean {
if (!str || typeof str !== 'string') return false
const d = new Date(str.trim())
return !isNaN(d.getTime())
}
function formatDateTime (d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
const s = String(d.getSeconds()).padStart(2, '0')
return `${y}-${m}-${day} ${h}:${min}:${s}`
}
function computeValidityEndFromType (
validityStart: string | null,
cardType: string,
cardDuration: string
): string | null {
if (!validityStart) return null
const start = new Date(validityStart)
if (isNaN(start.getTime())) return null
let end: Date | undefined
switch (cardType) {
case 'YEAR':
end = new Date(start)
end.setFullYear(end.getFullYear() + 1)
break
case 'QUARTER':
end = new Date(start)
end.setMonth(end.getMonth() + 3)
break
case 'MONTH':
end = new Date(start)
end.setMonth(end.getMonth() + 1)
break
case 'WEEK':
end = new Date(start)
end.setDate(end.getDate() + 7)
break
case 'OTHER':
if (cardDuration && String(cardDuration).trim()) {
const str = String(cardDuration).trim()
const dayMatch = str.match(/^(\d+)\s*天?$/)
if (dayMatch) {
end = new Date(start)
end.setDate(end.getDate() + parseInt(dayMatch[1], 10))
break
}
const monthMatch = str.match(/^(\d+)\s*月?$/)
if (monthMatch) {
end = new Date(start)
end.setMonth(end.getMonth() + parseInt(monthMatch[1], 10))
break
}
}
return null
default:
return null
}
return end ? formatDateTime(end) : null
}
function onDataFilterChange (): void {
page.value = 1
}
function onCardTypeChange (): void {
const cardType = importForm.selectedCardType
importForm.items.forEach(item => {
if (item.validityStart) {
const newEnd = computeValidityEndFromType(item.validityStart, cardType, item.cardDuration)
if (newEnd) {
item.validityEnd = newEnd
item._validateStatus = null
item._validateMessage = ''
}
}
})
if (importForm.items.some(item => (item.cardNo && item.cardNo.trim()))) {
const result = validateCardItems(importForm.items, importForm.selectedCardType)
importForm.items.forEach(it => { it._validateStatus = null; it._validateMessage = '' })
result.list.forEach(r => {
const idx = r.rowIndex - 1
if (importForm.items[idx]) {
importForm.items[idx]._validateStatus = r.status as ValidateRowStatus
importForm.items[idx]._validateMessage = r.message || ''
}
})
}
previewResult.value = null
}
function parseExpireTimeCell (cell: unknown): string | null {
if (cell == null || cell === '') return null
if (cell instanceof Date) {
if (isNaN(cell.getTime())) return null
const y = cell.getFullYear()
const m = String(cell.getMonth() + 1).padStart(2, '0')
const d = String(cell.getDate()).padStart(2, '0')
const h = String(cell.getHours()).padStart(2, '0')
const min = String(cell.getMinutes()).padStart(2, '0')
const s = String(cell.getSeconds()).padStart(2, '0')
return `${y}-${m}-${d} ${h}:${min}:${s}`
}
if (typeof cell === 'number' && cell > 0) {
const d = new Date((cell - 25569) * 86400 * 1000)
if (!isNaN(d.getTime())) return parseExpireTimeCell(d)
}
const str = String(cell).trim()
if (!str) return null
if (/^\d{4}-\d{2}-\d{2}/.test(str)) return isValidDateString(str) ? str : null
return null
}
function validateCardItems (items: CardImportRow[], cardType: string): { valid: boolean; list: ValidateRowResult[] } {
const list: ValidateRowResult[] = []
let hasError = false
const type = (cardType || '').trim() || 'YEAR'
if (type && !CARD_TYPES.includes(type)) {
items.forEach((item, idx) => {
list.push({ rowIndex: idx + 1, cardNo: (item.cardNo || '').trim() || '-', status: 'validate_fail', message: `卡类型须为:${CARD_TYPES.join('、')}之一` })
})
return { valid: false, list }
}
const requiredFields: Array<{ key: keyof CardImportRow; label: string }> = [
{ key: 'cardNo', label: '卡号' },
{ key: 'activationCode', label: '激活码' },
{ key: 'validityStart', label: '有效期起始' },
{ key: 'validityEnd', label: '有效期截止' }
]
items.forEach((item, idx) => {
const rowIndex = idx + 1
const cardNo = (item.cardNo || '').trim()
const validityStart = item.validityStart
const validityEnd = item.validityEnd
let status = 'success'
let message = ''
const missing = requiredFields.filter(f => {
const v = f.key === 'validityStart' ? validityStart : (f.key === 'validityEnd' ? validityEnd : item[f.key])
return !(v != null && String(v).trim() !== '')
})
if (missing.length > 0) {
status = 'validate_fail'
message = `缺少必填项:${missing.map(f => f.label).join('、')}`
hasError = true
} else if (validityStart && validityEnd) {
const startTime = new Date(validityStart).getTime()
const endTime = new Date(validityEnd).getTime()
if (!isNaN(startTime) && !isNaN(endTime) && startTime >= endTime) {
status = 'validate_fail'
message = '有效期起始必须早于有效期截止'
hasError = true
} else if (!isNaN(startTime) && !isNaN(endTime)) {
const diffDays = Math.round((endTime - startTime) / (24 * 60 * 60 * 1000))
const check = validateValidityEnd(validityEnd)
if (check.isError) { status = 'validate_fail'; message = check.message || ''; hasError = true }
else if (check.isWarning) { status = 'validate_warn'; message = check.message || '' }
else {
const durationWarn = checkValidityDurationMatch(diffDays, type)
if (durationWarn) { status = 'validate_warn'; message = durationWarn }
}
} else if (validityEnd) {
const check = validateValidityEnd(validityEnd)
if (check.isError) { status = 'validate_fail'; message = check.message || ''; hasError = true }
else if (check.isWarning) { status = 'validate_warn'; message = check.message || '' }
}
} else if (validityEnd) {
const check = validateValidityEnd(validityEnd)
if (check.isError) { status = 'validate_fail'; message = check.message || ''; hasError = true }
else if (check.isWarning) { status = 'validate_warn'; message = check.message || '' }
}
list.push({ rowIndex, cardNo: cardNo || '-', status, message })
})
return { valid: !hasError, list }
}
function parseCardRows (rows: unknown[][]): CardImportRow[] {
if (!rows || rows.length < 2) return []
const headerRow = rows[0]
const idxCardNo = getColumnIndex(headerRow, '卡号')
if (idxCardNo < 0) return []
const idxActivationCode = getColumnIndex(headerRow, '激活码')
const idxBusinessCombo = getColumnIndex(headerRow, '业务组合')
const idxCardDuration = getColumnIndex(headerRow, '卡密时长')
const idxValidityStart = getColumnIndex(headerRow, '有效期起始')
const idxValidityEnd = getColumnIndex(headerRow, '有效期截止')
const list: CardImportRow[] = []
for (let i = 1; i < rows.length; i++) {
const row = rows[i]
if (!Array.isArray(row)) continue
const cardNo = idxCardNo >= 0 ? trimCell(row[idxCardNo]) : ''
if (!cardNo) continue
const activationCode = idxActivationCode >= 0 ? trimCell(row[idxActivationCode]) : ''
const businessCombo = idxBusinessCombo >= 0 ? trimCell(row[idxBusinessCombo]) : ''
const cardDuration = idxCardDuration >= 0 ? trimCell(row[idxCardDuration]) : ''
const validityStart = idxValidityStart >= 0 ? parseExpireTimeCell(row[idxValidityStart]) : null
const validityEnd = idxValidityEnd >= 0 ? parseExpireTimeCell(row[idxValidityEnd]) : null
list.push({
cardNo: cardNo || '',
activationCode: activationCode || '',
businessCombo: businessCombo || '',
cardDuration: cardDuration || '',
validityStart: validityStart || null,
validityEnd: validityEnd || null
})
}
return list
}
function defaultCardItem (): CardImportRow {
return {
cardNo: '',
activationCode: '',
businessCombo: '',
cardDuration: '',
validityStart: null,
validityEnd: null
}
}
const importForm = reactive({
selectedCardType: 'YEAR',
items: [defaultCardItem()] as CardImportRow[]
})
const previewResult = ref<PreviewCounts | null>(null)
const importLoading = ref(false)
const page = ref(1)
const pageSize = ref(10)
const importResult = ref<ImportResultView | null>(null)
const dataFilter = ref<'all' | 'valid' | 'invalid'>('all')
const filteredItems = computed(() => {
if (dataFilter.value === 'valid') {
return importForm.items.filter(item => item._validateStatus !== 'validate_fail')
}
if (dataFilter.value === 'invalid') {
return importForm.items.filter(item => item._validateStatus === 'validate_fail')
}
return importForm.items
})
const paginatedItems = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredItems.value.slice(start, start + pageSize.value)
})
function tableRowClassName ({ row }: { row: CardImportRow }): string {
if (row._validateStatus === 'validate_fail') return 'row-validate-error'
if (row._validateStatus === 'validate_warn') return 'row-validate-warn'
return ''
}
const hasCardRows = computed(() => importForm.items.some(item => (item.cardNo && item.cardNo.trim())))
const hasErrorRows = computed(() => importForm.items.some(item => item._validateStatus === 'validate_fail'))
const errorRowCount = computed(() =>
importForm.items.filter(item => item._validateStatus === 'validate_fail').length
)
const canSubmit = computed(() => hasCardRows.value)
const TEMPLATE_HEADERS = ['卡号', '激活码', '业务组合', '卡密时长', '有效期起始', '有效期截止']
const TEMPLATE_EXAMPLE_ROW = ['20260311-101110-0000', '7FFD110884D3DBA8', '爱视(极速版)测试', '1天', '2026-03-11 00:00:00', '2026-03-11 23:00:00']
const TEMPLATE_COLUMNS = ['卡号', '激活码', '业务组合', '卡密时长', '有效期起始', '有效期截止']
function downloadImportTemplate (): void {
void getXLSX().then((XLSX) => {
const utils = XLSX.utils as {
aoa_to_sheet: (rows: unknown[][]) => unknown
book_new: () => unknown
book_append_sheet: (wb: unknown, ws: unknown, name: string) => void
}
const write = XLSX.write as (wb: unknown, opts: { bookType: string; type: string }) => Uint8Array
const rows = [TEMPLATE_HEADERS, TEMPLATE_EXAMPLE_ROW]
const ws = utils.aoa_to_sheet(rows)
const wb = utils.book_new()
utils.book_append_sheet(wb, ws, '卡导入')
const wbout = write(wb, { bookType: 'xlsx', type: 'array' })
const blob = new Blob([wbout as BlobPart], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '卡批量导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
ElMessage.success('模板已下载,请按表头填写后上传')
})
}
function exportToExcel (): void {
if (importForm.items.length === 0) {
ElMessage.warning('暂无数据可导出')
return
}
void getXLSX().then((XLSX) => {
const utils = XLSX.utils as {
aoa_to_sheet: (rows: unknown[][]) => unknown
book_new: () => unknown
book_append_sheet: (wb: unknown, ws: unknown, name: string) => void
}
const write = XLSX.write as (wb: unknown, opts: { bookType: string; type: string }) => Uint8Array
const headers = [...TEMPLATE_COLUMNS, '原因']
const rows = importForm.items.map(item => [
item.cardNo || '',
item.activationCode || '',
item.businessCombo || '',
item.cardDuration || '',
item.validityStart || '',
item.validityEnd || '',
item._validateMessage || ''
])
const ws = utils.aoa_to_sheet([headers, ...rows])
const wb = utils.book_new()
utils.book_append_sheet(wb, ws, '卡列表')
const wbout = write(wb, { bookType: 'xlsx', type: 'array' })
const blob = new Blob([wbout as BlobPart], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `卡导入数据_${new Date().toISOString().slice(0, 10)}.xlsx`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('已导出为 Excel')
})
}
function onExcelChange (file: UploadFile): void {
const raw = file.raw
if (!raw) return
parseExcelFile(raw).then((rows) => {
const items = parseCardRows(rows)
if (items.length === 0) {
ElMessage.warning('未解析到有效卡数据,请使用标准模板(表头含:卡号、激活码、业务组合、卡密时长、有效期起始、有效期截止),且每行必须填写卡号')
return
}
importForm.items = items
dataFilter.value = 'all'
previewResult.value = null
page.value = 1
ElMessage.success(`已解析 ${items.length} 条`)
}).catch(() => ElMessage.error('无法解析该文件,请确认是 .xlsx 或 .xls 格式并使用标准模板'))
}
function addCardItem (): void {
importForm.items.push(defaultCardItem())
previewResult.value = null
}
function clearData (): void {
importForm.items = [defaultCardItem()]
dataFilter.value = 'all'
previewResult.value = null
page.value = 1
ElMessage.success('已清空卡列表')
}
function removeCardItem (row: CardImportRow): void {
const idx = importForm.items.findIndex(item => item === row)
if (idx >= 0) importForm.items.splice(idx, 1)
previewResult.value = null
if (importForm.items.length > 0 && (page.value - 1) * pageSize.value >= filteredItems.value.length) {
page.value = Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value))
}
}
function buildImportPayload (): ImportItemPayload[] {
return importForm.items
.filter(item => (item.cardNo && item.cardNo.trim()))
.map(item => {
const base: ImportItemPayload = {
cardNo: item.cardNo?.trim() || undefined,
cardType: (importForm.selectedCardType && importForm.selectedCardType.trim()) || 'YEAR'
}
if (item.activationCode?.trim()) base.activationCode = item.activationCode.trim()
if (item.businessCombo?.trim()) base.businessCombo = item.businessCombo.trim()
if (item.cardDuration?.trim()) base.cardDuration = item.cardDuration.trim()
if (item.validityStart) base.validityStart = item.validityStart
if (item.validityEnd) base.validityEnd = item.validityEnd
return base
})
}
interface PreviewApiBody {
code?: number
msg?: string
items?: Array<{ cardNo?: string; valid: boolean; message?: string }>
data?: { items?: Array<{ cardNo?: string; valid: boolean; message?: string }> }
}
interface ImportApiBody {
code?: number
msg?: string
successCount?: number
failCount?: number
batch?: BatchSummary
data?: {
batch?: BatchSummary
successCount?: number
failCount?: number
}
}
function submitImport (): void {
if (!hasCardRows.value) {
ElMessage.warning('请至少填写一条卡信息(卡号必填)')
return
}
const result = validateCardItems(importForm.items, importForm.selectedCardType)
importForm.items.forEach(it => { it._validateStatus = null; it._validateMessage = '' })
result.list.forEach(r => {
const idx = r.rowIndex - 1
if (importForm.items[idx]) {
importForm.items[idx]._validateStatus = r.status as ValidateRowStatus
importForm.items[idx]._validateMessage = r.message || ''
}
})
if (!result.valid) {
ElMessage.warning('校验未通过,请根据表格内「原因」列修改后再导入')
return
}
const items = buildImportPayload()
if (items.length === 0) {
ElMessage.warning('请至少填写一条有效的卡信息(卡号必填)')
return
}
importLoading.value = true
previewResult.value = null
const importPayload = { items } as unknown as CardImportData
importCardPreview(importPayload)
.then(({ data }) => {
const previewBody = data as unknown as PreviewApiBody
if (!previewBody || previewBody.code !== 0) {
ElMessage.error(previewBody?.msg || '预校验失败')
importLoading.value = false
return undefined
}
const resultItems = previewBody.items || previewBody.data?.items || []
resultItems.forEach((item) => {
const row = importForm.items.find(r => (r.cardNo || '').trim() === (item.cardNo || '').trim())
if (row) {
row._validateStatus = item.valid ? null : 'validate_fail'
row._validateMessage = item.valid ? '' : (item.message || '')
}
})
const hasPreviewErrors = resultItems.some(i => !i.valid)
if (hasPreviewErrors) {
const failCount = resultItems.filter(i => !i.valid).length
previewResult.value = { successCount: resultItems.filter(i => i.valid).length, failCount }
ElMessage.warning(`预校验未通过,${failCount} 条存在错误,请根据表格内「原因」列修改后再导入`)
importLoading.value = false
return undefined
}
return importCard(importPayload)
})
.then((res) => {
if (!res) return
const { data } = res
const body = data as unknown as ImportApiBody
if (body && body.code === 0) {
const batch = body.batch || body.data?.batch
importResult.value = {
successCount: batch?.successCount ?? body.successCount ?? body.data?.successCount ?? 0,
failCount: batch?.failCount ?? body.failCount ?? body.data?.failCount ?? 0,
batchNo: batch?.batchNo,
resultReportUrl: batch?.resultReportUrl || null
}
ElMessage.success('导入完成,请查看下方结果')
emit('success')
} else {
ElMessage.error(body?.msg || '导入失败')
}
})
.catch(() => ElMessage.error('导入请求失败'))
.finally(() => { importLoading.value = false })
}
function reset (): void {
importForm.selectedCardType = 'YEAR'
importForm.items = [defaultCardItem()]
importResult.value = null
previewResult.value = null
page.value = 1
}
defineExpose({
submitImport,
canSubmit,
importLoading,
importResult,
reset
})
</script>
<style lang="scss" scoped>
.card-items-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
width: 100%;
.toolbar-left {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
}
.card-validate-alert {
margin-bottom: 8px;
}
.text-danger { color: var(--el-color-danger); }
.text-warning { color: var(--el-color-warning); }
:deep(.row-validate-error) { background-color: var(--el-color-danger-light-9); }
:deep(.row-validate-warn) { background-color: var(--el-color-warning-light-9); }
:deep(.cell-error .el-input__wrapper) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
.card-import-result {
margin-top: 12px;
padding: 10px 12px;
background: var(--el-fill-color-light);
border-radius: 4px;
font-size: 14px;
.result-label { color: var(--el-text-color-regular); margin-right: 8px; }
.result-success { color: var(--el-color-success); margin-right: 16px; }
.result-fail { color: var(--el-color-danger); margin-right: 16px; }
.result-batch { color: var(--el-text-color-regular); margin-right: 16px; }
.result-report-link { vertical-align: baseline; }
}
.ml-8px { margin-left: 8px; }
.form-tip { margin-left: 8px; color: var(--el-text-color-secondary); font-size: 12px; }
</style>

74
src/views/modules/card/data.ts Executable file
View File

@@ -0,0 +1,74 @@
import type { CardInfo } from '@/api/card'
import type { PageParams, PageResult } from '@/types/api'
export type ElTagType = 'success' | 'warning' | 'info' | 'danger' | undefined
export interface CardTypeUiItem {
label: string
tagType?: ElTagType
}
/** 导入批次列表行(兼容 snake_case 字段) */
export interface ImportBatchRow extends Record<string, unknown> {
id?: number | string
batchNo?: string
cardType?: string
cardTypeName?: string
totalCount?: number
importCount?: number
cardCount?: number
remark?: string
remarkNote?: string
note?: string
memo?: string
createdAt?: string
}
export interface DataForm {
batchNo: string
remark: string
status: string
}
export interface CardFilterForm {
usageStatus: string
}
export interface CurrentBatch {
id: number | string | null
batchNo: string
}
export interface ImportBatchListParams extends PageParams {
batchNo?: string
remark?: string
status?: string
}
/** 列表接口响应体(兼容 page / list 两种分页形态;与 axios 的 data 字段一致) */
export type ImportBatchListPayload = {
code?: number
msg?: string
page?: PageResult<ImportBatchRow>
list?: ImportBatchRow[]
totalCount?: number
}
export type ImportBatchCardsPayload = {
code?: number
msg?: string
page?: PageResult<CardInfo>
list?: CardInfo[]
totalCount?: number
}
/** 无效卡回退渠道接口响应体 */
export type RollbackInvalidBatchPayload = {
code?: number
msg?: string
}
export interface BatchStatusOption {
value: string
label: string
}

View File

@@ -0,0 +1,13 @@
export interface DeviceImportDialogEmits {
(e: 'update:modelValue', v: boolean): void
(e: 'success'): void
}
export interface DeviceImportFormInstance {
submitImport: () => Promise<void> | void
importLoading?: { value: boolean }
canSubmit?: { value: boolean }
importResult?: { value: unknown }
activeStep?: unknown
primaryFooterLabel?: unknown
}

View File

@@ -0,0 +1,64 @@
<template>
<el-dialog
:model-value="modelValue"
title="批量导入设备(仅限门店)"
width="900px"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="onClosed">
<device-import-form
ref="formRef"
:show-action-bar="false"
:preset-pre-bind-card-type="'YEAR'"
:hide-pre-bind-card-type-selector="false" />
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button
v-if="wizardStep < 2"
type="primary"
:loading="importLoading"
:disabled="!canSubmit"
@click="handleSubmit">
{{ primaryLabel }}
</el-button>
<el-button v-if="wizardStep === 2" type="primary" @click="closeAfterImport">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, unref } from 'vue'
import DeviceImportForm from './device-import-form.vue'
import type { DeviceImportFormInstance } from './device-import-dialog.data'
defineOptions({ name: 'DeviceImportDialog' })
withDefaults(defineProps<{ modelValue?: boolean }>(), { modelValue: false })
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'success'): void
}>()
const formRef = ref<DeviceImportFormInstance | null>(null)
const importLoading = computed(() => formRef.value?.importLoading?.value ?? false)
const canSubmit = computed(() => formRef.value?.canSubmit?.value ?? false)
const wizardStep = computed(() => (unref(formRef.value?.activeStep) ?? 0) as number)
const primaryLabel = computed(() => (unref(formRef.value?.primaryFooterLabel) ?? '下一步') as string)
function onClosed (): void {
// 表单会在组件销毁时自动重置
}
function closeAfterImport (): void {
emit('update:modelValue', false)
emit('success')
}
async function handleSubmit (): Promise<void> {
const form = formRef.value
if (!form || typeof form.submitImport !== 'function') return
await form.submitImport()
}
</script>

View File

@@ -0,0 +1,64 @@
import type { DeviceListItem } from '@/utils/device-list'
/** 导入表格行(可编辑 MAC / 房间号及校验状态) */
export type DeviceImportValidateStatus =
| 'validate_fail'
| 'validate_ok'
| 'import_fail'
| 'import_skip'
| null
export interface DeviceImportRowItem {
mac: string
roomNo: string
_validateStatus?: DeviceImportValidateStatus
_validateMessage?: string
_removing?: boolean
_fromSelect?: boolean
_sourceRowUid?: string
}
export type PreBindCardType = 'YEAR' | 'QUARTER' | 'MONTH' | 'WEEK' | 'OTHER' | string
export const CARD_TYPE_LABELS: Record<string, string> = {
YEAR: '年卡',
QUARTER: '季卡',
MONTH: '月卡',
WEEK: '周卡',
OTHER: '天卡'
}
/** 批量预置导入结果 */
export interface DeviceBatchImportResult {
successCount: number
failCount: number
skipCount: number
totalCount: number
hasError: boolean
items?: unknown
}
export interface ValidatePassedBanner {
title: string
description: string
}
/** 可选设备表格行(附加 rowUid 用于勾选同步) */
export type SelectDeviceRow = DeviceListItem & { rowUid?: string }
export interface DeviceImportFormModel {
storeOrgId: string | number | null
preBindCardType: PreBindCardType
items: DeviceImportRowItem[]
}
export interface DeviceImportFormProps {
showActionBar: boolean
presetPreBindCardType: PreBindCardType
hidePreBindCardTypeSelector: boolean
}
export interface OverviewChangePayload {
storeOrgId?: string | number | null
cardType?: PreBindCardType
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
export interface DevicePresetDialogEmits {
(e: 'update:modelValue', v: boolean): void
(e: 'success'): void
}
export interface DeviceImportFormInstance {
submitImport: () => Promise<void> | void
importLoading?: { value: boolean }
canSubmit?: { value: boolean }
importResult?: { value: unknown }
activeStep?: unknown
primaryFooterLabel?: unknown
}

View File

@@ -0,0 +1,69 @@
<template>
<el-dialog
:model-value="modelValue"
title="批量预置"
width="900px"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="onClosed">
<device-import-form
ref="formRef"
:show-action-bar="false"
:preset-pre-bind-card-type="presetCardType"
:hide-pre-bind-card-type-selector="true" />
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button
v-if="wizardStep < 2"
type="primary"
:loading="importLoading"
:disabled="!canSubmit"
@click="handleSubmit">
{{ primaryLabel }}
</el-button>
<el-button v-if="wizardStep === 2" type="primary" @click="closeAfterImport">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, unref } from 'vue'
import DeviceImportForm from './device-import-form.vue'
import type { DeviceImportFormInstance } from './device-preset-dialog.data'
defineOptions({ name: 'DevicePresetDialog' })
const props = withDefaults(defineProps<{ modelValue?: boolean; presetCardType?: string }>(), {
modelValue: false,
presetCardType: 'YEAR'
})
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'success'): void
}>()
const formRef = ref<DeviceImportFormInstance | null>(null)
const importLoading = computed(() => formRef.value?.importLoading?.value ?? false)
const canSubmit = computed(() => formRef.value?.canSubmit?.value ?? false)
const wizardStep = computed(() => (unref(formRef.value?.activeStep) ?? 0) as number)
const primaryLabel = computed(() => (unref(formRef.value?.primaryFooterLabel) ?? '下一步') as string)
function onClosed (): void {
// 表单会在组件销毁时自动重置
}
function closeAfterImport (): void {
emit('update:modelValue', false)
emit('success')
}
async function handleSubmit (): Promise<void> {
const form = formRef.value
if (!form || typeof form.submitImport !== 'function') return
await form.submitImport()
}
</script>

View File

@@ -0,0 +1,243 @@
<template>
<el-dialog
:model-value="modelValue"
title="设备替换"
width="960px"
destroy-on-close
:close-on-click-modal="false"
class="replace-duplicate-dialog-wrap"
@update:model-value="emit('update:modelValue', $event)"
@closed="onClosed">
<div class="replace-duplicate-dialog">
<div class="section-title">待合并的重复设备(将被软删除)</div>
<div class="duplicate-summary">
<span>MAC:{{ duplicateDevice?.mac ?? '-' }}</span>
<span>SN:{{ duplicateDevice?.deviceSn ?? '-' }}</span>
<span>UID:{{ duplicateDevice?.deviceUid ?? '-' }}</span>
</div>
<div class="section-title">选择保留的已绑卡设备</div>
<el-table
v-loading="listLoading"
:data="boundDeviceList"
border
highlight-current-row
empty-text="暂无同 MAC 已绑卡设备"
class="bound-device-table"
@current-change="onCurrentChange">
<el-table-column width="56" align="center">
<template #default="{ row }">
<el-radio v-model="selectedDeviceId" :label="getRowDeviceId(row)" />
</template>
</el-table-column>
<el-table-column prop="mac" header-align="center" align="center" min-width="160" label="MAC" show-overflow-tooltip />
<el-table-column prop="deviceSn" header-align="center" align="center" min-width="150" label="设备序列号" show-overflow-tooltip />
<el-table-column prop="deviceUid" header-align="center" align="center" min-width="150" label="设备 UID" show-overflow-tooltip />
<el-table-column header-align="center" align="center" width="120" label="绑定/权益状态">
<template #default="{ row }">
<el-tag effect="plain" size="small">{{ getBindStatusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="storeName" header-align="center" align="center" min-width="180" label="所属门店" show-overflow-tooltip>
<template #default="{ row }">{{ row.storeName ?? '-' }}</template>
</el-table-column>
</el-table>
<div class="replace-tip">
替换后:保留设备将获得重复设备上的 SN/UID(与硬件一致),卡绑定仍在保留设备上;重复记录将被软删除。
</div>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitLoading" :disabled="!selectedDeviceId" @click="submit">确认替换</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
// @ts-nocheck — 列表响应结构与生成类型不完全一致。
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSameMacBoundDeviceList, replaceDuplicateDevice } from '@/api/device'
import { normalizeDeviceList } from '@/utils/device-list'
import {
STATUS_ALIASES,
STATUS_CONFIG,
STATUS_KEYS,
STATUS_NUM_MAP,
type DeviceStatusKey,
type DuplicateMacSnDeviceRow
} from '../device-duplicate-mac-sn.data'
defineOptions({ name: 'DeviceReplaceDuplicateDialog' })
const props = defineProps<{
modelValue?: boolean
duplicateDevice?: DuplicateMacSnDeviceRow | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'success'): void
}>()
const listLoading = ref(false)
const submitLoading = ref(false)
const boundDeviceList = ref<DuplicateMacSnDeviceRow[]>([])
const selectedDeviceId = ref<number | null>(null)
const duplicateDeviceId = computed(() => {
const row = props.duplicateDevice
if (!row) return null
const id = row.id ?? row.deviceId
return id == null ? null : Number(id)
})
const getRowDeviceId = (row: DuplicateMacSnDeviceRow): number | null => {
const id = row?.id ?? row?.deviceId
return id == null ? null : Number(id)
}
const getStatusKey = (row: DuplicateMacSnDeviceRow): DeviceStatusKey | 'UNBOUND' => {
const v = row?.bindCardStatus
if (v == null || v === '') return 'UNBOUND'
if (typeof v === 'number') return STATUS_NUM_MAP[v] || 'UNBOUND'
let s = String(v).toUpperCase().replace(/-/g, '_')
while (STATUS_ALIASES[s]) s = STATUS_ALIASES[s]
return STATUS_KEYS.includes(s as DeviceStatusKey) ? (s as DeviceStatusKey) : 'UNBOUND'
}
const getBindStatusLabel = (row: DuplicateMacSnDeviceRow): string =>
row?.bindCardStatusLabel || STATUS_CONFIG[getStatusKey(row) as DeviceStatusKey]?.label || '未绑定'
const onCurrentChange = (row: DuplicateMacSnDeviceRow | undefined): void => {
selectedDeviceId.value = row ? getRowDeviceId(row) : null
}
const loadBoundDeviceList = async (): Promise<void> => {
const deviceId = duplicateDeviceId.value
if (deviceId == null) {
boundDeviceList.value = []
return
}
listLoading.value = true
try {
const { data } = await getSameMacBoundDeviceList(deviceId)
const list = (data?.code === 0 && Array.isArray(data?.list)) ? data.list : []
boundDeviceList.value = normalizeDeviceList(list)
if (boundDeviceList.value.length === 1) {
selectedDeviceId.value = getRowDeviceId(boundDeviceList.value[0])
}
} catch {
boundDeviceList.value = []
} finally {
listLoading.value = false
}
}
const onClosed = (): void => {
boundDeviceList.value = []
selectedDeviceId.value = null
}
watch(
() => props.modelValue,
visible => {
if (visible) {
selectedDeviceId.value = null
loadBoundDeviceList()
}
}
)
const submit = (): void => {
const device2 = duplicateDeviceId.value
const device1 = selectedDeviceId.value
if (device2 == null) {
ElMessage.warning('重复设备信息有误')
return
}
if (device1 == null) {
ElMessage.warning('请选择要保留的已绑卡设备')
return
}
const duplicateRow = props.duplicateDevice
const keepRow = boundDeviceList.value.find(row => getRowDeviceId(row) === device1)
const confirmMsg = [
`确定将重复设备(MAC: ${duplicateRow?.mac ?? '-'},SN: ${duplicateRow?.deviceSn ?? '-'},UID: ${duplicateRow?.deviceUid ?? '-'})的序列号与 UID 合并到已绑卡设备吗?`,
'',
`保留设备:${keepRow?.mac ?? duplicateRow?.mac ?? '-'} · ${keepRow?.storeName ?? '-'}`,
'',
'替换后保留设备获得重复设备上的 SN/UID,卡绑定仍在保留设备上;重复记录将被软删除。'
].join('\n')
ElMessageBox.confirm(confirmMsg, '确认设备替换', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
submitLoading.value = true
try {
const { data } = await replaceDuplicateDevice({ device1, device2 })
if (data?.code === 0) {
ElMessage.success('设备替换成功')
emit('update:modelValue', false)
emit('success')
} else {
ElMessage.error(data?.msg || '设备替换失败')
}
} catch (err: unknown) {
const e = err as { response?: { data?: { msg?: string } }; message?: string }
ElMessage.error(e?.response?.data?.msg ?? e?.message ?? '请求失败')
} finally {
submitLoading.value = false
}
}).catch(() => {})
}
</script>
<style lang="scss" scoped>
.replace-duplicate-dialog {
padding: 4px 0 8px;
.section-title {
margin-bottom: 10px;
font-size: 15px;
font-weight: 600;
color: #303133;
}
.section-title + .duplicate-summary,
.section-title + .bound-device-table {
margin-bottom: 20px;
}
.duplicate-summary {
display: flex;
flex-wrap: wrap;
gap: 16px 32px;
padding: 14px 16px;
background: #f5f7fa;
border-radius: 6px;
color: #606266;
font-size: 14px;
}
.bound-device-table {
width: 100%;
:deep(.el-table__body tr) {
cursor: pointer;
}
}
.replace-tip {
margin-top: 16px;
color: #909399;
font-size: 14px;
line-height: 1.6;
}
}
</style>
<style lang="scss">
.replace-duplicate-dialog-wrap {
.el-dialog__body {
padding-top: 12px;
padding-bottom: 16px;
}
}
</style>

View File

@@ -0,0 +1,18 @@
import type { DeviceImportRowItem } from './device-import-form.data'
/** 设备列表行(选择弹窗右侧表格,与导入行结构兼容) */
export interface SelectableDeviceRow {
mac?: string | null
deviceMac?: string | null
device_mac?: string | null
roomNo?: string | null
storeName?: string | null
storeOrgName?: string | null
orgName?: string | null
hotelName?: string | null
rowUid?: string
_removing?: boolean
[key: string]: unknown
}
export type DeviceSelectImportItem = DeviceImportRowItem

View File

@@ -0,0 +1,328 @@
<template>
<el-dialog
:model-value="modelValue"
title="选择设备"
fullscreen
destroy-on-close
:close-on-click-modal="false"
class="device-select-fullscreen-dialog"
@update:model-value="emit('update:modelValue', $event)"
@opened="emit('opened')">
<div class="select-dialog-body">
<!-- 顶部:门店 + 卡类型 -->
<div class="select-dialog-header">
<div class="header-filters">
<div class="filter-item">
<span class="filter-label">门店</span>
<store-select
:model-value="storeOrgId === null || storeOrgId === '' ? undefined : storeOrgId"
placeholder="请选择门店"
width="260px"
@update:model-value="emit('update:storeOrgId', $event)"
/>
</div>
<div v-if="!hidePreBindCardTypeSelector" class="filter-item">
<span class="filter-label">卡类型</span>
<el-select
:model-value="preBindCardType"
placeholder="请选择"
style="width: 140px"
@update:model-value="emit('update:preBindCardType', $event)">
<el-option label="年卡" value="YEAR" />
<el-option label="季卡" value="QUARTER" />
<el-option label="月卡" value="MONTH" />
<el-option label="周卡" value="WEEK" />
<el-option label="天卡" value="OTHER" />
</el-select>
</div>
</div>
</div>
<!-- 左右布局 -->
<div class="select-dialog-main">
<!-- 左侧:导入信息 table -->
<div class="main-left">
<div class="panel-title">导入信息</div>
<el-table
:data="importItems"
border
max-height="calc(100vh - 260px)"
style="width: 100%"
:row-class-name="importTableRowClassName">
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column prop="mac" label="MAC地址" min-width="160" show-overflow-tooltip />
<el-table-column prop="roomNo" label="房间号" width="120" show-overflow-tooltip />
<el-table-column label="操作" width="70" align="center" fixed="right">
<template #default="{ row, $index }">
<el-button
link
type="danger"
size="small"
:disabled="row._removing"
@click="emit('remove-item', $index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 右侧:可选择设备 table -->
<div class="main-right">
<div class="panel-header">
<span class="panel-title">可选设备</span>
<el-button type="primary" size="small" :loading="selectLoading" @click="emit('load-devices')">
刷新列表
</el-button>
</div>
<el-alert
type="info"
:closable="false"
show-icon
class="mb-8px"
title="勾选设备后会自动加入左侧导入列表" />
<el-table
ref="selectTableRef"
v-loading="selectLoading"
:data="selectDeviceList"
border
row-key="rowUid"
max-height="calc(100vh - 340px)"
style="width: 100%"
@selection-change="emit('selection-change', $event)">
<el-table-column type="selection" width="48" reserve-selection />
<el-table-column prop="mac" label="MAC" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ row.mac ?? row.deviceMac ?? row.device_mac ?? '-' }}
</template>
</el-table-column>
<el-table-column prop="roomNo" label="房间号" width="110" show-overflow-tooltip />
<el-table-column label="门店/组织" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
{{ row.storeName ?? row.storeOrgName ?? row.orgName ?? row.hotelName ?? '-' }}
</template>
</el-table-column>
</el-table>
<el-pagination
:current-page="selectPage"
:page-size="selectPageSize"
class="mt-8px"
:page-sizes="[10, 20, 50]"
:total="selectTotal"
layout="total, sizes, prev, pager, next"
@size-change="onSizeChange"
@current-change="onPageChange" />
</div>
</div>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import StoreSelect from '@/components/store-select/index.vue'
import type { DeviceSelectImportItem, SelectableDeviceRow } from './device-select-fullscreen-dialog.data'
defineOptions({ name: 'DeviceSelectFullscreenDialog' })
const props = withDefaults(defineProps<{
modelValue?: boolean
storeOrgId?: string | number | null
preBindCardType?: string
importItems?: DeviceSelectImportItem[]
selectDeviceList?: SelectableDeviceRow[]
selectLoading?: boolean
selectPage?: number
selectPageSize?: number
selectTotal?: number
hidePreBindCardTypeSelector?: boolean
}>(), {
modelValue: false,
storeOrgId: null,
preBindCardType: 'YEAR',
importItems: () => [],
selectDeviceList: () => [],
selectLoading: false,
selectPage: 1,
selectPageSize: 10,
selectTotal: 0,
hidePreBindCardTypeSelector: false
})
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'update:storeOrgId', v: string | number | null): void
(e: 'update:preBindCardType', v: string): void
(e: 'update:selectPage', v: number): void
(e: 'update:selectPageSize', v: number): void
(e: 'load-devices'): void
(e: 'selection-change', rows: SelectableDeviceRow[]): void
(e: 'remove-item', index: number): void
(e: 'opened'): void
(e: 'restore-start'): void
(e: 'restore-end', rows: SelectableDeviceRow[]): void
}>()
const selectTableRef = ref<{ clearSelection?: () => void; toggleRowSelection?: (row: SelectableDeviceRow, selected?: boolean) => void; getSelectionRows?: () => SelectableDeviceRow[] } | null>(null)
function normalizeMac (v: unknown): string {
if (!v || String(v).trim() === '') return ''
return String(v).replace(/[:\-\s]/g, '').toUpperCase()
}
function pickMacFromRow (row: SelectableDeviceRow): string {
return (row?.mac ?? row?.deviceMac ?? row?.device_mac ?? '').trim()
}
/** 根据导入信息中的 MAC 恢复可选设备表格的勾选状态 */
function restoreSelection (): void {
const table = selectTableRef.value
if (!table || !props.selectDeviceList?.length) return
const selectedMacSet = new Set(
(props.importItems || [])
.map(item => normalizeMac(item?.mac))
.filter(Boolean)
)
emit('restore-start')
table.clearSelection?.()
props.selectDeviceList.forEach((row) => {
const mac = normalizeMac(pickMacFromRow(row))
if (mac && selectedMacSet.has(mac)) {
table.toggleRowSelection?.(row, true)
}
})
nextTick(() => {
const rows = table.getSelectionRows?.() ?? []
emit('restore-end', rows as SelectableDeviceRow[])
})
}
// 可选设备列表加载完成后,恢复与导入信息匹配的行的勾选
watch(
() => [props.selectDeviceList, props.importItems],
() => {
if (props.selectDeviceList?.length) {
nextTick(() => {
nextTick(restoreSelection)
})
}
},
{ deep: true }
)
function onPageChange (page: number): void {
emit('update:selectPage', page)
}
function onSizeChange (size: number): void {
emit('update:selectPageSize', size)
}
function importTableRowClassName ({ row }: { row: DeviceSelectImportItem }): string {
return row._removing ? 'row-removing' : ''
}
defineExpose({
selectTableRef,
restoreSelection
})
</script>
<style lang="scss" scoped>
.device-select-fullscreen-dialog {
.select-dialog-body {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
}
.select-dialog-header {
padding: 16px 0;
margin-bottom: 16px;
border-bottom: 1px solid #ebeef5;
.header-filters {
display: flex;
align-items: center;
gap: 24px;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 14px;
color: #606266;
white-space: nowrap;
}
}
.select-dialog-main {
flex: 1;
display: flex;
gap: 24px;
min-height: 0;
}
.main-left,
.main-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.main-left {
min-width: 320px;
max-width: 50%;
}
.main-right {
min-width: 360px;
}
.panel-title {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.panel-title {
margin-bottom: 0;
}
}
:deep(.mb-8px) {
margin-bottom: 8px;
}
:deep(.mt-8px) {
margin-top: 8px;
}
/* 与 device-import-form 删除动画一致 */
:deep(.el-table__body tr.row-removing > td) {
transition:
opacity 0.32s ease,
transform 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
:deep(.el-table__body tr.row-removing) {
opacity: 0;
transform: translate3d(-100%, 0, 0);
pointer-events: none;
}
}
</style>

View File

@@ -0,0 +1,30 @@
import type { DeviceListItem } from '@/utils/device-list'
export interface SameChannelOrgNode {
id?: string | number
name?: string
type?: string
label?: string
disabled?: boolean
children?: SameChannelOrgNode[]
[key: string]: unknown
}
/** 选择目标设备时下拉中的「新增设备」哨兵值 */
export const ADD_DEVICE_VALUE = '__ADD_DEVICE__' as const
export interface TransferCardForm {
targetOrgId: string | number | null
toDeviceId: string | number | typeof ADD_DEVICE_VALUE | null
reason: string
reasonOther: string
}
export interface TransferAddDeviceForm {
mac: string
deviceSn: string
roomNo: string
remark: string
}
export type TransferSourceRow = DeviceListItem

View File

@@ -0,0 +1,493 @@
<template>
<el-dialog
:model-value="modelValue"
title="设备间权益卡的转移"
width="520px"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="onClosed">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="源设备">
<div class="transfer-readonly">
<span>
序号 {{ sourceDeviceId }} · {{ sourceRow.mac }}
<span class="text-secondary">({{ storeNameOf(sourceRow) }})</span>
<br>
<span class="text-secondary">当前绑定卡号:{{ sourceRow.bindCardNo ?? '-' }}</span>
</span>
</div>
</el-form-item>
<el-form-item label="目标门店" prop="targetOrgId">
<el-tree-select
v-model="form.targetOrgId"
:data="orgTreeSelectData"
:props="{ label: 'label', value: 'id' }"
node-key="id"
placeholder="请选择同渠道目标门店(仅门店可选)"
clearable
filterable
style="width: 100%"
:loading="sameChannelOrgsLoading"
:disabled="!sourceShopOrgId"
:render-after-expand="false"
default-expand-all
check-strictly
popper-class="transfer-org-tree-select-popper" />
<div v-if="!sourceShopOrgId" class="text-secondary tip-line">源设备缺少门店组织 ID,无法加载同渠道门店</div>
</el-form-item>
<el-form-item label="目标设备" prop="toDeviceId">
<el-select
v-model="form.toDeviceId"
placeholder="请先选择目标门店,再选择目标设备"
filterable
clearable
style="width: 100%"
:loading="targetDeviceLoading"
:disabled="!form.targetOrgId"
popper-class="transfer-device-select-popper"
@change="onTargetDeviceChange">
<el-option
v-if="form.targetOrgId"
:value="ADD_DEVICE_VALUE"
:label="addDeviceFormVisible && addDeviceForm.mac ? `新增:${addDeviceForm.mac}` : '+ 添加设备'">
<span class="add-device-option">+ 添加设备</span>
</el-option>
<el-option
v-for="d in targetDeviceList"
:key="d.id ?? d.deviceId"
:label="targetDeviceOptionLabel(d)"
:value="d.id ?? d.deviceId" />
</el-select>
</el-form-item>
<el-form-item label="转移原因" prop="reason">
<el-select
v-model="form.reason"
placeholder="请选择转移原因"
clearable
filterable
style="width: 100%">
<el-option
v-for="opt in transferReasonOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value" />
</el-select>
<el-input
v-if="form.reason === TRANSFER_REASON_OTHER"
v-model="form.reasonOther"
class="reason-other-input"
placeholder="请输入具体原因"
clearable
maxlength="100"
show-word-limit />
</el-form-item>
<!-- 添加设备表单(点击下拉顶部「添加设备」后展示在原因下方) -->
<template v-if="addDeviceFormVisible">
<el-divider content-position="left">添加目标设备</el-divider>
<el-form ref="addDeviceFormRef" :model="addDeviceForm" :rules="addDeviceRules" label-width="90px" class="add-device-inline-form">
<el-form-item label="目标门店">
<el-input :model-value="targetOrgLabel" disabled />
</el-form-item>
<el-form-item label="MAC地址" prop="mac">
<el-input v-model="addDeviceForm.mac" placeholder="如 AA:BB:CC:DD:EE:FF" clearable />
</el-form-item>
<el-form-item label="设备SN" prop="deviceSn">
<el-input v-model="addDeviceForm.deviceSn" placeholder="选填,如 SN001" clearable />
</el-form-item>
<el-form-item label="房间号" prop="roomNo">
<el-input v-model="addDeviceForm.roomNo" placeholder="选填" clearable />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="addDeviceForm.remark" type="textarea" placeholder="选填" :rows="2" />
</el-form-item>
<el-form-item>
<el-button size="small" @click="cancelAddDevice">取消添加</el-button>
<span class="text-secondary add-hint">将与转移一起提交,无需单独确认</span>
</el-form-item>
</el-form>
</template>
</el-form>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submit">确认转移</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
// @ts-nocheck — 组织树响应与 API 声明不完全一致;结构体类型见 device-transfer-card-dialog.data.ts。
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { transferDeviceCard, getDeviceListByOrg } from '@/api/device'
import { getSameChannelOrgs } from '@/api/org'
import { normalizeDeviceList } from '@/utils/device-list'
import { ADD_DEVICE_VALUE } from './device-transfer-card-dialog.data'
defineOptions({ name: 'DeviceTransferCardDialog' })
const props = defineProps({
modelValue: { type: Boolean, default: false },
sourceDevice: { type: Object, required: true }
})
const emit = defineEmits(['update:modelValue', 'success'])
const sourceRow = computed(() => props.sourceDevice || {})
const sourceDeviceId = computed(() => sourceRow.value.id ?? sourceRow.value.deviceId ?? '-')
const sourceShopOrgId = computed(() => {
const row = sourceRow.value
const id = row?.storeOrgId ?? row?.orgId
if (id == null || id === '') return null
return id
})
function storeNameOf (row) {
return row?.storeName ?? row?.storeOrgName ?? '-'
}
function targetDeviceOptionLabel (d) {
const store = storeNameOf(d)
return `${d.mac ?? '-'}(${store})`
}
const ORG_TYPE_LABEL = {
CHANNEL: '渠道',
SERVICE_PROVIDER: '代理',
STORE: '门店',
store: '门店'
}
const ORG_TYPE_STORE = 'STORE'
/** 为 el-tree-select 递归生成 label,保留 children 树形结构;仅门店可选,渠道/代理等禁用 */
function normalizeOrgTreeForSelect (nodes) {
if (!nodes || !Array.isArray(nodes)) return []
return nodes.map((n) => {
const typeLabel = ORG_TYPE_LABEL[n.type] || n.type || '组织'
let label = `${n.name || '-'}(${typeLabel})`
const isStore = (n.type || '').toUpperCase() === ORG_TYPE_STORE
const item = { ...n, label, disabled: !isStore }
if (n.children?.length) item.children = normalizeOrgTreeForSelect(n.children)
return item
})
}
const formRef = ref(null)
const sameChannelOrgTree = ref([])
const sameChannelOrgsLoading = ref(false)
const targetDeviceList = ref([])
const targetDeviceLoading = ref(false)
const submitLoading = ref(false)
const addDeviceFormVisible = ref(false)
const addDeviceFormRef = ref(null)
const orgTreeSelectData = computed(() => normalizeOrgTreeForSelect(sameChannelOrgTree.value))
/** 转移原因下拉选项(模拟数据) */
const TRANSFER_REASON_OTHER = '__OTHER__'
const transferReasonOptions = [
{ value: '设备更换', label: '设备更换' },
{ value: '房间调整', label: '房间调整' },
{ value: '设备故障', label: '设备故障' },
{ value: '门店迁移', label: '门店迁移' },
{ value: '设备升级', label: '设备升级' },
{ value: TRANSFER_REASON_OTHER, label: '其他' }
]
const form = reactive({
targetOrgId: null,
toDeviceId: null,
reason: '',
reasonOther: ''
})
const addDeviceForm = reactive({
mac: '',
deviceSn: '',
roomNo: '',
remark: ''
})
const addDeviceRules = {
mac: [
{ required: true, message: '请输入 MAC 地址', trigger: 'blur' },
{
pattern: /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$|^([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}$|^[0-9A-Fa-f]{12}$/,
message: '格式应为 6 组两位十六进制,如 AA:BB:CC:DD:EE:FF',
trigger: 'blur'
}
]
}
/** 从组织树中查找选中组织的 label */
function findOrgLabelById (nodes, id) {
if (!nodes || !id) return ''
for (const n of nodes) {
if (n.id === id) return n.label || n.name || `ID ${id}`
if (n.children?.length) {
const found = findOrgLabelById(n.children, id)
if (found) return found
}
}
return ''
}
const targetOrgLabel = computed(() => {
if (!form.targetOrgId) return '-'
return findOrgLabelById(orgTreeSelectData.value, form.targetOrgId) || `ID ${form.targetOrgId}`
})
/** 是否为新增设备模式(目标设备即待添加的设备,与转移一起提交) */
const isNewDevice = computed(() => form.toDeviceId === ADD_DEVICE_VALUE)
const rules = {
targetOrgId: [{ required: true, message: '请选择目标门店', trigger: 'change' }],
toDeviceId: [{ required: true, message: '请选择目标设备', trigger: 'change' }],
reason: [{ required: true, message: '请选择转移原因', trigger: 'change' }]
}
function parseSameChannelOrgList (data) {
if (!data) return []
if (data.code === 0 && Array.isArray(data.list)) return data.list
if (data.code === 0 && Array.isArray(data.data)) return data.data
if (data.code === 0 && data.page && Array.isArray(data.page.list)) return data.page.list
if (Array.isArray(data)) return data
if (Array.isArray(data.list)) return data.list
return []
}
async function loadSameChannelOrgs () {
const orgId = sourceShopOrgId.value
if (orgId == null) {
sameChannelOrgTree.value = []
return
}
sameChannelOrgsLoading.value = true
try {
const { data } = await getSameChannelOrgs(orgId)
sameChannelOrgTree.value = parseSameChannelOrgList(data)
if (sameChannelOrgTree.value.length === 0 && data?.code === 0) {
ElMessage.warning('未获取到同渠道组织')
}
} catch {
sameChannelOrgTree.value = []
ElMessage.error('加载同渠道组织失败')
} finally {
sameChannelOrgsLoading.value = false
}
}
function resetForm () {
form.targetOrgId = null
form.toDeviceId = null
form.reason = ''
form.reasonOther = ''
addDeviceFormVisible.value = false
addDeviceForm.mac = ''
addDeviceForm.deviceSn = ''
addDeviceForm.roomNo = ''
addDeviceForm.remark = ''
sameChannelOrgTree.value = []
targetDeviceList.value = []
}
watch(() => props.modelValue, (visible) => {
if (visible) {
resetForm()
loadSameChannelOrgs()
}
}, { immediate: true })
async function loadTargetDevices (orgId) {
if (!orgId) {
targetDeviceList.value = []
return
}
targetDeviceLoading.value = true
const sourceId = sourceRow.value.id ?? sourceRow.value.deviceId
try {
const { data } = await getDeviceListByOrg(orgId)
const list = (data?.code === 0 && Array.isArray(data?.list)) ? data.list : []
const raw = normalizeDeviceList(list || [])
targetDeviceList.value = raw.filter(d => (d.id ?? d.deviceId) !== sourceId)
} catch {
targetDeviceList.value = []
} finally {
targetDeviceLoading.value = false
}
}
watch(() => form.targetOrgId, (orgId) => {
form.toDeviceId = null
targetDeviceList.value = []
addDeviceFormVisible.value = false
if (!orgId) return
loadTargetDevices(orgId)
})
function onTargetDeviceChange (val) {
if (val === ADD_DEVICE_VALUE) {
form.toDeviceId = ADD_DEVICE_VALUE
addDeviceFormVisible.value = true
} else {
cancelAddDevice(false)
}
}
function onClosed () {
resetForm()
formRef.value?.resetFields()
}
/** @param {boolean} clearSelection - 是否清空目标设备选择(点击取消添加为 true,选择已有设备为 false) */
function cancelAddDevice (clearSelection = true) {
if (clearSelection && form.toDeviceId === ADD_DEVICE_VALUE) form.toDeviceId = null
addDeviceFormVisible.value = false
addDeviceForm.mac = ''
addDeviceForm.deviceSn = ''
addDeviceForm.roomNo = ''
addDeviceForm.remark = ''
addDeviceFormRef.value?.resetFields()
}
function getReasonText () {
if (form.reason === TRANSFER_REASON_OTHER) {
return (form.reasonOther || '').trim()
}
return (form.reason || '').trim()
}
function submit () {
const doSubmit = async () => {
const fromId = sourceRow.value.id ?? sourceRow.value.deviceId
if (!fromId) {
ElMessage.warning('源设备无效')
return
}
if (!form.targetOrgId) {
ElMessage.warning('请选择目标门店')
return
}
const reason = getReasonText()
if (!reason) {
ElMessage.warning(form.reason === TRANSFER_REASON_OTHER ? '请输入具体原因' : '请选择转移原因')
return
}
let toDeviceId = form.toDeviceId
const isNew = isNewDevice.value
if (isNew) {
const addValid = await addDeviceFormRef.value?.validate().catch(() => false)
if (!addValid) return
if (!addDeviceForm.mac?.trim()) {
ElMessage.warning('请填写新增设备的 MAC 地址')
return
}
} else {
if (!toDeviceId) {
ElMessage.warning('请选择目标设备')
return
}
}
const confirmMsg = isNew
? '将添加新设备并转移卡,确定执行吗?'
: '确定将该卡从源设备转移到目标设备吗?'
ElMessageBox.confirm(confirmMsg, '二次确认', {
confirmButtonText: '确认转移',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
submitLoading.value = true
try {
const payload = {
fromDeviceId: fromId,
reason,
isNewDevice: isNew
}
if (isNew) {
payload.newDeviceInfo = {
storeOrgId: form.targetOrgId,
preBind: false,
mac: addDeviceForm.mac.trim(),
deviceSn: addDeviceForm.deviceSn?.trim() || undefined,
roomNo: addDeviceForm.roomNo?.trim() || undefined,
remark: addDeviceForm.remark?.trim() || undefined
}
} else {
payload.toDeviceId = toDeviceId
}
const { data } = await transferDeviceCard(payload)
if (data && data.code === 0) {
ElMessage.success('卡转移成功')
emit('success')
} else {
ElMessage.error(data?.msg || '卡转移失败')
}
} catch (err) {
const msg = err?.response?.data?.msg ?? err?.message ?? '请求失败'
ElMessage.error(msg)
} finally {
submitLoading.value = false
}
}).catch(() => {})
}
formRef.value?.validate((valid) => {
if (!valid) return
doSubmit()
})
}
</script>
<style lang="scss" scoped>
.transfer-readonly {
font-size: 14px;
line-height: 1.6;
.text-secondary {
color: var(--el-text-color-secondary);
font-size: 13px;
}
}
.text-secondary {
color: var(--el-text-color-secondary);
}
.tip-line {
margin-top: 6px;
font-size: 12px;
}
.add-device-inline-form {
margin-top: 0;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-radius: 4px;
}
.add-hint {
margin-left: 8px;
font-size: 12px;
}
.reason-other-input {
margin-top: 8px;
width: 100%;
}
</style>
<style lang="scss">
.transfer-org-tree-select-popper {
min-width: 320px !important;
.el-tree-node__content {
height: auto;
min-height: 32px;
padding: 4px 0;
align-items: flex-start;
}
}
.transfer-device-select-popper {
.add-device-option {
color: var(--el-color-primary);
font-weight: 500;
}
}
</style>

View File

@@ -0,0 +1,7 @@
import type { DeviceListItem } from '@/utils/device-list'
export interface DeviceUpdateMacForm {
mac: string
}
export type DeviceUpdateMacDevice = DeviceListItem | null

View File

@@ -0,0 +1,155 @@
<template>
<el-dialog
:model-value="modelValue"
title="修改 MAC 地址"
width="440px"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="onClosed">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="当前 MAC">
<el-input :model-value="currentMacDisplay" disabled />
</el-form-item>
<el-form-item label="新 MAC" prop="mac">
<el-input
v-model="form.mac"
placeholder="如 AA:BB:CC:DD:EE:FF(将统一为大写)"
clearable
@blur="onMacBlur" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage, type FormInstance } from 'element-plus'
import { updateDeviceMac } from '@/api/device'
import type { DeviceUpdateMacDevice, DeviceUpdateMacForm } from './device-update-mac-dialog.data'
defineOptions({ name: 'DeviceUpdateMacDialog' })
const MAC_COLON_RE = /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/
const props = withDefaults(defineProps<{
modelValue?: boolean
/** 列表行或详情对象,需含 id 或 deviceId,以及 mac */
device?: DeviceUpdateMacDevice
}>(), {
modelValue: false,
device: null
})
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'success'): void
}>()
const formRef = ref<FormInstance | null>(null)
const submitLoading = ref(false)
const form = reactive<DeviceUpdateMacForm>({ mac: '' })
const rules = {
mac: [
{ required: true, message: '请输入新 MAC 地址', trigger: 'blur' },
{
validator: (_rule: unknown, value: unknown, callback: (e?: Error) => void) => {
const s = String(value ?? '').trim()
if (!s) {
callback(new Error('请输入新 MAC 地址'))
return
}
if (!MAC_COLON_RE.test(s)) {
callback(new Error('格式须为 AA:BB:CC:DD:EE:FF(6 组两位十六进制,英文冒号分隔)'))
return
}
callback()
},
trigger: 'blur'
}
]
}
const deviceId = computed(() => {
const d = props.device
if (!d) return null
return d.id ?? d.deviceId ?? null
})
const currentMacDisplay = computed(() => {
const m = props.device?.mac
return m != null && String(m).trim() !== '' ? String(m) : '-'
})
function normalizeMacSegment (raw: unknown): string {
return String(raw ?? '')
.trim()
.replace(/-/g, ':')
.toUpperCase()
}
/** 失焦时:若用户输入 12 位连续 hex,自动转为冒号格式 */
function onMacBlur (): void {
let s = String(form.mac ?? '').trim().replace(/-/g, ':').toUpperCase()
if (/^[0-9A-F]{12}$/.test(s)) {
const parts = s.match(/.{2}/g)
s = parts ? parts.join(':') : s
form.mac = s
} else if (s) {
form.mac = s
}
}
function onClosed (): void {
form.mac = ''
formRef.value?.resetFields()
}
watch(
() => props.modelValue,
vis => {
if (vis) {
form.mac = ''
nextTick(() => {
formRef.value?.clearValidate()
})
}
}
)
async function submit (): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
const id = deviceId.value
if (id == null || id === '') {
ElMessage.error('设备信息有误')
return
}
const mac = normalizeMacSegment(form.mac)
if (!MAC_COLON_RE.test(mac)) {
ElMessage.warning('格式须为 AA:BB:CC:DD:EE:FF')
return
}
submitLoading.value = true
try {
const { data } = await updateDeviceMac({ deviceId: id, mac })
if (data?.code === 0) {
ElMessage.success('MAC 修改成功')
emit('update:modelValue', false)
emit('success')
} else {
ElMessage.error(data?.msg || 'MAC 修改失败')
}
} catch (err: unknown) {
const e = err as { response?: { data?: { msg?: string } }; message?: string }
ElMessage.error(e?.response?.data?.msg ?? e?.message ?? '请求失败')
} finally {
submitLoading.value = false
}
}
</script>

View File

@@ -0,0 +1,13 @@
import type { DeviceListItem } from '@/utils/device-list'
export interface StoreOption {
id: string | number
name: string
}
export interface ActivationFailedQueryForm {
orgId: string | number | null
mac: string
}
export type ActivationFailedDeviceRow = DeviceListItem

View File

@@ -0,0 +1,237 @@
<template>
<div class="mod-device-activation-failed">
<el-card shadow="never" class="device-list-card">
<template #header>
<div class="card-header-inner">
<span>激活失败设备</span>
<el-button @click="goBack">返回设备列表</el-button>
</div>
</template>
<el-alert
type="warning"
:closable="false"
show-icon
class="device-alert">
以下设备激活失败,仅支持回归渠道操作。回归后设备将变为未绑定状态,卡可另作他用。
</el-alert>
<el-form :inline="true" :model="queryForm" @keyup.enter="getDataList" class="device-toolbar">
<el-form-item label="门店">
<el-select
v-model="queryForm.orgId"
placeholder="全部门店"
clearable
filterable
style="width: 220px"
@focus="loadStoreOptions">
<el-option
v-for="item in storeOptions"
:key="item.id"
:label="item.name"
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="MAC地址">
<el-input
v-model="queryForm.mac"
placeholder="支持模糊搜索"
clearable
style="width: 200px" />
</el-form-item>
<el-form-item>
<el-button @click="getDataList">查询</el-button>
</el-form-item>
</el-form>
<el-table
:data="dataList"
border
v-loading="loading"
row-key="id"
style="width: 100%">
<el-table-column header-align="center" align="center" width="70" label="序号">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="mac" header-align="center" align="center" min-width="120" label="MAC地址" show-overflow-tooltip />
<el-table-column prop="bindCardNo" header-align="center" align="center" min-width="120" label="卡号" show-overflow-tooltip />
<el-table-column prop="roomNo" header-align="center" align="center" width="100" label="房间号" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.roomNo ?? '-' }}
</template>
</el-table-column>
<el-table-column prop="storeName" header-align="center" align="center" width="120" label="所属门店" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.storeName ?? scope.row.storeOrgName ?? '-' }}
</template>
</el-table-column>
<el-table-column prop="remark" header-align="center" min-width="120" label="备注" show-overflow-tooltip />
<el-table-column label="操作" align="center" width="120" fixed="right">
<template #default="scope">
<el-button
type="warning"
plain
size="small"
:loading="unbindLoading && (scope.row.id ?? scope.row.deviceId) === unbindDeviceId"
@click="handleUnbind(scope.row)">
回归渠道
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > 0"
class="device-pagination"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle" />
<el-empty v-else-if="!loading" description="暂无激活失败设备" />
</el-card>
</div>
</template>
<script setup lang="ts">
// @ts-nocheck — 门店列表与设备分页 API 与 PageResult 声明不一致。
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getDeviceList, unbindDevice } from '@/api/device'
import { getStoreList } from '@/api/store'
import { normalizeDeviceList } from '@/utils/device-list'
import type { ActivationFailedDeviceRow, ActivationFailedQueryForm, StoreOption } from './device-activation-failed.data'
defineOptions({ name: 'DeviceActivationFailed' })
const ACTIVATION_FAILED = 'ACTIVATION_FAILED'
const router = useRouter()
const unbindLoading = ref(false)
const unbindDeviceId = ref<string | number | null>(null)
const storeOptions = ref<StoreOption[]>([])
const queryForm = reactive<ActivationFailedQueryForm>({ orgId: null, mac: '' })
const dataList = ref<ActivationFailedDeviceRow[]>([])
const loading = ref(false)
const pageIndex = ref(1)
const pageSize = ref(10)
const total = ref(0)
function goBack (): void {
router.push({ name: 'device-device' }).catch(() => router.back())
}
function parseStoreListPayload (data: Record<string, unknown> | null | undefined): unknown[] {
if (!data) return []
const codeOk = data.code === 0 || data.code === undefined
if (!codeOk) return []
if (Array.isArray(data.list)) return data.list
if (Array.isArray(data.data)) return data.data
if (Array.isArray(data.data?.list)) return data.data.list
if (Array.isArray(data.page?.list)) return data.page.list
return []
}
function loadStoreOptions () {
if (storeOptions.value.length > 0) return
getStoreList()
.then(({ data }) => {
const list = parseStoreListPayload(data as Record<string, unknown>)
storeOptions.value = (list || []).map((item: Record<string, unknown>) => ({
id: item.id ?? item.storeId ?? item.orgId ?? item.storeOrgId,
name: (item.name ?? item.storeName ?? item.orgName ?? '-') as string
})).filter((item: StoreOption): item is StoreOption => item.id != null)
})
.catch(() => { storeOptions.value = [] })
}
function getDataList (): void {
loading.value = true
getDeviceList({
page: pageIndex.value,
limit: pageSize.value,
storeOrgId: queryForm.orgId || undefined,
mac: queryForm.mac?.trim() || undefined,
cardStatus: ACTIVATION_FAILED
})
.then(({ data }) => {
dataList.value = normalizeDeviceList(data?.page?.list || [])
total.value = data?.page?.totalCount ?? 0
})
.catch(() => {
dataList.value = []
total.value = 0
})
.finally(() => { loading.value = false })
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function handleUnbind (row: ActivationFailedDeviceRow): void {
const id = row.id ?? row.deviceId
const mac = row.mac ?? '-'
ElMessageBox.confirm(
`确定要让该设备(MAC: ${mac})回归渠道吗?回归后设备将变为未绑定状态。`,
'确认回归渠道',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
unbindLoading.value = true
unbindDeviceId.value = id
unbindDevice({ deviceId: id })
.then(({ data }) => {
if (data && data.code === 0) {
ElMessage.success('回归渠道成功')
getDataList()
} else {
ElMessage.error(data?.msg || '回归渠道失败')
}
})
.catch((err: unknown) => {
const e = err as { response?: { data?: { msg?: string } }; message?: string }
const msg = e?.response?.data?.msg ?? e?.message ?? '请求失败'
ElMessage.error(msg)
})
.finally(() => {
unbindLoading.value = false
unbindDeviceId.value = null
})
}).catch(() => {})
}
onMounted(() => {
getDataList()
})
</script>
<style lang="scss" scoped>
.mod-device-activation-failed {
.card-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.device-alert {
margin-bottom: 12px;
}
.device-toolbar {
margin-bottom: 12px;
}
.device-pagination {
margin-top: 12px;
}
}
</style>

View File

@@ -0,0 +1,33 @@
import type { DeviceListItem } from '@/utils/device-list'
export const STATUS_CONFIG = {
UNBOUND: { label: '未绑定', class: 'unbound' },
PRESET: { label: '预置', class: 'preset' },
ACTIVATING: { label: '激活中', class: 'activating' },
ACTIVATION_FAILED: { label: '激活失败', class: 'activation-failed' },
ACTIVE: { label: '已激活', class: 'active' },
EXPIRED: { label: '过期', class: 'expired' },
ACTIVATION_BIND: { label: '激活码', class: 'activation-bind' }
} as const
export type DeviceStatusKey = keyof typeof STATUS_CONFIG
export const STATUS_KEYS = Object.keys(STATUS_CONFIG) as DeviceStatusKey[]
export const STATUS_ALIASES: Record<string, string> = {
ACTIVATED: 'ACTIVE',
ACTIVATE_FAILED: 'ACTIVATION_FAILED'
}
export const STATUS_NUM_MAP: Record<number, DeviceStatusKey | 'UNBOUND'> = {
1: 'UNBOUND',
2: 'PRESET',
3: 'ACTIVATING',
4: 'ACTIVATION_FAILED',
5: 'ACTIVE',
6: 'EXPIRED'
}
export type DuplicateMacSnDeviceRow = DeviceListItem
export type ActionKind = 'clear' | 'delete' | 'disable'

View File

@@ -0,0 +1,353 @@
<template>
<div class="mod-device-duplicate-mac-sn">
<el-card shadow="never" class="list-card">
<template #header><span>重复 MAC / 设备 SN</span></template>
<el-form :inline="true" @keyup.enter="searchHandle" class="toolbar">
<el-form-item label="MAC地址">
<el-input
v-model="searchMac"
placeholder="MAC搜索"
clearable
style="width: 220px"
@keyup.enter="searchHandle"
/>
</el-form-item>
<el-form-item>
<el-button @click="searchHandle">查询</el-button>
</el-form-item>
</el-form>
<el-table :data="dataList" border v-loading="loading" style="width: 100%">
<el-table-column header-align="center" align="center" width="70" label="序号">
<template #default="scope">{{ scope.$index + 1 }}</template>
</el-table-column>
<el-table-column prop="mac" header-align="center" align="center" min-width="130" label="MAC" show-overflow-tooltip>
<template #default="{ row }">
<div class="copy-mac-cell" title="点击复制 MAC" @click="copyMac(row)">{{ row.mac ?? '-' }}</div>
</template>
</el-table-column>
<el-table-column prop="deviceSn" header-align="center" align="center" min-width="120" label="设备序列号" show-overflow-tooltip />
<el-table-column prop="deviceUid" header-align="center" align="center" min-width="120" label="设备 UID" show-overflow-tooltip />
<el-table-column header-align="center" align="center" min-width="165" label="创建时间" show-overflow-tooltip>
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column header-align="center" align="center" width="110" label="绑定/权益状态">
<template #default="{ row }">
<el-tag effect="plain" size="small" class="device-status-tag" :class="getStatusDisplayClass(row)">{{ getStatusDisplayLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="storeName" header-align="center" align="center" min-width="160" label="所属门店" show-overflow-tooltip>
<template #default="{ row }">{{ row.storeName ?? '-' }}</template>
</el-table-column>
<el-table-column label="操作" align="center" width="480" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="canReplaceDuplicate(row)"
type="primary"
plain
size="small"
@click="openReplaceDialog(row)">设备替换</el-button>
<el-button
v-if="canDisableDevice(row)"
type="danger"
plain
size="small"
:loading="isActionLoading(row, 'disable')"
@click="handleDisableDevice(row)">禁用</el-button>
<el-button
v-if="!isDeviceDisabled(row)"
type="warning"
plain
size="small"
:loading="isActionLoading(row, 'clear')"
@click="handleClearSnUid(row)">清空设备信息</el-button>
<el-button
v-if="!isDeviceDisabled(row)"
type="danger"
plain
size="small"
:loading="isActionLoading(row, 'delete')"
@click="handleDeleteDevice(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > 0"
class="pagination"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle" />
</el-card>
<DeviceReplaceDuplicateDialog
v-model="replaceDialogVisible"
:duplicate-device="replaceTargetRow"
@success="getDataList" />
</div>
</template>
<script setup lang="ts">
// @ts-nocheck — 分页与 SoftDelete API 参数与生成类型不完全一致。
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getDuplicateMacDeviceSnList, clearDeviceSnUid, softDeleteDevice, disableDevice } from '@/api/device'
import { normalizeDeviceList } from '@/utils/device-list'
import DeviceReplaceDuplicateDialog from './components/device-replace-duplicate-dialog.vue'
import {
STATUS_ALIASES,
STATUS_CONFIG,
STATUS_KEYS,
STATUS_NUM_MAP,
type ActionKind,
type DeviceStatusKey,
type DuplicateMacSnDeviceRow
} from './device-duplicate-mac-sn.data'
defineOptions({ name: 'DeviceDuplicateMacSn' })
const dataList = ref<DuplicateMacSnDeviceRow[]>([])
const loading = ref(false)
const pageIndex = ref(1)
const pageSize = ref(10)
const total = ref(0)
/** 当前进行中的操作:`'clear-' + deviceId` | `'delete-' + deviceId` | `'disable-' + deviceId` */
const actionKey = ref('')
const searchMac = ref('')
const replaceDialogVisible = ref(false)
const replaceTargetRow = ref<DuplicateMacSnDeviceRow | null>(null)
const getDeviceId = (row: DuplicateMacSnDeviceRow | undefined | null): string | number | undefined =>
row?.id ?? row?.deviceId
const isActionLoading = (row: DuplicateMacSnDeviceRow, kind: ActionKind): boolean =>
actionKey.value === `${kind}-${getDeviceId(row)}`
const getStatusKey = (row: DuplicateMacSnDeviceRow): DeviceStatusKey | 'UNBOUND' => {
const v = row?.bindCardStatus
if (v == null || v === '') return 'UNBOUND'
if (typeof v === 'number') return STATUS_NUM_MAP[v] || 'UNBOUND'
let s = String(v).toUpperCase().replace(/-/g, '_')
while (STATUS_ALIASES[s]) s = STATUS_ALIASES[s]
return STATUS_KEYS.includes(s as DeviceStatusKey) ? (s as DeviceStatusKey) : 'UNBOUND'
}
const getBindStatusLabel = (row: DuplicateMacSnDeviceRow): string =>
row?.bindCardStatusLabel || STATUS_CONFIG[getStatusKey(row) as DeviceStatusKey]?.label || '未绑定'
const getStatusClass = (row: DuplicateMacSnDeviceRow): string =>
`device-status--${STATUS_CONFIG[getStatusKey(row) as DeviceStatusKey]?.class || 'unknown'}`
const isDeviceDisabled = (row: DuplicateMacSnDeviceRow): boolean => String(row?.status ?? '').toUpperCase() === 'DISABLED'
const canDisableDevice = (row: DuplicateMacSnDeviceRow): boolean => !isDeviceDisabled(row)
const getStatusDisplayLabel = (row: DuplicateMacSnDeviceRow): string => (isDeviceDisabled(row) ? '已禁用' : getBindStatusLabel(row))
const getStatusDisplayClass = (row: DuplicateMacSnDeviceRow): string => (isDeviceDisabled(row) ? 'device-status--disabled' : getStatusClass(row))
/** 无有效卡绑定的重复记录可发起替换(device2) */
const canReplaceDuplicate = (row: DuplicateMacSnDeviceRow): boolean => !isDeviceDisabled(row) && getStatusKey(row) === 'UNBOUND'
const openReplaceDialog = (row: DuplicateMacSnDeviceRow): void => {
const deviceId = getDeviceId(row)
if (deviceId == null) {
ElMessage.warning('缺少设备 ID,无法替换')
return
}
replaceTargetRow.value = row
replaceDialogVisible.value = true
}
const formatDateTime = (val: string | number | null | undefined): string => {
if (val == null || val === '') return '-'
if (typeof val === 'number') {
const d = new Date(val > 1e12 ? val : val * 1000)
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleString('zh-CN', { hour12: false })
}
return String(val).replace('T', ' ').slice(0, 19) || '-'
}
const getDataList = (): void => {
loading.value = true
const params: { page: number; limit: number; mac?: string } = {
page: pageIndex.value,
limit: pageSize.value
}
const mac = searchMac.value.trim()
if (mac) params.mac = mac
getDuplicateMacDeviceSnList(params).then(({ data }) => {
dataList.value = normalizeDeviceList(data?.page?.list || [])
total.value = data?.page?.totalCount ?? 0
}).catch(() => {
dataList.value = []
total.value = 0
}).finally(() => { loading.value = false })
}
const sizeChangeHandle = (val: number): void => { pageSize.value = val; pageIndex.value = 1; getDataList() }
const currentChangeHandle = (val: number): void => { pageIndex.value = val; getDataList() }
const searchHandle = (): void => {
pageIndex.value = 1
getDataList()
}
const copyMac = async (row: DuplicateMacSnDeviceRow): Promise<void> => {
const text = String(row?.mac ?? '').trim()
if (!text || text === '-') {
ElMessage.warning('暂无 MAC 可复制')
return
}
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.cssText = 'position:fixed;opacity:0;'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
ElMessage.success(`已复制:${text}`)
} catch (err: unknown) {
const e = err as { message?: string }
ElMessage.error(e?.message || '复制失败')
}
}
const handleClearSnUid = (row: DuplicateMacSnDeviceRow): void => {
const deviceId = getDeviceId(row)
if (deviceId == null) {
ElMessage.warning('缺少设备 ID,无法清空')
return
}
const mac = row?.mac ?? '-'
ElMessageBox.confirm(
`确定要清空该设备(MAC: ${mac})的序列号与 UID 吗?清空后 device_sn、device_uid 将置空。`,
'清空设备信息',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
actionKey.value = `clear-${deviceId}`
clearDeviceSnUid({ deviceId: deviceId as number })
.then(({ data }) => {
if (data?.code === 0) {
ElMessage.success('已清空设备信息')
getDataList()
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
.catch((err: unknown) => {
const e = err as { response?: { data?: { msg?: string } }; message?: string }
ElMessage.error(e?.response?.data?.msg ?? e?.message ?? '请求失败')
})
.finally(() => { actionKey.value = '' })
}).catch(() => {})
}
const handleDeleteDevice = (row: DuplicateMacSnDeviceRow): void => {
const deviceId = getDeviceId(row)
if (deviceId == null) {
ElMessage.warning('缺少设备 ID,无法删除')
return
}
const mac = row?.mac ?? '-'
ElMessageBox.confirm(
`确定要软删除该设备记录(MAC: ${mac})吗?删除后可在后台按业务规则恢复或清理。`,
'软删除设备',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
actionKey.value = `delete-${deviceId}`
softDeleteDevice({ deviceId: deviceId as number })
.then(({ data }) => {
if (data?.code === 0) {
ElMessage.success('软删除成功')
getDataList()
} else {
ElMessage.error(data?.msg || '软删除失败')
}
})
.catch((err: unknown) => {
const e = err as { response?: { data?: { msg?: string } }; message?: string }
ElMessage.error(e?.response?.data?.msg ?? e?.message ?? '请求失败')
})
.finally(() => { actionKey.value = '' })
}).catch(() => {})
}
const handleDisableDevice = (row: DuplicateMacSnDeviceRow): void => {
const deviceId = getDeviceId(row)
if (deviceId == null) {
ElMessage.warning('缺少设备 ID,无法禁用')
return
}
const mac = row?.mac ?? '-'
const storeName = row?.storeName ?? '-'
ElMessageBox.confirm(
`确定要禁用该设备(MAC: ${mac},门店: ${storeName})吗?禁用后该设备将不可再执行清空、删除等操作。`,
'禁用设备',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
actionKey.value = `disable-${deviceId}`
disableDevice({ deviceId: deviceId as number })
.then(({ data }) => {
if (data?.code === 0) {
ElMessage.success('设备已禁用')
getDataList()
} else {
ElMessage.error(data?.msg || '禁用失败')
}
})
.catch((err: unknown) => {
const e = err as { response?: { data?: { msg?: string } }; message?: string }
ElMessage.error(e?.response?.data?.msg ?? e?.message ?? '请求失败')
})
.finally(() => { actionKey.value = '' })
}).catch(() => {})
}
onMounted(() => {
getDataList()
})
</script>
<style lang="scss" scoped>
.mod-device-duplicate-mac-sn {
.list-card { margin-top: 0; }
.toolbar { margin-bottom: 12px; }
.pagination { margin-top: 12px; }
.table-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px;
}
.copy-mac-cell {
cursor: pointer;
margin: -10px -12px;
padding: 10px 12px;
color: inherit;
text-align: center;
user-select: none;
}
.device-status-tag {
border-width: 1px;
font-weight: 500;
&.device-status--unbound { background-color: #f4f4f5 !important; border-color: #c0c4cc !important; color: #606266 !important; }
&.device-status--preset { background-color: #fdf6ec !important; border-color: #e6a23c !important; color: #b88230 !important; }
&.device-status--active { background-color: #f0f9eb !important; border-color: #67c23a !important; color: #529b2e !important; }
&.device-status--activating { background-color: #ecf5ff !important; border-color: #409eff !important; color: #337ecc !important; }
&.device-status--activation-failed { background-color: #fef0f0 !important; border-color: #f56c6c !important; color: #c45656 !important; }
&.device-status--expired { background-color: #f4e9ff !important; border-color: #9b59b6 !important; color: #7d3c98 !important; }
&.device-status--activation-bind { background-color: #e8f4fd !important; border-color: #0ea5e9 !important; color: #0284c7 !important; }
&.device-status--disabled { background-color: #fef0f0 !important; border-color: #f56c6c !important; color: #c45656 !important; }
&.device-status--unknown { background-color: #ebeef5 !important; border-color: #909399 !important; color: #303133 !important; }
}
}
</style>

View File

@@ -0,0 +1,40 @@
import type { DeviceListItem } from '@/utils/device-list'
export const STATUS_CONFIG = {
UNBOUND: { label: '未绑定', class: 'unbound' },
PRESET: { label: '预置', class: 'preset' },
ACTIVATING: { label: '激活中', class: 'activating' },
ACTIVATION_FAILED: { label: '激活失败', class: 'activation-failed' },
ACTIVE: { label: '已激活', class: 'active' },
EXPIRED: { label: '过期', class: 'expired' },
ACTIVATION_BIND: { label: '激活码', class: 'activation-bind' }
} as const
export type DeviceStatusKey = keyof typeof STATUS_CONFIG
export const STATUS_KEYS = Object.keys(STATUS_CONFIG) as DeviceStatusKey[]
export const STATUS_ALIASES: Record<string, string> = {
ACTIVATED: 'ACTIVE',
ACTIVATE_FAILED: 'ACTIVATION_FAILED'
}
export const STATUS_NUM_MAP: Record<number, DeviceStatusKey | 'UNBOUND'> = {
1: 'UNBOUND',
2: 'PRESET',
3: 'ACTIVATING',
4: 'ACTIVATION_FAILED',
5: 'ACTIVE',
6: 'EXPIRED'
}
export interface StoreOption {
id: string | number
name: string
}
export interface ExpiringYearQueryForm {
orgId: string | number | null
}
export type ExpiringYearDeviceRow = DeviceListItem

View File

@@ -0,0 +1,263 @@
<template>
<div class="mod-device-expiring-year">
<el-card shadow="never" class="list-card">
<template #header><span>临期设备</span></template>
<el-alert type="info" :closable="false" show-icon class="hint-alert">
展示权益即将到期的设备(年卡等),便于提前续费或跟进。数据与设备列表权限一致。
</el-alert>
<el-form :inline="true" :model="queryForm" @keyup.enter="getDataList" class="toolbar">
<el-form-item label="门店">
<el-select v-model="queryForm.orgId" placeholder="全部门店" clearable filterable style="width: 220px" @focus="loadStoreOptions">
<el-option v-for="item in storeOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="getDataList">查询</el-button>
</el-form-item>
</el-form>
<el-table :data="dataList" border v-loading="loading" row-key="id" style="width: 100%">
<el-table-column header-align="center" align="center" width="70" label="序号">
<template #default="scope">{{ scope.$index + 1 }}</template>
</el-table-column>
<el-table-column prop="mac" header-align="center" align="center" min-width="130" label="MAC" show-overflow-tooltip>
<template #default="{ row }">
<div class="copy-mac-cell" title="点击复制 MAC" @click="copyMac(row)">{{ row.mac ?? '-' }}</div>
</template>
</el-table-column>
<el-table-column header-align="center" align="center" width="110" label="设备状态">
<template #default="{ row }">
<el-tag effect="plain" size="small" class="device-status-tag" :class="getStatusClass(row)">{{ getBindStatusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="bindCardNo" header-align="center" align="center" min-width="120" label="卡号" show-overflow-tooltip>
<template #default="{ row }">{{ row.bindCardNo ?? '-' }}</template>
</el-table-column>
<el-table-column header-align="center" align="center" min-width="160" label="授权时间" show-overflow-tooltip>
<template #default="{ row }">{{ formatAuthTime(row) }}</template>
</el-table-column>
<el-table-column header-align="center" align="center" min-width="160" label="过期时间" show-overflow-tooltip>
<template #default="{ row }">{{ formatExpireTime(row) }}</template>
</el-table-column>
<el-table-column prop="storeName" header-align="center" align="center" min-width="160" label="所属门店" show-overflow-tooltip>
<template #default="{ row }">{{ row.storeName ?? row.storeOrgName ?? '-' }}</template>
</el-table-column>
<el-table-column prop="roomNo" header-align="center" align="center" width="100" label="房间号" show-overflow-tooltip>
<template #default="{ row }">{{ row.roomNo ?? '-' }}</template>
</el-table-column>
<el-table-column prop="remark" header-align="center" min-width="80" label="备注" show-overflow-tooltip />
</el-table>
<el-pagination
v-if="total > 0"
class="pagination"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle" />
<el-empty v-else-if="!loading" description="暂无临期设备" />
</el-card>
</div>
</template>
<script setup lang="ts">
// @ts-nocheck — 门店下拉与分页 API 响应形态与声明不完全一致。
import { ref, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getExpiringYearDeviceList } from '@/api/device'
import { getStoreList } from '@/api/store'
import { getOrgInfo } from '@/api/org'
import { normalizeDeviceList } from '@/utils/device-list'
import {
STATUS_ALIASES,
STATUS_CONFIG,
STATUS_KEYS,
STATUS_NUM_MAP,
type DeviceStatusKey,
type ExpiringYearDeviceRow,
type ExpiringYearQueryForm,
type StoreOption
} from './device-expiring-year.data'
defineOptions({ name: 'DeviceExpiringYear' })
const route = useRoute()
const storeOptions = ref<StoreOption[]>([])
const storeDropdownFetched = ref(false)
const dataList = ref<ExpiringYearDeviceRow[]>([])
const loading = ref(false)
const pageIndex = ref(1)
const pageSize = ref(10)
const total = ref(0)
const queryForm = reactive<ExpiringYearQueryForm>({ orgId: null })
const parseStoreList = (data: Record<string, unknown> | null | undefined): unknown[] => {
if (!data || (data.code !== 0 && data.code !== undefined)) return []
return (data.list as unknown[]) || (data.data as unknown[]) || (data.data as { list?: unknown[] })?.list || (data.page as { list?: unknown[] })?.list || []
}
function mergeStoreDropdownOptions (newOptions: StoreOption[]): void {
const map = new Map<string, StoreOption>()
for (const o of storeOptions.value) {
if (o?.id != null) map.set(String(o.id), { id: o.id, name: o.name ?? '-' })
}
for (const o of newOptions) {
if (o?.id == null) continue
map.set(String(o.id), { id: o.id, name: o.name ?? '-' })
}
storeOptions.value = Array.from(map.values())
}
const loadStoreOptions = (): Promise<void> => {
if (storeDropdownFetched.value) return Promise.resolve()
return getStoreList().then(({ data }) => {
const list = parseStoreList(data as Record<string, unknown>).map((item: Record<string, unknown>) => ({
id: item.id ?? item.storeId ?? item.orgId ?? item.storeOrgId,
name: (item.name ?? item.storeName ?? item.orgName ?? '-') as string
})).filter((item): item is StoreOption => item.id != null)
mergeStoreDropdownOptions(list)
storeDropdownFetched.value = true
}).catch(() => {
storeDropdownFetched.value = false
})
}
async function bootstrapStoreFilterFromRoute (): Promise<void> {
const raw = route.query.storeOrgId ?? route.query.orgId
if (raw == null || raw === '') return
const s = Array.isArray(raw) ? raw[0] : raw
const n = Number(s)
const id = Number.isFinite(n) && String(n) === String(s).trim() ? n : s
queryForm.orgId = id
try {
const { data } = await getOrgInfo(id)
const d = data as Record<string, unknown> | undefined
const info = (d && d.code === 0 && d.org) ? d.org as Record<string, unknown> : (d?.data as Record<string, unknown> | null)
if (!info) {
ElMessage.warning((d?.msg as string) || '门店不存在或无权查看')
queryForm.orgId = null
return
}
const optId = info.id ?? id
const sp = (info.storeProfile) as Record<string, unknown> | undefined
const optName = (info.name as string) || (sp?.storeName as string) || '-'
mergeStoreDropdownOptions([{ id: optId, name: optName }])
} catch (e: unknown) {
const err = e as { response?: { data?: { msg?: string } }; message?: string }
ElMessage.error(err?.response?.data?.msg ?? err?.message ?? '查询门店失败')
queryForm.orgId = null
}
}
const getStatusKey = (row: ExpiringYearDeviceRow): DeviceStatusKey | 'UNBOUND' => {
const v = row?.bindCardStatus
if (v == null || v === '') return 'UNBOUND'
if (typeof v === 'number') return STATUS_NUM_MAP[v] || 'UNBOUND'
let s = String(v).toUpperCase().replace(/-/g, '_')
while (STATUS_ALIASES[s]) s = STATUS_ALIASES[s]
return STATUS_KEYS.includes(s as DeviceStatusKey) ? (s as DeviceStatusKey) : 'UNBOUND'
}
const getBindStatusLabel = (row: ExpiringYearDeviceRow): string =>
row?.bindCardStatusLabel || STATUS_CONFIG[getStatusKey(row) as DeviceStatusKey]?.label || '未绑定'
const getStatusClass = (row: ExpiringYearDeviceRow): string =>
`device-status--${STATUS_CONFIG[getStatusKey(row) as DeviceStatusKey]?.class || 'unknown'}`
const isPresetDevice = (row: ExpiringYearDeviceRow): boolean => getStatusKey(row) === 'PRESET'
const formatDateTimeVal = (val: string | number | null | undefined): string => {
if (val == null || val === '') return '-'
if (typeof val === 'number') {
const d = new Date(val > 1e12 ? val : val * 1000)
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleString('zh-CN', { hour12: false })
}
return String(val).replace('T', ' ').slice(0, 19) || '-'
}
const formatAuthTime = (row: ExpiringYearDeviceRow): string => {
if (isPresetDevice(row)) return formatDateTimeVal(row.bindCardPresetAt)
return formatDateTimeVal(row.bindCardActiveAt)
}
const formatExpireTime = (row: ExpiringYearDeviceRow): string => formatDateTimeVal(row.bindCardExpireAt)
const getDataList = (): void => {
loading.value = true
getExpiringYearDeviceList({
page: pageIndex.value,
limit: pageSize.value,
storeOrgId: queryForm.orgId || undefined
}).then(({ data }) => {
dataList.value = normalizeDeviceList(data?.page?.list || [])
total.value = data?.page?.totalCount ?? 0
}).catch(() => {
dataList.value = []
total.value = 0
}).finally(() => { loading.value = false })
}
const sizeChangeHandle = (val: number): void => { pageSize.value = val; pageIndex.value = 1; getDataList() }
const currentChangeHandle = (val: number): void => { pageIndex.value = val; getDataList() }
const copyMac = async (row: ExpiringYearDeviceRow): Promise<void> => {
const text = String(row?.mac ?? '').trim()
if (!text || text === '-') return ElMessage.warning('暂无 MAC 可复制')
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.cssText = 'position:fixed;opacity:0;'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
ElMessage.success(`已复制:${text}`)
} catch (err) {
ElMessage.error(err?.message || '复制失败')
}
}
onMounted(async () => {
await bootstrapStoreFilterFromRoute()
await loadStoreOptions()
getDataList()
})
</script>
<style lang="scss" scoped>
.mod-device-expiring-year {
.list-card { margin-top: 0; }
.hint-alert { margin-bottom: 12px; }
.toolbar { margin-bottom: 12px; }
.pagination { margin-top: 12px; }
.copy-mac-cell {
cursor: pointer;
margin: -10px -12px;
padding: 10px 12px;
color: inherit;
text-align: center;
user-select: none;
}
.device-status-tag {
border-width: 1px;
font-weight: 500;
&.device-status--unbound { background-color: #f4f4f5 !important; border-color: #c0c4cc !important; color: #606266 !important; }
&.device-status--preset { background-color: #fdf6ec !important; border-color: #e6a23c !important; color: #b88230 !important; }
&.device-status--active { background-color: #f0f9eb !important; border-color: #67c23a !important; color: #529b2e !important; }
&.device-status--activating { background-color: #ecf5ff !important; border-color: #409eff !important; color: #337ecc !important; }
&.device-status--activation-failed { background-color: #fef0f0 !important; border-color: #f56c6c !important; color: #c45656 !important; }
&.device-status--expired { background-color: #f4e9ff !important; border-color: #9b59b6 !important; color: #7d3c98 !important; }
&.device-status--activation-bind { background-color: #e8f4fd !important; border-color: #0ea5e9 !important; color: #0284c7 !important; }
&.device-status--unknown { background-color: #ebeef5 !important; border-color: #909399 !important; color: #303133 !important; }
}
}
</style>

View File

@@ -0,0 +1,23 @@
/** 设备导入页数据概览(展示用) */
export interface DashboardStats {
cumulativePurchase: number
cumulativeActivation: number
preRecharge: number
currentRemaining: number
estimatedRemaining: number
activationFailedCount: number
unboundActivation: number
activeCodeCount: number
}
/** 概览 byType 单项 */
export interface OrgCardOverviewByTypeRow {
cardType?: string
allocatableCount?: number
[key: string]: unknown
}
export interface OverviewChangePayload {
storeOrgId?: string | number | null
cardType?: string
}

View File

@@ -0,0 +1,136 @@
<template>
<div class="mod-device-import">
<!-- 剩余可用数量展示 -->
<div class="remaining-info">
剩余可用数量:{{ formatStatNum(dashboardStats.currentRemaining) }} 张
</div>
<el-card shadow="never" class="mt-15px">
<template #header>
<div class="card-header-inner">
<span>会员预置(仅限门店)</span>
</div>
</template>
<device-import-form
show-action-bar
:preset-pre-bind-card-type="presetPreBindCardType"
:hide-pre-bind-card-type-selector="false"
@overview-change="onOverviewChange"
@success="onImportSuccess" />
</el-card>
</div>
</template>
<script setup lang="ts">
// @ts-nocheck — 概览接口与路由 meta 与生成 API 类型不一致;共享类型见 device-import.data.ts。
import { computed, onMounted, reactive } from 'vue'
import { useRoute } from 'vue-router'
import DeviceImportForm from './components/device-import-form.vue'
import { getOrgCardOverview } from '@/api/org'
import type { DashboardStats, OrgCardOverviewByTypeRow, OverviewChangePayload } from './device-import.data'
defineOptions({ name: 'DeviceImport' })
const route = useRoute()
const presetPreBindCardType = computed(() => (route.meta?.preBindCardType as string | undefined) ?? 'YEAR')
/** 数据概览展示用(数值由后端在列表接口中扩展字段时再对接) */
const dashboardStats = reactive<DashboardStats>({
cumulativePurchase: 0,
cumulativeActivation: 0,
preRecharge: 0,
currentRemaining: 0,
estimatedRemaining: 0,
activationFailedCount: 0,
unboundActivation: 0,
activeCodeCount: 0
})
function formatStatNum (n: unknown): string {
const v = Number(n)
if (Number.isNaN(v)) return '0'
return Math.trunc(v).toLocaleString('zh-CN')
}
function pickNumber (obj: Record<string, unknown> | null | undefined, keys: string[]): number {
if (!obj || typeof obj !== 'object') return 0
for (let i = 0; i < keys.length; i++) {
const n = Number(obj[keys[i]])
if (!Number.isNaN(n)) return n
}
return 0
}
function applyOverviewStats (payload: Record<string, unknown> & { byType?: OrgCardOverviewByTypeRow[] }, cardType: string): void {
const byType = Array.isArray(payload?.byType) ? payload.byType : []
const row = byType.find((x) => x && x.cardType === cardType)
if (row && row.allocatableCount != null) {
dashboardStats.currentRemaining = pickNumber(row as Record<string, unknown>, ['allocatableCount'])
} else {
dashboardStats.currentRemaining = pickNumber(payload, ['unusedCount'])
}
dashboardStats.cumulativePurchase = pickNumber(payload, ['totalCount'])
dashboardStats.cumulativeActivation = pickNumber(payload, ['activatedCount'])
dashboardStats.preRecharge = pickNumber(payload, ['presetCount'])
dashboardStats.estimatedRemaining = pickNumber(payload, ['estimatedRemainingCount'])
dashboardStats.activationFailedCount = pickNumber(payload, ['activationFailedCount'])
dashboardStats.activeCodeCount = pickNumber(payload, ['activeCodeCount'])
}
function resetOverviewStats (): void {
dashboardStats.cumulativePurchase = 0
dashboardStats.cumulativeActivation = 0
dashboardStats.preRecharge = 0
dashboardStats.currentRemaining = 0
dashboardStats.estimatedRemaining = 0
dashboardStats.activationFailedCount = 0
dashboardStats.unboundActivation = 0
dashboardStats.activeCodeCount = 0
}
function onImportSuccess (): void {
onOverviewChange({ cardType: presetPreBindCardType.value })
}
function onOverviewChange ({ storeOrgId, cardType }: OverviewChangePayload = {}): void {
const resolvedCardType = cardType || presetPreBindCardType.value
getOrgCardOverview({
storeOrgId: storeOrgId || undefined,
cardType: resolvedCardType
})
.then(({ data }) => {
if (data && (data.code === 0 || data.code === undefined)) {
const payload = data.data ?? data
applyOverviewStats(payload, resolvedCardType)
} else {
resetOverviewStats()
}
})
.catch(() => {
resetOverviewStats()
})
}
onMounted(() => {
onOverviewChange({ cardType: presetPreBindCardType.value })
})
</script>
<style lang="scss" scoped>
.mod-device-import {
.remaining-info {
font-size: 14px;
color: #303133;
margin-bottom: 15px;
padding: 10px 0;
}
.card-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
}
</style>

View File

@@ -0,0 +1,14 @@
import type { DeviceListItem } from '@/utils/device-list'
export interface AssignStoreForm {
storeOrgId: string
}
/** 无组织设备列表行 */
export type DeviceInfoRow = DeviceListItem & {
id?: string | number
deviceId?: string | number
mac?: string | null
createdAt?: string | number | null
[key: string]: unknown
}

View File

@@ -0,0 +1,224 @@
<template>
<div class="mod-device-info">
<el-card shadow="never" class="device-info-list-card">
<template #header>
<div class="card-header">
<span>设备信息</span>
<div class="search-box">
<el-input
v-model="searchMac"
placeholder="MAC搜索"
clearable
style="width: 220px"
@keyup.enter="searchHandle"
>
<template #append>
<el-button :icon="Search" @click="searchHandle" />
</template>
</el-input>
</div>
</div>
</template>
<el-table
:data="dataList"
border
v-loading="loading"
row-key="id"
style="width: 100%">
<el-table-column header-align="center" align="center" width="70" label="序号">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="mac" header-align="center" align="center" min-width="120" label="MAC地址" show-overflow-tooltip>
<template #default="{ row }">
{{ row.mac ?? '-' }}
</template>
</el-table-column>
<el-table-column prop="deviceSn" header-align="center" align="center" min-width="120" label="设备Sn" show-overflow-tooltip></el-table-column>
<el-table-column prop="deviceUid" header-align="center" align="center" min-width="120" label="设备Uid" show-overflow-tooltip></el-table-column>
<el-table-column prop="createTime" header-align="center" align="center" min-width="160" label="导入时间" show-overflow-tooltip>
<template #default="{ row }">
{{ formatCreateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column header-align="center" align="center" width="150" label="操作" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openAssignStoreDialog(row)">分配门店</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > pageSize"
class="device-info-pagination"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle" />
</el-card>
<!-- 分配门店对话框 -->
<el-dialog
v-model="assignStoreDialogVisible"
title="分配门店"
width="400px"
:close-on-click-modal="false"
>
<el-form :model="assignStoreForm" label-width="80px">
<el-form-item label="MAC地址">
<span>{{ currentRow?.mac ?? '-' }}</span>
</el-form-item>
<el-form-item label="选择门店" prop="storeOrgId">
<store-select
v-model="assignStoreForm.storeOrgId"
placeholder="请选择门店"
width="100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignStoreDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="assignStoreLoading" @click="submitAssignStore">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
// @ts-nocheck — 门店列表/分页 API 与 PageResult 声明不一致。
import { ref, reactive, onMounted } from 'vue'
import { getDeviceListWithoutOrg, assignDeviceStore } from '@/api/device'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import StoreSelect from '@/components/store-select/index.vue'
import type { AssignStoreForm, DeviceInfoRow } from './device-info.data'
defineOptions({ name: 'DeviceInfo' })
const dataList = ref<DeviceInfoRow[]>([])
const loading = ref(false)
const pageIndex = ref(1)
const pageSize = ref(10)
const total = ref(0)
const searchMac = ref('')
// 分配门店相关
const assignStoreDialogVisible = ref(false)
const assignStoreLoading = ref(false)
const currentRow = ref<DeviceInfoRow | null>(null)
const assignStoreForm = reactive<AssignStoreForm>({
storeOrgId: ''
})
function getDataList (): void {
loading.value = true
const params: { page: number; limit: number; mac?: string } = {
page: pageIndex.value,
limit: pageSize.value
}
if (searchMac.value.trim()) {
params.mac = searchMac.value.trim()
}
getDeviceListWithoutOrg(params)
.then(({ data }) => {
dataList.value = data?.page?.list || []
total.value = data?.page?.totalCount ?? 0
})
.catch(() => {
dataList.value = []
total.value = 0
})
.finally(() => { loading.value = false })
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function searchHandle (): void {
pageIndex.value = 1
getDataList()
}
function formatCreateTime (createTime: string | number | null | undefined): string {
if (!createTime) return '-'
return new Date(createTime).toLocaleString('zh-CN')
}
function openAssignStoreDialog (row: DeviceInfoRow): void {
currentRow.value = row
assignStoreForm.storeOrgId = ''
assignStoreDialogVisible.value = true
}
function submitAssignStore (): void {
if (!assignStoreForm.storeOrgId) {
ElMessage.warning('请选择门店')
return
}
if (!currentRow.value?.id) {
ElMessage.warning('设备信息异常')
return
}
assignStoreLoading.value = true
assignDeviceStore({
deviceId: currentRow.value.id,
toStoreOrgId: assignStoreForm.storeOrgId
})
.then(({ data }) => {
if (data?.code === 0 || data?.code === undefined) {
ElMessage.success('分配成功')
assignStoreDialogVisible.value = false
getDataList()
} else {
ElMessage.error(data?.msg || '分配失败')
}
})
.catch(() => {
ElMessage.error('分配失败')
})
.finally(() => {
assignStoreLoading.value = false
})
}
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.mod-device-info {
padding: 20px;
}
.device-info-list-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.search-box {
display: flex;
gap: 10px;
}
.device-info-pagination {
margin-top: 20px;
text-align: right;
}
</style>

View File

@@ -0,0 +1,118 @@
import type { DeviceListItem } from '@/utils/device-list'
/** 列表权益状态(与后端枚举对齐;部分状态仅接口返回、无展示配置时回落为 unknown 样式) */
export const STATUS_CONFIG = {
UNBOUND: { label: '未绑定', class: 'unbound' },
PRESET: { label: '预置', class: 'preset' },
ACTIVATING: { label: '激活中', class: 'activating' },
ACTIVATION_FAILED: { label: '激活失败', class: 'activation-failed' },
ACTIVE: { label: '已激活', class: 'active' },
EXPIRED: { label: '过期', class: 'expired' }
} as const
export type DeviceBindStatusKey = keyof typeof STATUS_CONFIG | 'ACTIVATION_BIND'
export const STATUS_KEYS = Object.keys(STATUS_CONFIG) as (keyof typeof STATUS_CONFIG)[]
export const STATUS_ALIASES: Record<string, string> = {
ACTIVATED: 'ACTIVE',
ACTIVATE_FAILED: 'ACTIVATION_FAILED'
}
export const STATUS_NUM_MAP: Record<number, keyof typeof STATUS_CONFIG | 'ACTIVATION_BIND' | 'UNBOUND'> = {
1: 'UNBOUND',
2: 'PRESET',
3: 'ACTIVATING',
4: 'ACTIVATION_FAILED',
5: 'ACTIVE',
6: 'EXPIRED'
}
/** 操作列 fixed 宽度 */
export const DEVICE_TABLE_ACTION_COL_WIDTH = 580
export interface StoreOption {
id: string | number
name: string
}
export interface StoreNameOption {
id: string | number
label: string
}
export type StorePickMode = 'name' | string
export interface DeviceQueryForm {
status: string | null
mac: string
cardNo: string
}
export type DeviceDialogKey =
| 'transfer'
| 'transferStore'
| 'replace'
| 'editRoom'
| 'renew'
| 'detail'
| 'updateMac'
| 'developerMigrate'
export type DeviceLoadingFlagKey =
| 'add'
| 'transferStore'
| 'replace'
| 'editRoom'
| 'renew'
| 'detail'
| 'developerMigrate'
export interface DeviceDialogsState {
transfer: boolean
transferStore: boolean
replace: boolean
editRoom: boolean
renew: boolean
detail: boolean
updateMac: boolean
developerMigrate: boolean
}
export interface DeviceLoadingFlagsState {
add: boolean
transferStore: boolean
replace: boolean
editRoom: boolean
renew: boolean
detail: boolean
developerMigrate: boolean
}
export interface DeviceFormsState {
editRoom: { mac: string; currentRoomNo: string; roomNo: string }
renew: { mac: string; cardType: string }
replace: { cardId: number | null }
transferStore: { mac: string; currentStoreName: string; storeOrgId: string | number | null }
developerMigrate: { storeId: string | number | null; mac: string; expireAt: string }
}
export interface DeviceAddForm {
storeOrgId: string | number | null
mac: string
deviceSn: string
roomNo: string
remark: string
preBind: boolean
preBindCardType: string
}
export interface DeviceDetailItem {
label: string
prop?: string
format: (d: DeviceListItem | null | undefined) => string
}
export type DeviceResetFormKey = keyof Pick<DeviceFormsState, 'editRoom' | 'renew' | 'replace' | 'transferStore' | 'developerMigrate'>
export type DeviceTableRow = DeviceListItem

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
<template>
<div class="jetsen-media-page">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item label="栏目">
<el-select v-model="dataForm.catalogid" placeholder="选择栏目" clearable filterable style="width: 220px" @change="onCatalogChange">
<el-option-group v-for="g in catalogTree" :key="g.id" :label="g.name">
<el-option v-for="c in g.children" :key="c.id" :label="c.name" :value="c.id" />
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="关键字">
<el-input v-model="dataForm.keyword" placeholder="名称/演员等" clearable style="width: 200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getDataList()">查询</el-button>
<el-button @click="resetQuery()">重置</el-button>
<el-button type="success" :loading="exportLoading" @click="exportHandle()">导出Excel</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%">
<el-table-column type="index" header-align="center" align="center" width="60" label="序号" />
<el-table-column label="海报" width="80" align="center">
<template #default="{ row }">
<el-image
v-if="getPosterUrl(row)"
:src="getPosterUrl(row)"
:preview-src-list="[getPosterUrl(row)]"
fit="cover"
style="width: 48px; height: 64px; border-radius: 4px"
lazy
/>
<span v-else style="color: #999">无</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="contentcode" label="内容编码" width="160" show-overflow-tooltip />
<el-table-column label="类型" width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.categoryname || row.firstproject || '-' }}</template>
</el-table-column>
<el-table-column label="评分" width="70" align="center">
<template #default="{ row }">{{ row.score || '-' }}</template>
</el-table-column>
<el-table-column label="演员" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.actordisplay || '-' }}</template>
</el-table-column>
<el-table-column label="年份" width="70" align="center">
<template #default="{ row }">{{ row.releaseyear || '-' }}</template>
</el-table-column>
<el-table-column label="地区" width="80" show-overflow-tooltip>
<template #default="{ row }">{{ row.originalcountry || '-' }}</template>
</el-table-column>
<el-table-column label="免费" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.isFree === 1 ? 'success' : 'warning'" size="small">
{{ row.isFree === 1 ? '免费' : '付费' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="集数" width="60" align="center">
<template #default="{ row }">{{ row.episodecount || '-' }}</template>
</el-table-column>
<el-table-column label="简介" min-width="200" show-overflow-tooltip>
<template #default="{ row }">{{ row.description || '-' }}</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalCount"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
getJetsenMediaCatalogTree,
getJetsenMediaList,
searchJetsenMedia,
exportJetsenMedia,
type JetsenContentItem,
type JetsenCatalogNode
} from '@/api/jetsenMedia'
const dataForm = reactive({
catalogid: '' as string | number,
keyword: ''
})
const catalogTree = ref<JetsenCatalogNode[]>([])
const dataList = ref<JetsenContentItem[]>([])
const pageIndex = ref(1)
const pageSize = ref(20)
const totalCount = ref(0)
const dataListLoading = ref(false)
const exportLoading = ref(false)
/** 获取海报地址 */
function getPosterUrl(row: JetsenContentItem): string {
return row.posterimg || row.midimg || row.image || ''
}
/** 选取栏目树第一个二级栏目 id */
function pickFirstLeaf(): string | number {
for (const g of catalogTree.value) {
if (g.children && g.children.length > 0) {
return g.children[0].id
}
}
return ''
}
/** 根据 id 查栏目名称(用于导出文件名) */
function getCatalogName(id: string | number): string {
for (const g of catalogTree.value) {
const c = (g.children || []).find(c => c.id === id)
if (c) return c.name
}
return String(id)
}
/** 加载栏目树(一级 + 二级) */
async function loadCatalogTree() {
try {
const res = await getJetsenMediaCatalogTree()
const { data } = res
if (data && Number(data.code) === 0) {
catalogTree.value = (data.data && data.data.list) || []
if (!dataForm.catalogid) {
dataForm.catalogid = pickFirstLeaf()
}
}
} catch (e) {
console.error('加载栏目树失败', e)
}
}
function onCatalogChange() {
dataForm.keyword = ''
pageIndex.value = 1
getDataList()
}
function resetQuery() {
dataForm.keyword = ''
dataForm.catalogid = pickFirstLeaf()
pageIndex.value = 1
getDataList()
}
async function getDataList() {
// 关键字搜索优先
if (dataForm.keyword && dataForm.keyword.trim()) {
return searchDataList()
}
if (!dataForm.catalogid) {
ElMessage.warning('请先选择栏目')
return
}
dataListLoading.value = true
try {
const res = await getJetsenMediaList({
catalogid: dataForm.catalogid,
pageindex: pageIndex.value,
pagesize: pageSize.value
})
const { data } = res
if (data == null || Number(data.code) !== 0) {
ElMessage.error((data && data.msg) || '查询失败')
dataList.value = []
totalCount.value = 0
return
}
dataList.value = (data.data && data.data.list) || []
totalCount.value = (data.data && data.data.totalCount) || 0
} catch (e: any) {
ElMessage.error(e.message || '查询异常')
dataList.value = []
totalCount.value = 0
} finally {
dataListLoading.value = false
}
}
async function searchDataList() {
dataListLoading.value = true
try {
const res = await searchJetsenMedia({
keyword: dataForm.keyword.trim(),
pageindex: pageIndex.value,
pagesize: pageSize.value
})
const { data } = res
if (data == null || Number(data.code) !== 0) {
ElMessage.error((data && data.msg) || '搜索失败')
dataList.value = []
totalCount.value = 0
return
}
dataList.value = (data.data && data.data.list) || []
totalCount.value = (data.data && data.data.totalCount) || 0
} catch (e: any) {
ElMessage.error(e.message || '搜索异常')
dataList.value = []
totalCount.value = 0
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle(val: number) {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle(val: number) {
pageIndex.value = val
getDataList()
}
async function exportHandle() {
// 关键字搜索导出优先,否则按栏目导出
if (!dataForm.keyword && !dataForm.catalogid) {
ElMessage.warning('请先选择栏目或输入关键字')
return
}
exportLoading.value = true
try {
const res = await exportJetsenMedia({
catalogid: dataForm.keyword ? undefined : dataForm.catalogid,
keyword: dataForm.keyword && dataForm.keyword.trim() ? dataForm.keyword.trim() : undefined,
catalogName: dataForm.keyword ? undefined : getCatalogName(dataForm.catalogid)
})
const blob = res.data
if (!(blob instanceof Blob)) {
ElMessage.error('导出失败')
return
}
// 后端返回的文件名
const disposition = res.headers['content-disposition'] || ''
let fileName = '橘视厅媒资导出.xlsx'
const match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
if (match && match[1]) {
try {
fileName = decodeURIComponent(match[1])
} catch (_) {
// 保留默认名
}
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e: any) {
// 后端可能返回 JSON 错误(blob 形式)
if (e.response && e.response.data instanceof Blob) {
try {
const text = await e.response.data.text()
const j = JSON.parse(text)
ElMessage.error(j.msg || j.message || '导出失败')
} catch (_) {
ElMessage.error('导出失败')
}
} else {
ElMessage.error(e.message || '导出异常')
}
} finally {
exportLoading.value = false
}
}
onMounted(async () => {
await loadCatalogTree()
getDataList()
})
</script>
<style scoped>
.jetsen-media-page {
padding: 10px;
}
.table-page-wrap {
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,140 @@
<template>
<el-dialog
:title="!dataForm.id ? '新增' : '修改'"
:close-on-click-modal="false"
v-model="visible">
<el-form :model="dataForm" :rules="dataRule" ref="dataFormRef" @keyup.enter="dataFormSubmit()" label-width="100px">
<el-form-item label="bean名称" prop="beanName">
<el-input v-model="dataForm.beanName" placeholder="spring bean名称, 如: testTask"></el-input>
</el-form-item>
<el-form-item label="参数" prop="params">
<el-input v-model="dataForm.params" placeholder="参数"></el-input>
</el-form-item>
<el-form-item label="cron表达式" prop="cronExpression">
<el-input v-model="dataForm.cronExpression" placeholder="如: 0 0 12 * * ?"></el-input>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="dataForm.remark" placeholder="备注"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmit()">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick } from 'vue'
import { getScheduleInfo, saveSchedule, type ScheduleInfo } from '@/api/sys'
import type { ApiResponse } from '@/types/api'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
const emit = defineEmits<{
refreshDataList: []
}>()
const visible = ref(false)
const dataFormRef = ref<FormInstance | null>(null)
interface ScheduleFormModel {
id: number
beanName: string
params: string
cronExpression: string
remark: string
status: number
}
const dataForm = reactive<ScheduleFormModel>({
id: 0,
beanName: '',
params: '',
cronExpression: '',
remark: '',
status: 0,
})
const dataRule: FormRules = {
beanName: [{ required: true, message: 'bean名称不能为空', trigger: 'blur' }],
cronExpression: [{ required: true, message: 'cron表达式不能为空', trigger: 'blur' }],
}
interface LooseScheduleInfoResponse extends ApiResponse<unknown> {
schedule?: Record<string, unknown>
job?: Record<string, unknown>
}
type ScheduleEntity = Partial<ScheduleInfo> & Record<string, unknown>
/**
* /sys/schedule/info/:id 兼容:顶层 schedule / job,
* 或 data.schedule / data.job / data 本体为实体
*/
function pickScheduleEntity(
res: LooseScheduleInfoResponse | null | undefined,
): ScheduleEntity | null {
if (!res || res.code !== 0) return null
const nested =
res.data != null && typeof res.data === 'object' && !Array.isArray(res.data)
? (res.data as Record<string, unknown>)
: null
if (nested != null) {
const boxed = nested.schedule ?? nested.job
if (boxed != null && typeof boxed === 'object') return boxed as ScheduleEntity
return nested as ScheduleEntity
}
const top = res.schedule ?? res.job
return top != null && typeof top === 'object' ? (top as ScheduleEntity) : null
}
function init(id?: number) {
dataForm.id = id ?? 0
visible.value = true
nextTick(() => {
dataFormRef.value?.resetFields()
if (dataForm.id) {
getScheduleInfo(dataForm.id).then((res) => {
const s = pickScheduleEntity(res as LooseScheduleInfoResponse)
if (!s) return
dataForm.beanName = s.beanName ?? ''
dataForm.params = (s.params as string | undefined) ?? ''
dataForm.cronExpression = s.cronExpression ?? ''
dataForm.remark = s.remark ?? ''
dataForm.status = s.status != null ? s.status : 0
})
}
})
}
function dataFormSubmit() {
dataFormRef.value?.validate((valid) => {
if (!valid) return
saveSchedule({
jobId: dataForm.id || undefined,
beanName: dataForm.beanName,
params: dataForm.params,
cronExpression: dataForm.cronExpression,
remark: dataForm.remark,
status: !dataForm.id ? undefined : dataForm.status,
}).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
visible.value = false
emit('refreshDataList')
},
})
} else {
ElMessage.error(res?.msg || '操作失败')
}
})
})
}
defineExpose({ init })
</script>

View File

@@ -0,0 +1,196 @@
<template>
<el-dialog
title="日志列表"
:close-on-click-modal="false"
v-model="visible"
width="75%">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.id" placeholder="任务ID" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
height="460"
style="width: 100%;">
<el-table-column
prop="logId"
header-align="center"
align="center"
width="80"
label="日志ID">
</el-table-column>
<el-table-column
prop="jobId"
header-align="center"
align="center"
width="80"
label="任务ID">
</el-table-column>
<el-table-column
prop="beanName"
header-align="center"
align="center"
label="bean名称">
</el-table-column>
<el-table-column
prop="params"
header-align="center"
align="center"
label="参数">
</el-table-column>
<el-table-column
prop="status"
header-align="center"
align="center"
label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 0" size="small">成功</el-tag>
<el-tag v-else @click="showErrorInfo(scope.row.logId)" size="small" type="danger" style="cursor: pointer;">失败</el-tag>
</template>
</el-table-column>
<el-table-column
prop="times"
header-align="center"
align="center"
label="耗时(单位: 毫秒)">
</el-table-column>
<el-table-column
prop="createTime"
header-align="center"
align="center"
width="180"
label="执行时间">
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { getScheduleLogList, getScheduleLogInfo, type ScheduleLogInfo } from '@/api/sys'
import type { ApiResponse } from '@/types/api'
import { ElMessage, ElMessageBox } from 'element-plus'
type ScheduleLogPage = { list: ScheduleLogInfo[]; totalCount?: number }
interface LooseScheduleLogListResponse extends ApiResponse<unknown> {
page?: ScheduleLogPage
list?: ScheduleLogInfo[]
totalCount?: number
}
interface LooseLogDetailResponse extends ApiResponse<unknown> {
log?: Record<string, unknown>
scheduleLog?: Record<string, unknown>
}
const visible = ref(false)
const dataForm = reactive({
id: '',
})
const dataList = ref<ScheduleLogInfo[]>([])
const pageIndex = ref(1)
const pageSize = ref(10)
const totalPage = ref(0)
const dataListLoading = ref(false)
/** /schedule/log/list 分页解析 */
function normalizeScheduleLogPage(
res: LooseScheduleLogListResponse | null | undefined,
): ScheduleLogPage | null {
if (!res || res.code !== 0) return null
const env =
res.data != null && typeof res.data === 'object'
? (res.data as { page?: ScheduleLogPage; list?: ScheduleLogInfo[] })
: null
if (res.page && Array.isArray(res.page.list)) return res.page
if (env?.page && Array.isArray(env.page.list)) return env.page
if (env && Array.isArray(env.list)) return env as ScheduleLogPage
if (Array.isArray(res.list)) return { list: res.list, totalCount: res.totalCount }
const flat = env ?? res
if (flat && Array.isArray(flat.list)) return flat as ScheduleLogPage
return null
}
/** /schedule/log/info 详情:顶层 log / data.log */
function pickScheduleLogDetail(
res: LooseLogDetailResponse | null | undefined,
): Record<string, unknown> | null {
if (!res || res.code !== 0) return null
const nested =
res.data != null && typeof res.data === 'object' ? (res.data as Record<string, unknown>) : null
if (nested != null) {
const boxed = nested.log ?? nested.scheduleLog
return boxed != null ? (boxed as Record<string, unknown>) : nested
}
const top = res.log ?? res.scheduleLog
return top != null && typeof top === 'object' ? (top as Record<string, unknown>) : null
}
function getDataList() {
dataListLoading.value = true
getScheduleLogList({
page: pageIndex.value,
limit: pageSize.value,
jobId: dataForm.id,
}).then((res) => {
const page = normalizeScheduleLogPage(res as LooseScheduleLogListResponse)
if (page && Array.isArray(page.list)) {
dataList.value = page.list
totalPage.value = page.totalCount ?? 0
} else {
dataList.value = []
totalPage.value = 0
}
dataListLoading.value = false
})
}
function init() {
visible.value = true
getDataList()
}
function sizeChangeHandle(val: number) {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle(val: number) {
pageIndex.value = val
getDataList()
}
function showErrorInfo(logId: number) {
getScheduleLogInfo(logId).then((res) => {
const log = pickScheduleLogDetail(res as LooseLogDetailResponse)
if (log && typeof log === 'object') {
const text =
log.error != null ? String(log.error) : log.msg != null ? String(log.msg) : ''
ElMessageBox.alert(text || '无详情')
} else {
ElMessage.error(res?.msg || '请求失败')
}
})
}
defineExpose({ init })
</script>

View File

@@ -0,0 +1,336 @@
<template>
<div class="mod-schedule">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.beanName" placeholder="bean名称" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
<el-button type="primary" @click="addOrUpdateHandle()">新增</el-button>
<el-button type="success" @click="logHandle()">日志列表</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
@selection-change="selectionChangeHandle"
style="width: 100%;">
<el-table-column
type="selection"
header-align="center"
align="center"
width="50">
</el-table-column>
<el-table-column
prop="jobId"
header-align="center"
align="center"
width="80"
label="ID">
</el-table-column>
<el-table-column
prop="beanName"
header-align="center"
align="center"
label="bean名称">
</el-table-column>
<el-table-column
prop="params"
header-align="center"
align="center"
label="参数">
</el-table-column>
<el-table-column
prop="cronExpression"
header-align="center"
align="center"
label="cron表达式">
</el-table-column>
<el-table-column
prop="remark"
header-align="center"
align="center"
label="备注">
</el-table-column>
<el-table-column
prop="status"
header-align="center"
align="center"
label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 0" size="small">正常</el-tag>
<el-tag v-else size="small" type="danger">暂停</el-tag>
</template>
</el-table-column>
<el-table-column
fixed="right"
header-align="center"
align="center"
width="150"
label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="addOrUpdateHandle(scope.row.jobId)">修改</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.jobId)">删除</el-button>
<el-button link size="small" @click="pauseHandle(scope.row.jobId)">暂停</el-button>
<el-button link size="small" @click="resumeHandle(scope.row.jobId)">恢复</el-button>
<el-button link size="small" @click="runHandle(scope.row.jobId)">立即执行</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update v-if="addOrUpdateVisible" ref="addOrUpdateRef" @refreshDataList="getDataList"></add-or-update>
<!-- 弹窗, 日志列表 -->
<log v-if="logVisible" ref="logRef"></log>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted } from 'vue'
import AddOrUpdate from './components/schedule-add-or-update.vue'
import Log from './components/schedule-log.vue'
import {
getScheduleList,
deleteSchedule,
pauseSchedule,
resumeSchedule,
runSchedule,
type ScheduleInfo,
} from '@/api/sys'
import type { ApiResponse } from '@/types/api'
import { ElMessage, ElMessageBox } from 'element-plus'
type ScheduleListPage = { list: ScheduleInfo[]; totalCount?: number }
interface LooseScheduleListResponse extends ApiResponse<unknown> {
page?: ScheduleListPage
list?: ScheduleInfo[]
totalCount?: number
}
const dataForm = reactive({
beanName: '',
})
const dataList = ref<ScheduleInfo[]>([])
const pageIndex = ref(1)
const pageSize = ref(10)
const totalPage = ref(0)
const dataListLoading = ref(false)
const dataListSelections = ref<ScheduleInfo[]>([])
const addOrUpdateVisible = ref(false)
const logVisible = ref(false)
const addOrUpdateRef = ref<{ init: (id?: number) => void } | null>(null)
const logRef = ref<{ init: () => void } | null>(null)
/** /sys/schedule/list 分页:顶层 page/list 或 data.page / data.list */
function normalizeScheduleListPage(
res: LooseScheduleListResponse | null | undefined,
): ScheduleListPage | null {
if (!res || res.code !== 0) return null
const env =
res.data != null && typeof res.data === 'object'
? (res.data as { page?: ScheduleListPage; list?: ScheduleInfo[] })
: null
if (res.page && Array.isArray(res.page.list)) return res.page
if (env?.page && Array.isArray(env.page.list)) return env.page
if (env && Array.isArray(env.list)) return env as ScheduleListPage
if (Array.isArray(res.list)) return { list: res.list, totalCount: res.totalCount }
const flat = env ?? res
if (flat && Array.isArray(flat.list)) return flat as ScheduleListPage
return null
}
function getDataList() {
dataListLoading.value = true
getScheduleList({
page: pageIndex.value,
limit: pageSize.value,
beanName: dataForm.beanName,
}).then((res) => {
const page = normalizeScheduleListPage(res as LooseScheduleListResponse)
if (page && Array.isArray(page.list)) {
dataList.value = page.list
totalPage.value = page.totalCount ?? 0
} else {
dataList.value = []
totalPage.value = 0
}
dataListLoading.value = false
})
}
function sizeChangeHandle(val: number) {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle(val: number) {
pageIndex.value = val
getDataList()
}
function selectionChangeHandle(val: ScheduleInfo[]) {
dataListSelections.value = val
}
function addOrUpdateHandle(id?: number) {
addOrUpdateVisible.value = true
nextTick(() => {
addOrUpdateRef.value?.init(id)
})
}
function resolveJobIds(id?: number): number[] {
if (id != null) return [id]
return dataListSelections.value
.map((item) => item.jobId)
.filter((jid): jid is number => jid != null)
}
function deleteHandle(id?: number) {
const ids = resolveJobIds(id)
ElMessageBox.confirm(
`确定对[id=${ids.join(',')}]进行[${id != null ? '删除' : '批量删除'}]操作?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
},
)
.then(() => {
deleteSchedule(ids).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
},
})
} else {
ElMessage.error(res?.msg || '操作失败')
}
})
})
.catch(() => {})
}
function pauseHandle(id?: number) {
const ids = resolveJobIds(id)
ElMessageBox.confirm(
`确定对[id=${ids.join(',')}]进行[${id != null ? '暂停' : '批量暂停'}]操作?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
},
)
.then(() => {
const promises = ids.map((jobId) => pauseSchedule(jobId))
Promise.all(promises).then((responses) => {
const res = responses[0]
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
},
})
} else {
ElMessage.error(res?.msg || '操作失败')
}
})
})
.catch(() => {})
}
function resumeHandle(id?: number) {
const ids = resolveJobIds(id)
ElMessageBox.confirm(
`确定对[id=${ids.join(',')}]进行[${id != null ? '恢复' : '批量恢复'}]操作?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
},
)
.then(() => {
const promises = ids.map((jobId) => resumeSchedule(jobId))
Promise.all(promises).then((responses) => {
const res = responses[0]
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
},
})
} else {
ElMessage.error(res?.msg || '操作失败')
}
})
})
.catch(() => {})
}
function runHandle(id?: number) {
const ids = resolveJobIds(id)
ElMessageBox.confirm(
`确定对[id=${ids.join(',')}]进行[${id != null ? '立即执行' : '批量立即执行'}]操作?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
},
)
.then(() => {
const promises = ids.map((jobId) => runSchedule(jobId))
Promise.all(promises).then((responses) => {
const res = responses[0]
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
},
})
} else {
ElMessage.error(res?.msg || '操作失败')
}
})
})
.catch(() => {})
}
function logHandle() {
logVisible.value = true
nextTick(() => {
logRef.value?.init()
})
}
onMounted(() => {
getDataList()
})
</script>

View File

@@ -0,0 +1,215 @@
<template>
<div class="mod-activation-code">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<org-tree-select v-model="dataForm.storeOrgId" placeholder="请选择门店" :hide-node-types="['PLATFORM', 'CHANNEL', 'SERVICE_PROVIDER']" width="200px" />
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="generateHandle()">生成激活码</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%;">
<el-table-column type="index" header-align="center" align="center" width="60" label="序号"></el-table-column>
<el-table-column prop="code" header-align="center" align="center" min-width="120" label="激活码">
<template #default="scope">
<span style="font-weight: bold; color: #409eff;">{{ scope.row.code }}</span>
</template>
</el-table-column>
<el-table-column prop="storeOrgId" header-align="center" align="center" label="门店"></el-table-column>
<el-table-column prop="channelOrgId" header-align="center" align="center" label="渠道"></el-table-column>
<el-table-column prop="status" header-align="center" align="center" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else-if="scope.row.status === 'DISABLED'" size="small" type="danger">禁用</el-tag>
<el-tag v-else size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="maxDeviceCount" header-align="center" align="center" label="最大设备数">
<template #default="scope">{{ scope.row.maxDeviceCount === 0 ? '不限' : scope.row.maxDeviceCount }}</template>
</el-table-column>
<el-table-column prop="activatedCount" header-align="center" align="center" label="已激活数"></el-table-column>
<el-table-column prop="remark" header-align="center" align="center" label="备注" show-overflow-tooltip></el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="170" label="创建时间"></el-table-column>
<el-table-column fixed="right" header-align="center" align="center" width="220" label="操作">
<template #default="scope">
<el-button link type="warning" size="small" @click="regenerateHandle(scope.row)">重新生成</el-button>
<el-button v-if="scope.row.status === 'ACTIVE'" link type="danger" size="small" @click="toggleStatus(scope.row, 'DISABLED')">禁用</el-button>
<el-button v-else link type="success" size="small" @click="toggleStatus(scope.row, 'ACTIVE')">启用</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!dataList.length && !dataListLoading" style="text-align: center; color: #909399; padding: 20px 0;">
请输入门店组织ID查询该门店的激活码
</div>
</div>
<!-- 生成激活码弹窗 -->
<el-dialog v-model="generateVisible" title="生成激活码" width="480px">
<el-form :model="generateForm" :rules="generateRules" ref="generateFormRef" label-width="120px">
<el-form-item label="门店" prop="storeOrgId">
<org-tree-select v-model="generateForm.storeOrgId" placeholder="请选择门店" :hide-node-types="['PLATFORM', 'CHANNEL', 'SERVICE_PROVIDER']" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="generateForm.remark" placeholder="如:前台设备"></el-input>
</el-form-item>
<div style="font-size: 12px; color: #999; margin-left: 120px;">
每门店一个激活码,已存在则返回已有的
</div>
</el-form>
<template #footer>
<el-button @click="generateVisible = false">取消</el-button>
<el-button type="primary" :loading="generateLoading" @click="submitGenerate">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
getActivationCodeList,
generateActivationCode,
regenerateActivationCode,
updateActivationCodeStatus,
deleteActivationCode,
type ActivationCodeItem
} from '@/api/manage/activationCode'
const dataForm = reactive<{ storeOrgId: number | null }>({
storeOrgId: null
})
const dataList = ref<ActivationCodeItem[]>([])
const dataListLoading = ref<boolean>(false)
// 生成弹窗
const generateVisible = ref<boolean>(false)
const generateLoading = ref<boolean>(false)
const generateFormRef = ref<FormInstance>()
interface GenerateForm {
storeOrgId: number | null
remark: string
}
const generateForm = reactive<GenerateForm>({
storeOrgId: null,
remark: ''
})
const generateRules: FormRules = {
storeOrgId: [{ required: true, message: '请选择门店', trigger: 'change' }]
}
function getDataList (): void {
if (!dataForm.storeOrgId) {
ElMessage.warning('请选择门店')
return
}
dataListLoading.value = true
getActivationCodeList(dataForm.storeOrgId as number).then((res) => {
if (res && res.code === 0) {
dataList.value = res.data?.list || []
} else {
dataList.value = []
ElMessage.error(res?.msg || '查询失败')
}
dataListLoading.value = false
}).catch(() => {
dataListLoading.value = false
})
}
function generateHandle (): void {
generateForm.storeOrgId = dataForm.storeOrgId
generateForm.remark = ''
generateFormRef.value?.clearValidate()
generateVisible.value = true
}
function submitGenerate (): void {
generateFormRef.value?.validate((valid) => {
if (!valid) return
generateLoading.value = true
generateActivationCode({
storeOrgId: generateForm.storeOrgId ?? 0,
remark: generateForm.remark || undefined
}).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '生成成功',
duration: 1500,
onClose: () => {
generateVisible.value = false
dataForm.storeOrgId = generateForm.storeOrgId
getDataList()
}
})
} else {
ElMessage.error(res?.msg || '生成失败')
}
generateLoading.value = false
}).catch(() => {
generateLoading.value = false
})
})
}
function regenerateHandle (row: ActivationCodeItem): void {
ElMessageBox.confirm(`确定重新生成激活码?旧码[${row.code}]将失效。`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
regenerateActivationCode(row.id).then((res) => {
if (res && res.code === 0) {
const newCode = res.data?.code
ElMessage.success(`已重新生成${newCode ? ':' + newCode : ''}`)
getDataList()
} else {
ElMessage.error(res?.msg || '重新生成失败')
}
})
}).catch(() => {})
}
function toggleStatus (row: ActivationCodeItem, status: 'ACTIVE' | 'DISABLED'): void {
updateActivationCodeStatus(row.id, status).then((res) => {
if (res && res.code === 0) {
ElMessage.success('操作成功')
getDataList()
} else {
ElMessage.error(res?.msg || '操作失败')
}
})
}
function deleteHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteActivationCode(id).then((res) => {
if (res && res.code === 0) {
ElMessage.success('操作成功')
getDataList()
} else {
ElMessage.error(res?.msg || '删除失败')
}
})
}).catch(() => {})
}
</script>
<style scoped>
.mod-activation-code {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,242 @@
<template>
<div class="mod-card-order">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.orderNo" placeholder="订单号" clearable></el-input>
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="订单状态" clearable>
<el-option label="已创建" value="CREATED"></el-option>
<el-option label="支付中" value="PAYING"></el-option>
<el-option label="已支付" value="PAID"></el-option>
<el-option label="支付失败" value="PAY_FAILED"></el-option>
<el-option label="已取消" value="CANCELLED"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<org-tree-select v-model="dataForm.partnerId" placeholder="合作商" width="160px" />
</el-form-item>
<el-form-item>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable>
</el-date-picker>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%;">
<el-table-column type="index" header-align="center" align="center" width="60" label="序号" />
<el-table-column prop="orderNo" header-align="center" align="center" min-width="180" label="订单号" show-overflow-tooltip />
<el-table-column prop="partnerId" header-align="center" align="center" width="100" label="合作商" />
<el-table-column prop="deviceId" header-align="center" align="center" width="90" label="设备" />
<el-table-column prop="deviceMac" header-align="center" align="center" width="140" label="设备MAC" show-overflow-tooltip />
<el-table-column prop="storeOrgId" header-align="center" align="center" width="100" label="门店" />
<el-table-column prop="channelOrgId" header-align="center" align="center" width="100" label="渠道" />
<el-table-column prop="cardNo" header-align="center" align="center" min-width="160" label="卡密(激活码)" show-overflow-tooltip />
<el-table-column prop="status" header-align="center" align="center" width="100" label="订单状态">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)" size="small">{{ getStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="amount" header-align="center" align="center" width="100" label="订单金额">
<template #default="{ row }">{{ formatAmount(row.amount) }}</template>
</el-table-column>
<el-table-column prop="payAmount" header-align="center" align="center" width="100" label="支付金额">
<template #default="{ row }">{{ formatAmount(row.payAmount) }}</template>
</el-table-column>
<el-table-column prop="payTime" header-align="center" align="center" width="170" label="支付时间">
<template #default="{ row }">{{ row.payTime || '-' }}</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="170" label="创建时间" />
<el-table-column fixed="right" header-align="center" align="center" width="80" label="操作">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="detailHandle(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="订单详情" width="720px">
<el-descriptions :column="2" border v-loading="detailLoading">
<el-descriptions-item label="订单号">{{ detailData?.orderNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="合作商">{{ detailData?.partnerId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="设备">{{ detailData?.deviceId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="设备MAC">{{ detailData?.deviceMac || '-' }}</el-descriptions-item>
<el-descriptions-item label="门店">{{ detailData?.storeOrgId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="渠道">{{ detailData?.channelOrgId ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="卡密(激活码)">{{ detailData?.cardNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag v-if="detailData?.status" :type="getStatusTagType(detailData.status)" size="small">
{{ getStatusLabel(detailData.status) }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="订单金额">{{ formatAmount(detailData?.amount) }}</el-descriptions-item>
<el-descriptions-item label="支付金额">{{ formatAmount(detailData?.payAmount) }}</el-descriptions-item>
<el-descriptions-item label="支付单号">{{ detailData?.payNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="支付时间">{{ detailData?.payTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="过期时间">{{ detailData?.expireTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="取消时间">{{ detailData?.cancelTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detailData?.createdAt || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ detailData?.updatedAt || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getDayCardOrderList, getDayCardOrderInfo, type DayCardOrderItem } from '@/api/manage/daycard'
interface DataForm {
orderNo: string
status: string
partnerId: string
}
const dataForm = reactive<DataForm>({
orderNo: '',
status: '',
partnerId: ''
})
const dateRange = ref<[string, string] | []>([])
const dataList = ref<DayCardOrderItem[]>([])
const pageIndex = ref(1)
const pageSize = ref(10)
const totalPage = ref(0)
const dataListLoading = ref(false)
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<DayCardOrderItem | null>(null)
/** 订单状态 -> el-tag type 映射 */
function getStatusTagType (status: string): 'info' | 'warning' | 'success' | 'danger' {
switch (status) {
case 'CREATED': return 'info'
case 'PAYING': return 'warning'
case 'PAID': return 'success'
case 'PAY_FAILED': return 'danger'
case 'CANCELLED': return 'info'
default: return 'info'
}
}
/** 订单状态 -> 中文文案映射 */
function getStatusLabel (status: string): string {
switch (status) {
case 'CREATED': return '已创建'
case 'PAYING': return '支付中'
case 'PAID': return '已支付'
case 'PAY_FAILED': return '支付失败'
case 'CANCELLED': return '已取消'
default: return status
}
}
/** 金额格式化(amount 单位为元,BigDecimal) */
function formatAmount (amount: number | null | undefined): string {
if (amount == null) return '-'
return '¥' + Number(amount).toFixed(2)
}
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
const params: Record<string, unknown> = {
page: pageIndex.value,
limit: pageSize.value
}
if (dataForm.orderNo) params.orderNo = dataForm.orderNo
if (dataForm.status) params.status = dataForm.status
if (dataForm.partnerId) params.partnerId = dataForm.partnerId
if (dateRange.value && dateRange.value.length === 2) {
params.startDate = dateRange.value[0]
params.endDate = dateRange.value[1]
}
const res = await getDayCardOrderList(params as any)
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
if (res?.msg) ElMessage.error(res.msg)
}
} catch (e: any) {
dataList.value = []
totalPage.value = 0
ElMessage.error(e?.message || '请求异常')
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
/** 查看订单详情 */
async function detailHandle (row: DayCardOrderItem): Promise<void> {
detailVisible.value = true
detailLoading.value = true
detailData.value = null
try {
const res = await getDayCardOrderInfo(row.orderNo)
if (res && res.code === 0) {
const orderData = (res as any).data?.order || null
detailData.value = orderData || row
} else {
detailData.value = row
if (res?.msg) ElMessage.error(res.msg)
}
} catch (e: any) {
detailData.value = row
ElMessage.error(e?.message || '获取详情失败')
} finally {
detailLoading.value = false
}
}
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.mod-card-order {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,312 @@
<template>
<div class="mod-card-pool">
<!-- 卡池统计 -->
<el-row :gutter="16" class="stats-row">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">总卡数</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value" style="color: #67c23a;">{{ stats.available }}</div>
<div class="stat-label">可用(UNUSED)</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value" style="color: #e6a23c;">{{ stats.reserved }}</div>
<div class="stat-label">已预留(RESERVED)</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value" style="color: #409eff;">{{ stats.used }}</div>
<div class="stat-label">已使用(USED)</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 搜索 + 操作 -->
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.cardCode" placeholder="卡密/激活码" clearable></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.orderNo" placeholder="关联订单号" clearable></el-input>
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="卡状态" clearable>
<el-option label="未使用" value="UNUSED"></el-option>
<el-option label="已预留" value="RESERVED"></el-option>
<el-option label="已使用" value="USED"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<org-tree-select v-model="dataForm.partnerId" placeholder="合作商" width="160px" />
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="importVisible = true">导入卡密</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%;">
<el-table-column type="index" header-align="center" align="center" width="60" label="序号" />
<el-table-column prop="cardCode" header-align="center" align="center" min-width="160" label="卡密(激活码)" show-overflow-tooltip />
<el-table-column prop="thirdCardNo" header-align="center" align="center" min-width="180" label="第三方卡号" show-overflow-tooltip />
<el-table-column prop="thirdPartyRef" header-align="center" align="center" min-width="180" label="第三方引用(UID)" show-overflow-tooltip />
<el-table-column prop="partnerId" header-align="center" align="center" width="100" label="合作商" />
<el-table-column prop="status" header-align="center" align="center" width="100" label="卡状态">
<template #default="{ row }">
<el-tag v-if="row.status === 'UNUSED'" size="small" type="success">未使用</el-tag>
<el-tag v-else-if="row.status === 'RESERVED'" size="small" type="warning">已预留</el-tag>
<el-tag v-else-if="row.status === 'USED'" size="small" type="primary">已使用</el-tag>
<el-tag v-else size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="activateStatus" header-align="center" align="center" width="100" label="激活状态">
<template #default="{ row }">
<el-tag v-if="row.activateStatus === 'PENDING'" size="small" type="info">待激活</el-tag>
<el-tag v-else-if="row.activateStatus === 'SUCCESS'" size="small" type="success">已激活</el-tag>
<el-tag v-else-if="row.activateStatus === 'FAILED'" size="small" type="danger">激活失败</el-tag>
<el-tag v-else size="small">{{ row.activateStatus || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="deviceId" header-align="center" align="center" width="90" label="设备" />
<el-table-column prop="orderNo" header-align="center" align="center" min-width="160" label="关联订单号" show-overflow-tooltip />
<el-table-column prop="expireAt" header-align="center" align="center" width="170" label="到期时间">
<template #default="{ row }">{{ row.expireAt || '-' }}</template>
</el-table-column>
<el-table-column prop="activatedAt" header-align="center" align="center" width="170" label="激活时间">
<template #default="{ row }">{{ row.activatedAt || '-' }}</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="170" label="创建时间" />
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 导入卡密弹窗 -->
<el-dialog v-model="importVisible" title="导入天卡卡密" width="600px">
<el-form :model="importForm" :rules="importRules" ref="importFormRef" label-width="100px">
<el-form-item label="合作商" prop="partnerId">
<org-tree-select v-model="importForm.partnerId" placeholder="请选择合作商" />
</el-form-item>
<el-form-item label="卡密列表" prop="cardCodesText">
<el-input
v-model="importForm.cardCodesText"
type="textarea"
:rows="10"
placeholder="每行一个卡密/激活码,例如:&#10;XXXX-XXXX-XXXX&#10;YYYY-YYYY-YYYY" />
<div style="font-size: 12px; color: #999; margin-top: 4px;">
每行一个橘视厅激活码,系统会自动生成第三方卡号和引用标识
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importLoading" @click="submitImport">开始导入</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import {
getDayCardPoolList,
getDayCardPoolStats,
importDayCardPool,
type DayCardPoolItem,
type DayCardPoolStats
} from '@/api/manage/daycard'
interface DataForm {
cardCode: string
orderNo: string
status: string
partnerId: string
}
const dataForm = reactive<DataForm>({
cardCode: '',
orderNo: '',
status: '',
partnerId: ''
})
const dataList = ref<DayCardPoolItem[]>([])
const pageIndex = ref(1)
const pageSize = ref(10)
const totalPage = ref(0)
const dataListLoading = ref(false)
const stats = reactive<DayCardPoolStats>({
total: 0,
available: 0,
reserved: 0,
used: 0
})
// 导入弹窗
const importVisible = ref(false)
const importLoading = ref(false)
const importFormRef = ref<FormInstance>()
interface ImportForm {
partnerId: number | null
cardCodesText: string
}
const importForm = reactive<ImportForm>({
partnerId: 1,
cardCodesText: ''
})
const importRules: FormRules = {
partnerId: [{ required: true, message: '请输入合作商ID', trigger: 'blur' }],
cardCodesText: [{ required: true, message: '请输入卡密', trigger: 'blur' }]
}
async function loadStats (): Promise<void> {
try {
const res = await getDayCardPoolStats()
if (res && res.code === 0 && res.data?.stats) {
stats.total = res.data.stats.total || 0
stats.available = res.data.stats.available || 0
stats.reserved = res.data.stats.reserved || 0
stats.used = res.data.stats.used || 0
}
} catch {
// 静默
}
}
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
const params: Record<string, unknown> = {
page: pageIndex.value,
limit: pageSize.value
}
if (dataForm.cardCode) params.cardCode = dataForm.cardCode
if (dataForm.orderNo) params.orderNo = dataForm.orderNo
if (dataForm.status) params.status = dataForm.status
if (dataForm.partnerId) params.partnerId = dataForm.partnerId
const res = await getDayCardPoolList(params as any)
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
if (res?.msg) ElMessage.error(res.msg)
}
} catch (e: any) {
dataList.value = []
totalPage.value = 0
ElMessage.error(e?.message || '请求异常')
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
async function submitImport (): Promise<void> {
if (!importFormRef.value) return
await importFormRef.value.validate(async (valid) => {
if (!valid) return
const cardCodes = importForm.cardCodesText
.split('\n')
.map(s => s.trim())
.filter(s => s.length > 0)
if (cardCodes.length === 0) {
ElMessage.warning('请输入至少一个卡密')
return
}
importLoading.value = true
try {
const res = await importDayCardPool({
partnerId: importForm.partnerId ?? 0,
cardCodes
})
if (res && res.code === 0) {
const successCount = (res as any).data?.successCount ?? 0
const totalCount = (res as any).data?.totalCount ?? cardCodes.length
ElMessage.success(`导入完成:成功 ${successCount} / 总计 ${totalCount}`)
importVisible.value = false
importForm.cardCodesText = ''
getDataList()
loadStats()
} else {
ElMessage.error(res?.msg || '导入失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '导入异常')
} finally {
importLoading.value = false
}
})
}
onMounted(() => {
getDataList()
loadStats()
})
</script>
<style scoped>
.mod-card-pool {
padding: 10px;
}
.stats-row {
margin-bottom: 16px;
}
.stat-card {
text-align: center;
padding: 10px 0;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #303133;
}
.stat-label {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<div class="mod-channel">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.name" placeholder="渠道名称" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="addHandle()">新增</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
style="width: 100%;">
<el-table-column
type="index"
header-align="center"
align="center"
width="80"
label="序号">
</el-table-column>
<el-table-column
prop="name"
header-align="center"
align="center"
label="渠道名称">
</el-table-column>
<el-table-column
prop="orgCode"
header-align="center"
align="center"
label="编码">
</el-table-column>
<el-table-column
prop="activationPrefix"
header-align="center"
align="center"
label="激活码前缀">
</el-table-column>
<el-table-column
prop="canHoldInventory"
header-align="center"
align="center"
label="可持有库存">
<template #default="scope">
<el-tag v-if="scope.row.canHoldInventory" size="small" type="success">是</el-tag>
<el-tag v-else size="small" type="info">否</el-tag>
</template>
</el-table-column>
<el-table-column
prop="developmentStaff"
header-align="center"
align="center"
label="开发人员">
</el-table-column>
<el-table-column
prop="contactPhone"
header-align="center"
align="center"
label="联系电话">
</el-table-column>
<el-table-column
prop="status"
header-align="center"
align="center"
label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else-if="scope.row.status === 'INACTIVE'" size="small" type="danger">禁用</el-tag>
<el-tag v-else size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
header-align="center"
align="center"
width="180"
label="创建时间">
<template #default="scope">
{{ scope.row.createdAt || '' }}
</template>
</el-table-column>
<el-table-column
fixed="right"
header-align="center"
align="center"
width="150"
label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="addOrUpdateHandle(scope.row)">修改</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 弹窗, 新增 / 修改 -->
<el-dialog v-model="addOrUpdateVisible" :title="addOrUpdateTitle" width="560px">
<el-form :model="addOrUpdateForm" :rules="addOrUpdateRules" ref="addOrUpdateFormRef" label-width="120px">
<el-form-item label="上级组织" prop="parentId">
<org-tree-select v-model="addOrUpdateForm.parentId" placeholder="请选择上级组织" :hide-node-types="['STORE']" />
</el-form-item>
<el-form-item label="渠道名称" prop="name">
<el-input v-model="addOrUpdateForm.name" placeholder="渠道名称"></el-input>
</el-form-item>
<el-form-item label="编码" prop="orgCode">
<el-input v-model="addOrUpdateForm.orgCode" placeholder="编码"></el-input>
</el-form-item>
<el-form-item label="激活码前缀" prop="activationPrefix">
<el-input v-model="addOrUpdateForm.activationPrefix" placeholder="激活码前缀"></el-input>
</el-form-item>
<el-form-item label="可持有库存" prop="canHoldInventory">
<el-switch v-model="addOrUpdateForm.canHoldInventory"></el-switch>
</el-form-item>
<el-form-item label="开发人员" prop="developmentStaff">
<el-input v-model="addOrUpdateForm.developmentStaff" placeholder="开发人员"></el-input>
</el-form-item>
<el-form-item label="联系电话" prop="contactPhone">
<el-input v-model="addOrUpdateForm.contactPhone" placeholder="联系电话"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addOrUpdateVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm()">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getChannelList, saveOrUpdateChannel, deleteChannel, type ChannelInfo } from '@/api/manage/channel'
interface DataForm {
name: string
}
const dataForm = reactive<DataForm>({
name: ''
})
const dataList = ref<ChannelInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
const addOrUpdateVisible = ref<boolean>(false)
const addOrUpdateTitle = ref<string>('')
const addOrUpdateFormRef = ref<FormInstance>()
interface AddOrUpdateForm {
id?: number
parentId: number | null
name: string
orgCode: string
activationPrefix: string
canHoldInventory: boolean
developmentStaff: string
contactPhone: string
}
const addOrUpdateForm = reactive<AddOrUpdateForm>({
parentId: null,
name: '',
orgCode: '',
activationPrefix: '',
canHoldInventory: false,
developmentStaff: '',
contactPhone: ''
})
const addOrUpdateRules: FormRules = {
name: [{ required: true, message: '请输入渠道名称', trigger: 'blur' }],
parentId: [{ required: true, message: '请选择上级组织', trigger: 'change' }]
}
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
const res = await getChannelList({
page: pageIndex.value,
limit: pageSize.value,
name: dataForm.name
})
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function resetForm (): void {
addOrUpdateForm.id = undefined
addOrUpdateForm.parentId = null
addOrUpdateForm.name = ''
addOrUpdateForm.orgCode = ''
addOrUpdateForm.activationPrefix = ''
addOrUpdateForm.canHoldInventory = false
addOrUpdateForm.developmentStaff = ''
addOrUpdateForm.contactPhone = ''
addOrUpdateFormRef.value?.clearValidate()
}
function addHandle (): void {
resetForm()
addOrUpdateTitle.value = '新增渠道'
addOrUpdateVisible.value = true
}
function addOrUpdateHandle (row: ChannelInfo): void {
resetForm()
addOrUpdateTitle.value = '修改渠道'
addOrUpdateForm.id = row.id
addOrUpdateForm.parentId = row.parentId
addOrUpdateForm.name = row.name
addOrUpdateForm.orgCode = row.orgCode || ''
addOrUpdateForm.activationPrefix = row.activationPrefix || ''
addOrUpdateForm.canHoldInventory = row.canHoldInventory
addOrUpdateForm.developmentStaff = row.developmentStaff || ''
addOrUpdateForm.contactPhone = row.contactPhone || ''
addOrUpdateVisible.value = true
}
async function submitForm (): Promise<void> {
if (!addOrUpdateFormRef.value) return
await addOrUpdateFormRef.value.validate(async (valid) => {
if (!valid) return
const res = await saveOrUpdateChannel({
id: addOrUpdateForm.id,
parentId: addOrUpdateForm.parentId ?? 0,
name: addOrUpdateForm.name,
orgCode: addOrUpdateForm.orgCode,
activationPrefix: addOrUpdateForm.activationPrefix,
canHoldInventory: addOrUpdateForm.canHoldInventory,
developmentStaff: addOrUpdateForm.developmentStaff,
contactPhone: addOrUpdateForm.contactPhone
})
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
addOrUpdateVisible.value = false
getDataList()
}
})
} else {
ElMessage.error(res?.msg || '操作失败')
}
})
}
function deleteHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteChannel([id]).then((data) => {
if (data && data.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
}
})
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
}).catch(() => {})
}
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.mod-channel {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,542 @@
<template>
<div class="mod-content-package">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-select v-model="dataForm.partnerId" placeholder="合作商" clearable filterable style="width:180px">
<el-option v-for="p in partnerOptions" :key="p.id" :label="p.name" :value="p.partnerId"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.name" placeholder="内容包名称" clearable></el-input>
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="状态" clearable>
<el-option label="启用" value="ACTIVE"></el-option>
<el-option label="禁用" value="DISABLED"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="success" @click="openSelectDialog()">从栏目选择内容</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
style="width: 100%;">
<el-table-column
type="index"
header-align="center"
align="center"
width="80"
label="序号">
</el-table-column>
<el-table-column
prop="partnerName"
header-align="center"
align="center"
label="合作商">
<template #default="scope">
{{ scope.row.partnerName || partnerNameOf(scope.row.partnerId) }}
</template>
</el-table-column>
<el-table-column
prop="name"
header-align="center"
align="center"
label="名称">
</el-table-column>
<el-table-column
prop="description"
header-align="center"
align="center"
label="描述">
</el-table-column>
<el-table-column
prop="status"
header-align="center"
align="center"
label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else-if="scope.row.status === 'DISABLED'" size="small" type="danger">禁用</el-tag>
<el-tag v-else size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
header-align="center"
align="center"
width="180"
label="创建时间">
<template #default="scope">
{{ scope.row.createdAt || '' }}
</template>
</el-table-column>
<el-table-column
fixed="right"
header-align="center"
align="center"
width="290"
label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="addOrUpdateHandle(scope.row)">修改</el-button>
<el-button link type="primary" size="small" @click="viewContentHandle(scope.row)">查看内容</el-button>
<el-button link type="success" size="small" @click="editContentHandle(scope.row)">修改内容</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 弹窗, 修改元信息 -->
<el-dialog v-model="addOrUpdateVisible" :title="addOrUpdateTitle" width="560px">
<el-form :model="addOrUpdateForm" :rules="addOrUpdateRules" ref="addOrUpdateFormRef" label-width="120px">
<el-form-item label="合作商" prop="partnerId">
<el-select v-model="addOrUpdateForm.partnerId" placeholder="请选择合作商" filterable style="width:100%">
<el-option v-for="p in partnerOptions" :key="p.id" :label="p.name" :value="p.partnerId"></el-option>
</el-select>
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input v-model="addOrUpdateForm.name" placeholder="名称"></el-input>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="addOrUpdateForm.description" type="textarea" placeholder="描述"></el-input>
</el-form-item>
<el-form-item v-if="addOrUpdateForm.id" label="状态" prop="status">
<el-select v-model="addOrUpdateForm.status" placeholder="状态">
<el-option label="启用" value="ACTIVE"></el-option>
<el-option label="禁用" value="DISABLED"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addOrUpdateVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm()">确定</el-button>
</template>
</el-dialog>
<!-- 弹窗, 查看内容 -->
<el-dialog v-model="contentVisible" title="内容包内容" width="800px" top="5vh">
<div style="margin-bottom: 8px">共 {{ viewContentList.length }} 部内容</div>
<el-table :data="viewContentList" border max-height="500" style="width: 100%">
<el-table-column type="index" header-align="center" align="center" width="50" label="序号" />
<el-table-column label="海报" header-align="center" align="center" width="80">
<template #default="scope">
<el-image v-if="scope.row.posterimg" :src="scope.row.posterimg" style="width: 50px; height: 70px" fit="cover" :preview-src-list="[scope.row.posterimg]" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="name" header-align="center" align="center" min-width="160" label="名称" />
<el-table-column prop="categoryname" header-align="center" align="center" width="100" label="分类" />
<el-table-column prop="score" header-align="center" align="center" width="80" label="评分" />
<el-table-column prop="releaseyear" header-align="center" align="center" width="80" label="年份" />
</el-table>
<template #footer>
<el-button type="primary" @click="contentVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 弹窗, 从栏目选择内容(新建/修改内容) -->
<el-dialog v-model="selectVisible" :title="selectTitle" width="900px" top="5vh">
<el-form :inline="true" :model="selectForm">
<el-form-item label="合作商">
<el-select v-model="selectForm.partnerId" filterable placeholder="请选择" style="width:180px">
<el-option v-for="p in partnerOptions" :key="p.id" :label="p.name" :value="p.partnerId"></el-option>
</el-select>
</el-form-item>
<el-form-item label="内容包名称">
<el-input v-model="selectForm.name" placeholder="如:电影频道包" style="width:200px"></el-input>
</el-form-item>
<el-form-item label="栏目">
<el-select v-model="selectForm.catalogid" filterable placeholder="选择栏目" style="width:220px" @change="onSelectCatalogChange">
<el-option-group v-for="g in catalogTree" :key="g.id" :label="g.name">
<el-option v-for="c in g.children" :key="c.id" :label="c.name" :value="c.id"></el-option>
</el-option-group>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="fetchContents(1)">拉取内容</el-button>
</el-form-item>
</el-form>
<el-table
ref="contentTableRef"
:data="contentList"
border
v-loading="contentLoading"
style="width: 100%"
max-height="380"
row-key="contentcode"
@selection-change="onSelectionChange">
<el-table-column type="selection" width="50" :reserve-selection="true"></el-table-column>
<el-table-column prop="name" label="名称" min-width="160"></el-table-column>
<el-table-column prop="categoryname" label="分类" width="100"></el-table-column>
<el-table-column prop="score" label="评分" width="80"></el-table-column>
<el-table-column label="免费" width="80">
<template #default="scope">{{ (scope.row.isFree == 1 || scope.row.isFree == '1') ? '是' : '否' }}</template>
</el-table-column>
<el-table-column prop="episodecount" label="集数" width="80"></el-table-column>
<el-table-column prop="releaseyear" label="年份" width="80"></el-table-column>
</el-table>
<el-pagination
@current-change="onContentPageChange"
:current-page="contentPageIndex"
:page-size="contentPageSize"
:total="contentTotal"
layout="total, prev, pager, next"
style="margin-top: 8px">
</el-pagination>
<div style="margin-top: 8px; color: #409eff">已选 {{ selectedContents.length }} 部内容</div>
<template #footer>
<el-button @click="selectVisible = false">取消</el-button>
<el-button type="primary" @click="submitSelection()">保存内容包</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
getContentPackageList,
saveOrUpdateContentPackage,
deleteContentPackage,
getContentPackageInfo,
fetchPackageContents,
saveContentPackageFromSelection,
type ContentPackage,
type CatalogContentItem
} from '@/api/manage/content-package'
import { getPartnerList, type PartnerInfo } from '@/api/manage/partner'
import { getJetsenMediaCatalogTree, type JetsenCatalogNode } from '@/api/jetsenMedia'
interface DataForm {
partnerId: string | null
name: string
status: string
}
const dataForm = reactive<DataForm>({
partnerId: null,
name: '',
status: ''
})
const dataList = ref<ContentPackage[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
/** 合作伙伴下拉选项(从 /manage/partner/list 加载) */
const partnerOptions = ref<PartnerInfo[]>([])
async function loadPartnerOptions (): Promise<void> {
try {
const res = await getPartnerList({ page: 1, limit: 500 })
if (res && res.code === 0) {
partnerOptions.value = res.data?.page?.list || []
} else {
partnerOptions.value = []
}
} catch {
partnerOptions.value = []
}
}
function partnerNameOf (partnerId?: string): string {
if (partnerId == null) return '-'
const p = partnerOptions.value.find(item => item.partnerId === partnerId)
return p?.name || String(partnerId)
}
const addOrUpdateVisible = ref<boolean>(false)
const addOrUpdateTitle = ref<string>('')
const addOrUpdateFormRef = ref<FormInstance>()
interface AddOrUpdateForm {
id?: number
partnerId: string | null
name: string
description: string
status: 'ACTIVE' | 'DISABLED'
}
const addOrUpdateForm = reactive<AddOrUpdateForm>({
partnerId: null,
name: '',
description: '',
status: 'ACTIVE'
})
const addOrUpdateRules: FormRules = {
partnerId: [{ required: true, message: '请选择合作商', trigger: 'change' }],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }]
}
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
const res = await getContentPackageList({
page: pageIndex.value,
limit: pageSize.value,
partnerId: dataForm.partnerId ?? undefined,
name: dataForm.name,
status: dataForm.status
})
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function resetForm (): void {
addOrUpdateForm.id = undefined
addOrUpdateForm.partnerId = null
addOrUpdateForm.name = ''
addOrUpdateForm.description = ''
addOrUpdateForm.status = 'ACTIVE'
addOrUpdateFormRef.value?.clearValidate()
}
function addOrUpdateHandle (row: ContentPackage): void {
resetForm()
addOrUpdateTitle.value = '修改内容包'
addOrUpdateForm.id = row.id
addOrUpdateForm.partnerId = row.partnerId
addOrUpdateForm.name = row.name
addOrUpdateForm.description = row.description || ''
addOrUpdateForm.status = row.status
addOrUpdateVisible.value = true
}
async function submitForm (): Promise<void> {
if (!addOrUpdateFormRef.value) return
await addOrUpdateFormRef.value.validate(async (valid) => {
if (!valid) return
const res = await saveOrUpdateContentPackage({
id: addOrUpdateForm.id,
partnerId: addOrUpdateForm.partnerId || '',
name: addOrUpdateForm.name,
description: addOrUpdateForm.description,
status: addOrUpdateForm.status
})
if (res && res.code === 0) {
ElMessage.success({ message: '操作成功', duration: 1500, onClose: () => { addOrUpdateVisible.value = false; getDataList() } })
} else {
ElMessage.error(res?.msg || '操作失败')
}
})
}
function deleteHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteContentPackage([id]).then((data) => {
if (data && data.code === 0) {
ElMessage.success({ message: '操作成功', duration: 1500, onClose: () => getDataList() })
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
}).catch(() => {})
}
// ===================== 查看内容 =====================
const contentVisible = ref<boolean>(false)
const viewContentList = ref<CatalogContentItem[]>([])
async function viewContentHandle (row: ContentPackage): Promise<void> {
const res = await getContentPackageInfo(row.id)
if (res && res.code === 0 && res.data) {
viewContentList.value = res.data.contentList || []
contentVisible.value = true
} else {
ElMessage.error(res?.msg || '查询失败')
}
}
// ===================== 从栏目选择内容(新建/修改内容) =====================
const selectVisible = ref<boolean>(false)
const selectTitle = ref<string>('从栏目选择内容')
const contentTableRef = ref<any>(null)
const catalogTree = ref<JetsenCatalogNode[]>([])
const contentList = ref<CatalogContentItem[]>([])
const contentLoading = ref<boolean>(false)
const contentPageIndex = ref<number>(1)
const contentPageSize = ref<number>(50)
const contentTotal = ref<number>(0)
const selectedContents = ref<CatalogContentItem[]>([])
const selectForm = reactive<{ id?: number; partnerId: string | null; name: string; catalogid: string | number }>({
id: undefined,
partnerId: null,
name: '',
catalogid: ''
})
async function loadCatalogTree (): Promise<void> {
try {
const res = await getJetsenMediaCatalogTree()
const body = (res as any).data
if (body && Number(body.code) === 0) {
catalogTree.value = (body.data && body.data.list) || []
}
} catch (e) {
console.error('加载栏目树失败', e)
}
}
/** 新建内容包:打开内容选择弹窗 */
function openSelectDialog (): void {
selectForm.id = undefined
selectForm.partnerId = null
selectForm.name = ''
selectForm.catalogid = ''
contentList.value = []
contentTotal.value = 0
selectedContents.value = []
contentTableRef.value?.clearSelection()
selectTitle.value = '从栏目选择内容'
selectVisible.value = true
if (catalogTree.value.length === 0) {
loadCatalogTree()
}
}
/** 修改内容:调 info 拿 id,打开内容选择弹窗(重新选择内容覆盖) */
async function editContentHandle (row: ContentPackage): Promise<void> {
const res = await getContentPackageInfo(row.id)
if (res && res.code === 0 && res.data) {
const pkg = res.data.contentPackage
selectForm.id = pkg.id
selectForm.partnerId = pkg.partnerId
selectForm.name = pkg.name
selectForm.catalogid = ''
contentList.value = []
contentTotal.value = 0
selectedContents.value = []
contentTableRef.value?.clearSelection()
selectTitle.value = '修改内容包内容'
selectVisible.value = true
if (catalogTree.value.length === 0) {
loadCatalogTree()
}
} else {
ElMessage.error(res?.msg || '查询失败')
}
}
function onSelectCatalogChange (): void {
contentPageIndex.value = 1
fetchContents(1)
}
async function fetchContents (page?: number): Promise<void> {
if (!selectForm.catalogid) {
ElMessage.warning('请先选择栏目')
return
}
if (page) contentPageIndex.value = page
contentLoading.value = true
try {
const res = await fetchPackageContents({
catalogid: selectForm.catalogid,
pageindex: contentPageIndex.value,
pagesize: contentPageSize.value
})
if (res && res.code === 0) {
contentList.value = res.data?.list || []
contentTotal.value = res.data?.totalCount || 0
} else {
contentList.value = []
contentTotal.value = 0
ElMessage.error(res?.msg || '拉取失败')
}
} finally {
contentLoading.value = false
}
}
function onContentPageChange (val: number): void {
fetchContents(val)
}
function onSelectionChange (rows: CatalogContentItem[]): void {
selectedContents.value = rows
}
/** 一键保存勾选内容(传 id 覆盖,不传新建) */
async function submitSelection (): Promise<void> {
if (!selectForm.partnerId) {
ElMessage.warning('请选择合作商')
return
}
if (!selectForm.name) {
ElMessage.warning('请输入内容包名称')
return
}
if (selectedContents.value.length === 0) {
ElMessage.warning('请勾选内容')
return
}
const res = await saveContentPackageFromSelection({
id: selectForm.id,
partnerId: selectForm.partnerId,
name: selectForm.name,
catalogIds: String(selectForm.catalogid),
selectedContents: selectedContents.value
})
if (res && res.code === 0) {
ElMessage.success({ message: '保存成功', duration: 1500, onClose: () => { selectVisible.value = false; getDataList() } })
} else {
ElMessage.error(res?.msg || '保存失败')
}
}
onMounted(() => {
loadPartnerOptions()
getDataList()
})
</script>
<style scoped>
.mod-content-package {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,503 @@
<template>
<div class="mod-device">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.deviceName" placeholder="请输入设备名称" clearable></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.mac" placeholder="请输入MAC地址" clearable></el-input>
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="状态" clearable>
<el-option label="启用" value="ACTIVE"></el-option>
<el-option label="禁用" value="INACTIVE"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="addHandle()">新增</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
style="width: 100%;">
<el-table-column
type="index"
header-align="center"
align="center"
width="60"
label="序号">
</el-table-column>
<el-table-column
prop="deviceName"
header-align="center"
align="center"
label="设备名称">
</el-table-column>
<el-table-column
prop="mac"
header-align="center"
align="center"
label="MAC地址">
</el-table-column>
<el-table-column
prop="androidId"
header-align="center"
align="center"
label="Android ID">
<template #default="scope">
{{ scope.row.androidId || '-' }}
</template>
</el-table-column>
<el-table-column
prop="manufacturer"
header-align="center"
align="center"
label="厂商">
<template #default="scope">
{{ scope.row.manufacturer || '-' }}
</template>
</el-table-column>
<el-table-column
prop="model"
header-align="center"
align="center"
label="型号">
<template #default="scope">
{{ scope.row.model || scope.row.deviceModel || '-' }}
</template>
</el-table-column>
<el-table-column
prop="brand"
header-align="center"
align="center"
label="品牌">
<template #default="scope">
{{ scope.row.brand || '-' }}
</template>
</el-table-column>
<el-table-column
header-align="center"
align="center"
label="绑定状态">
<template #default="scope">
<el-tag v-if="scope.row.storeOrgId" size="small" type="success">已绑定</el-tag>
<el-tag v-else size="small" type="info">未绑定</el-tag>
</template>
</el-table-column>
<el-table-column
prop="storeName"
header-align="center"
align="center"
label="所属门店">
<template #default="scope">
{{ scope.row.storeName || '-' }}
</template>
</el-table-column>
<el-table-column
prop="roomNo"
header-align="center"
align="center"
label="房间号">
</el-table-column>
<el-table-column
prop="lastHeartbeat"
header-align="center"
align="center"
width="180"
label="最后心跳">
<template #default="scope">
{{ scope.row.lastHeartbeat || '-' }}
</template>
</el-table-column>
<el-table-column
prop="status"
header-align="center"
align="center"
label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else-if="scope.row.status === 'INACTIVE'" size="small" type="danger">禁用</el-tag>
<el-tag v-else size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column
fixed="right"
header-align="center"
align="center"
width="280"
label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="addOrUpdateHandle(scope.row)">修改</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
<el-button link type="success" size="small" @click="showBenefits(scope.row)">权益</el-button>
<el-button link type="warning" size="small" @click="unbindOrgHandle(scope.row)" v-if="scope.row.storeOrgId">解绑设备</el-button>
<el-button link type="info" size="small" @click="unbindCardHandle(scope.row)">退天卡</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 弹窗, 新增 / 修改 -->
<el-dialog v-model="addOrUpdateVisible" :title="addOrUpdateTitle" width="600px">
<el-form :model="addOrUpdateForm" :rules="addOrUpdateRules" ref="addOrUpdateFormRef" label-width="100px">
<el-form-item label="门店" prop="storeOrgId">
<org-tree-select v-model="addOrUpdateForm.storeOrgId" placeholder="请选择门店" :hide-node-types="['PLATFORM', 'CHANNEL', 'SERVICE_PROVIDER']" />
</el-form-item>
<el-form-item label="MAC地址" prop="mac">
<el-input v-model="addOrUpdateForm.mac" placeholder="请输入MAC地址"></el-input>
</el-form-item>
<el-form-item label="WiFi MAC" prop="wifiMac">
<el-input v-model="addOrUpdateForm.wifiMac" placeholder="请输入WiFi MAC"></el-input>
</el-form-item>
<el-form-item label="设备UID" prop="deviceUid">
<el-input v-model="addOrUpdateForm.deviceUid" placeholder="请输入设备UID"></el-input>
</el-form-item>
<el-form-item label="设备SN" prop="deviceSn">
<el-input v-model="addOrUpdateForm.deviceSn" placeholder="请输入设备SN"></el-input>
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input v-model="addOrUpdateForm.deviceName" placeholder="请输入设备名称"></el-input>
</el-form-item>
<el-form-item label="设备型号" prop="deviceModel">
<el-input v-model="addOrUpdateForm.deviceModel" placeholder="请输入设备型号"></el-input>
</el-form-item>
<el-form-item label="房间号" prop="roomNo">
<el-input v-model="addOrUpdateForm.roomNo" placeholder="请输入房间号"></el-input>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="addOrUpdateForm.remark" placeholder="请输入备注"></el-input>
</el-form-item>
<!-- 只读设备信息(修改时展示) -->
<template v-if="addOrUpdateForm.id">
<el-divider content-position="left">设备硬件信息</el-divider>
<el-form-item label="Android ID">
<el-input :model-value="addOrUpdateForm.androidId" disabled placeholder="-"></el-input>
</el-form-item>
<el-form-item label="厂商">
<el-input :model-value="addOrUpdateForm.manufacturer" disabled placeholder="-"></el-input>
</el-form-item>
<el-form-item label="型号">
<el-input :model-value="addOrUpdateForm.model" disabled placeholder="-"></el-input>
</el-form-item>
<el-form-item label="品牌">
<el-input :model-value="addOrUpdateForm.brand" disabled placeholder="-"></el-input>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="addOrUpdateVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm()">确定</el-button>
</template>
</el-dialog>
<!-- 设备权益弹窗 -->
<el-dialog v-model="benefitsVisible" title="设备天卡权益" width="450px">
<div v-loading="benefitsLoading">
<el-descriptions :column="1" border v-if="benefitsData">
<el-descriptions-item label="设备名称">{{ benefitsData.deviceName || '-' }}</el-descriptions-item>
<el-descriptions-item label="MAC">{{ benefitsData.mac || '-' }}</el-descriptions-item>
<el-descriptions-item label="是否有权益">
<el-tag v-if="benefitsData.hasBenefits" size="small" type="success">有</el-tag>
<el-tag v-else size="small" type="info">无</el-tag>
</el-descriptions-item>
<el-descriptions-item label="第三方卡号" v-if="benefitsData.thirdCardNo">{{ benefitsData.thirdCardNo }}</el-descriptions-item>
<el-descriptions-item label="卡密" v-if="benefitsData.cardCode">{{ benefitsData.cardCode }}</el-descriptions-item>
<el-descriptions-item label="过期时间" v-if="benefitsData.expireAt">{{ benefitsData.expireAt }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="benefitsVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getDeviceList, saveOrUpdateDevice, deleteDevice, unbindDeviceOrg, unbindDayCard, type DeviceInfo, type DeviceSaveData } from '@/api/manage/device'
import { getDeviceBenefits, type DeviceBenefits } from '@/api/manage/daycard'
import OrgTreeSelect from '@/components/org-tree-select/index.vue'
// ===================== 列表 =====================
const dataForm = reactive({
deviceName: '',
mac: '',
status: ''
})
const dataList = ref<DeviceInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
const res = await getDeviceList({
page: pageIndex.value,
limit: pageSize.value,
deviceName: dataForm.deviceName,
mac: dataForm.mac,
status: dataForm.status
})
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
ElMessage.error(res?.msg || '获取数据失败')
}
} catch (e: any) {
dataList.value = []
totalPage.value = 0
ElMessage.error(e?.message || '请求异常')
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
// ===================== 新增/修改 =====================
const addOrUpdateVisible = ref<boolean>(false)
const addOrUpdateTitle = ref<string>('')
const addOrUpdateFormRef = ref<FormInstance>()
interface AddOrUpdateForm {
id: number | undefined
storeOrgId: number | null
mac: string
wifiMac: string
deviceUid: string
deviceSn: string
deviceName: string
deviceModel: string
roomNo: string
remark: string
androidId?: string
manufacturer?: string
model?: string
brand?: string
}
const addOrUpdateForm = reactive<AddOrUpdateForm>({
id: undefined,
storeOrgId: null,
mac: '',
wifiMac: '',
deviceUid: '',
deviceSn: '',
deviceName: '',
deviceModel: '',
roomNo: '',
remark: ''
})
const addOrUpdateRules: FormRules = {
storeOrgId: [{ required: true, message: '请选择门店', trigger: 'change' }]
}
function resetForm (): void {
addOrUpdateForm.id = undefined
addOrUpdateForm.storeOrgId = null
addOrUpdateForm.mac = ''
addOrUpdateForm.wifiMac = ''
addOrUpdateForm.deviceUid = ''
addOrUpdateForm.deviceSn = ''
addOrUpdateForm.deviceName = ''
addOrUpdateForm.deviceModel = ''
addOrUpdateForm.roomNo = ''
addOrUpdateForm.remark = ''
addOrUpdateForm.androidId = undefined
addOrUpdateForm.manufacturer = undefined
addOrUpdateForm.model = undefined
addOrUpdateForm.brand = undefined
addOrUpdateFormRef.value?.clearValidate()
}
function addHandle (): void {
resetForm()
addOrUpdateTitle.value = '新增设备'
addOrUpdateVisible.value = true
}
function addOrUpdateHandle (row: DeviceInfo): void {
resetForm()
addOrUpdateTitle.value = '修改设备'
addOrUpdateForm.id = row.id
addOrUpdateForm.storeOrgId = row.storeOrgId
addOrUpdateForm.mac = row.mac || ''
addOrUpdateForm.wifiMac = row.wifiMac || ''
addOrUpdateForm.deviceUid = row.deviceUid || ''
addOrUpdateForm.deviceSn = row.deviceSn || ''
addOrUpdateForm.deviceName = row.deviceName || ''
addOrUpdateForm.deviceModel = row.deviceModel || ''
addOrUpdateForm.roomNo = row.roomNo || ''
addOrUpdateForm.remark = row.remark || ''
addOrUpdateForm.androidId = row.androidId
addOrUpdateForm.manufacturer = row.manufacturer
addOrUpdateForm.model = row.model
addOrUpdateForm.brand = row.brand
addOrUpdateVisible.value = true
}
async function submitForm (): Promise<void> {
if (!addOrUpdateFormRef.value) return
await addOrUpdateFormRef.value.validate(async (valid) => {
if (!valid) return
const res = await saveOrUpdateDevice({
id: addOrUpdateForm.id,
storeOrgId: addOrUpdateForm.storeOrgId ?? 0,
mac: addOrUpdateForm.mac,
wifiMac: addOrUpdateForm.wifiMac,
deviceUid: addOrUpdateForm.deviceUid,
deviceSn: addOrUpdateForm.deviceSn,
deviceName: addOrUpdateForm.deviceName,
deviceModel: addOrUpdateForm.deviceModel,
roomNo: addOrUpdateForm.roomNo,
remark: addOrUpdateForm.remark
} as DeviceSaveData)
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
addOrUpdateVisible.value = false
getDataList()
}
})
} else {
ElMessage.error(res?.msg || '操作失败')
}
})
}
function deleteHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteDevice([id]).then((data) => {
if (data && data.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
}
})
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
}).catch(() => {})
}
// ===================== 解绑设备 / 退天卡 =====================
function unbindOrgHandle (row: DeviceInfo): void {
ElMessageBox.confirm(
`确定对设备[${row.deviceName || row.id}]进行[解绑设备(换场所)]操作?\n将清除门店关联、作废当前sign、释放天卡权益。`,
'解绑设备',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
unbindDeviceOrg(row.id).then((res) => {
if (res && res.code === 0) {
ElMessage.success({ message: '解绑成功', duration: 1500, onClose: () => getDataList() })
} else {
ElMessage.error(res?.msg || '解绑失败')
}
})
}).catch(() => {})
}
function unbindCardHandle (row: DeviceInfo): void {
ElMessageBox.confirm(
`确定对设备[${row.deviceName || row.id}]进行[退天卡]操作?\n仅退还天卡权益,不解绑门店。`,
'退天卡',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
unbindDayCard(row.id).then((res) => {
if (res && res.code === 0) {
ElMessage.success({ message: '退卡成功', duration: 1500, onClose: () => getDataList() })
} else {
ElMessage.error(res?.msg || '退卡失败')
}
})
}).catch(() => {})
}
// ===================== 设备权益 =====================
interface BenefitsDisplay extends DeviceBenefits {
deviceName?: string
deviceId?: number
mac?: string
}
const benefitsVisible = ref(false)
const benefitsLoading = ref(false)
const benefitsData = ref<BenefitsDisplay | null>(null)
async function showBenefits (row: DeviceInfo): Promise<void> {
benefitsVisible.value = true
benefitsLoading.value = true
benefitsData.value = {
hasBenefits: false,
deviceName: row.deviceName,
deviceId: row.id,
mac: row.mac
}
try {
const res = await getDeviceBenefits(row.id)
if (res && res.code === 0 && res.data) {
benefitsData.value = { ...res.data, deviceName: row.deviceName, deviceId: row.id, mac: row.mac }
} else if (res && res.code === 200 && res.data) {
benefitsData.value = { ...res.data, deviceName: row.deviceName, deviceId: row.id, mac: row.mac }
} else {
ElMessage.error(res?.msg || '查询权益失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '查询权益异常')
} finally {
benefitsLoading.value = false
}
}
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.mod-device {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<div class="mod-openapi-call-log">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-select v-model="dataForm.partnerId" placeholder="合作商" clearable filterable style="width:180px">
<el-option v-for="p in partnerOptions" :key="p.id" :label="p.name" :value="p.partnerId"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.api" placeholder="接口路径" clearable style="width:220px"></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%">
<el-table-column type="index" header-align="center" align="center" width="60" label="序号" />
<el-table-column header-align="center" align="center" min-width="140" label="合作商">
<template #default="scope">{{ partnerNameOf(scope.row.partnerId) }}</template>
</el-table-column>
<el-table-column prop="appKey" header-align="center" align="center" width="160" label="AppKey" show-overflow-tooltip />
<el-table-column prop="api" header-align="center" align="center" min-width="220" label="接口" show-overflow-tooltip />
<el-table-column prop="httpMethod" header-align="center" align="center" width="80" label="方法" />
<el-table-column prop="resultCode" header-align="center" align="center" width="90" label="结果">
<template #default="scope">
<el-tag v-if="scope.row.resultCode === 0" size="small" type="success">成功</el-tag>
<el-tag v-else size="small" type="danger">{{ scope.row.resultCode }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="costMs" header-align="center" align="center" width="90" label="耗时(ms)" />
<el-table-column prop="ip" header-align="center" align="center" width="130" label="IP" />
<el-table-column prop="createdAt" header-align="center" align="center" width="170" label="调用时间" />
<el-table-column fixed="right" header-align="center" align="center" width="90" label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="viewDetail(scope.row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="调用详情" width="720px" top="5vh">
<el-descriptions :column="1" border>
<el-descriptions-item label="合作商">{{ partnerNameOf(detailData.partnerId) }}</el-descriptions-item>
<el-descriptions-item label="AppKey">{{ detailData.appKey || '-' }}</el-descriptions-item>
<el-descriptions-item label="接口">{{ detailData.api }}</el-descriptions-item>
<el-descriptions-item label="方法">{{ detailData.httpMethod || '-' }}</el-descriptions-item>
<el-descriptions-item label="结果">
<el-tag v-if="detailData.resultCode === 0" size="small" type="success">成功</el-tag>
<el-tag v-else size="small" type="danger">{{ detailData.resultCode }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="耗时">{{ detailData.costMs ?? '-' }} ms</el-descriptions-item>
<el-descriptions-item label="IP">{{ detailData.ip || '-' }}</el-descriptions-item>
<el-descriptions-item label="请求参数">
<pre style="white-space: pre-wrap; word-break: break-all; max-height: 240px; overflow: auto; margin: 0">{{ detailData.params || '-' }}</pre>
</el-descriptions-item>
<el-descriptions-item label="调用时间">{{ detailData.createdAt || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="primary" @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { getOpenApiCallLogList, type OpenApiCallLogItem } from '@/api/manage/openapiCallLog'
import { getPartnerList, type PartnerInfo } from '@/api/manage/partner'
interface DataForm {
partnerId: string | null
api: string
}
const dataForm = reactive<DataForm>({ partnerId: null, api: '' })
const dataList = ref<OpenApiCallLogItem[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
const partnerOptions = ref<PartnerInfo[]>([])
async function loadPartnerOptions (): Promise<void> {
try {
const res = await getPartnerList({ page: 1, limit: 500 })
if (res && res.code === 0) partnerOptions.value = res.data?.page?.list || []
} catch {
partnerOptions.value = []
}
}
function partnerNameOf (partnerId?: string): string {
if (!partnerId) return '-'
const p = partnerOptions.value.find(i => i.partnerId === partnerId)
return p?.name || String(partnerId)
}
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
const res = await getOpenApiCallLogList({
page: pageIndex.value,
limit: pageSize.value,
partnerId: dataForm.partnerId ?? undefined,
api: dataForm.api || undefined
})
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
const detailVisible = ref<boolean>(false)
const detailData = reactive<Partial<OpenApiCallLogItem>>({})
function viewDetail (row: OpenApiCallLogItem): void {
Object.assign(detailData, row)
detailVisible.value = true
}
onMounted(() => {
loadPartnerOptions()
getDataList()
})
</script>
<style scoped>
.mod-openapi-call-log {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,385 @@
<template>
<div class="mod-partner-card">
<!-- 库存统计 -->
<el-row :gutter="16" class="stats-row">
<el-col :span="8">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">总卡数</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value" style="color: #67c23a;">{{ stats.available }}</div>
<div class="stat-label">可用(AVAILABLE)</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value" style="color: #409eff;">{{ stats.used }}</div>
<div class="stat-label">已消耗(CONSUMED)</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 搜索 + 采购 -->
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-select v-model="dataForm.partnerId" placeholder="合作商" clearable filterable style="width:180px">
<el-option v-for="p in partnerOptions" :key="p.id" :label="p.name" :value="p.partnerId"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="状态" clearable style="width:140px">
<el-option label="可用" value="AVAILABLE"></el-option>
<el-option label="已消耗" value="CONSUMED"></el-option>
<el-option label="已禁用" value="DISABLED"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.purchaseBatch" placeholder="采购批次号" clearable style="width:200px"></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="purchaseHandle()">采购天卡</el-button>
</el-form-item>
<el-form-item>
<el-button type="warning" @click="revertHandle()">回退卡</el-button>
</el-form-item>
</el-form>
<!-- 库存列表 -->
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%;">
<el-table-column type="index" header-align="center" align="center" width="60" label="序号" />
<el-table-column header-align="center" align="center" min-width="140" label="合作商">
<template #default="scope">
{{ partnerNameOf(scope.row.partnerId) }}
</template>
</el-table-column>
<el-table-column prop="cardCode" header-align="center" align="center" min-width="160" label="卡密" show-overflow-tooltip />
<el-table-column prop="thirdCardNo" header-align="center" align="center" min-width="160" label="第三方卡号" show-overflow-tooltip />
<el-table-column prop="status" header-align="center" align="center" width="100" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'AVAILABLE'" size="small" type="success">可用</el-tag>
<el-tag v-else-if="scope.row.status === 'CONSUMED'" size="small" type="primary">已消耗</el-tag>
<el-tag v-else-if="scope.row.status === 'DISABLED'" size="small" type="danger">已禁用</el-tag>
<el-tag v-else size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="purchaseBatch" header-align="center" align="center" min-width="180" label="采购批次" show-overflow-tooltip />
<el-table-column prop="deviceId" header-align="center" align="center" width="100" label="设备ID">
<template #default="scope">{{ scope.row.deviceId != null ? scope.row.deviceId : '-' }}</template>
</el-table-column>
<el-table-column prop="consumedAt" header-align="center" align="center" width="170" label="消耗时间">
<template #default="scope">{{ scope.row.consumedAt || '-' }}</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="170" label="采购时间" />
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 采购弹窗 -->
<el-dialog v-model="purchaseVisible" title="采购天卡" width="560px">
<el-form :model="purchaseForm" :rules="purchaseRules" ref="purchaseFormRef" label-width="120px">
<el-form-item label="合作商" prop="partnerId">
<el-select v-model="purchaseForm.partnerId" placeholder="请选择合作商" filterable style="width:100%">
<el-option v-for="p in partnerOptions" :key="p.id" :label="p.name" :value="p.partnerId"></el-option>
</el-select>
</el-form-item>
<el-form-item label="采购卡数" prop="quantity">
<el-input-number v-model="purchaseForm.quantity" :min="1" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="金额(元)" prop="amount">
<el-input-number v-model="purchaseForm.amount" :min="0" :precision="2" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="purchaseForm.remark" type="textarea" placeholder="备注"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="purchaseVisible = false">取消</el-button>
<el-button type="primary" @click="submitPurchase()">确定</el-button>
</template>
</el-dialog>
<!-- 回退卡弹窗 -->
<el-dialog v-model="revertVisible" title="回退天卡" width="560px">
<el-form :model="revertForm" :rules="revertRules" ref="revertFormRef" label-width="120px">
<el-form-item label="合作商" prop="partnerId">
<el-select v-model="revertForm.partnerId" placeholder="请选择合作商" filterable style="width:100%">
<el-option v-for="p in partnerOptions" :key="p.id" :label="p.name" :value="p.partnerId"></el-option>
</el-select>
</el-form-item>
<el-form-item label="回退数量" prop="quantity">
<el-input-number v-model="revertForm.quantity" :min="1" controls-position="right"></el-input-number>
</el-form-item>
<div style="margin-left: 120px; color: #e6a23c; font-size: 12px; line-height: 1.6">
从合作商池子取N张可用(AVAILABLE)天卡回退到平台池子,已消耗(CONSUMED)的天卡不能回退。
</div>
</el-form>
<template #footer>
<el-button @click="revertVisible = false">取消</el-button>
<el-button type="warning" @click="submitRevert()">确定回退</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { getPartnerCardList, purchasePartnerCard, revertPartnerCard, type PartnerCardItem, type PartnerCardStats } from '@/api/manage/partner-card'
import { getPartnerList, type PartnerInfo } from '@/api/manage/partner'
interface DataForm {
partnerId: string | null
status: string
purchaseBatch: string
}
const dataForm = reactive<DataForm>({
partnerId: null,
status: '',
purchaseBatch: ''
})
const dataList = ref<PartnerCardItem[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
/** 库存统计 */
const stats = reactive<PartnerCardStats>({
total: 0,
available: 0,
used: 0
})
/** 合作伙伴下拉选项(从 /manage/partner/list 加载) */
const partnerOptions = ref<PartnerInfo[]>([])
async function loadPartnerOptions (): Promise<void> {
try {
const res = await getPartnerList({ page: 1, limit: 500 })
if (res && res.code === 0) {
partnerOptions.value = res.data?.page?.list || []
} else {
partnerOptions.value = []
}
} catch {
partnerOptions.value = []
}
}
/** 根据 partnerId(UUID) 查名称(列表回显用) */
function partnerNameOf (partnerId?: string): string {
if (partnerId == null) return '-'
const p = partnerOptions.value.find(item => item.partnerId === partnerId)
return p?.name || String(partnerId)
}
/** 统计:用列表接口的 totalCount 获取(无需单独 stats 接口,limit:1 轻量请求) */
async function loadStats (): Promise<void> {
try {
const [allRes, availRes, usedRes] = await Promise.all([
getPartnerCardList({ page: 1, limit: 1 }),
getPartnerCardList({ page: 1, limit: 1, status: 'AVAILABLE' }),
getPartnerCardList({ page: 1, limit: 1, status: 'CONSUMED' })
])
stats.total = allRes?.data?.page?.totalCount || 0
stats.available = availRes?.data?.page?.totalCount || 0
stats.used = usedRes?.data?.page?.totalCount || 0
} catch {
// 静默
}
}
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
const res = await getPartnerCardList({
page: pageIndex.value,
limit: pageSize.value,
partnerId: dataForm.partnerId ?? undefined,
status: dataForm.status || undefined,
purchaseBatch: dataForm.purchaseBatch || undefined
})
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
// ===================== 采购 =====================
const purchaseVisible = ref<boolean>(false)
const purchaseFormRef = ref<FormInstance>()
interface PurchaseForm {
partnerId: string | null
quantity: number
amount: number
remark: string
}
const purchaseForm = reactive<PurchaseForm>({
partnerId: null,
quantity: 1,
amount: 0,
remark: ''
})
const purchaseRules: FormRules = {
partnerId: [{ required: true, message: '请选择合作商', trigger: 'change' }],
quantity: [{ required: true, message: '请输入采购卡数', trigger: 'blur' }]
}
function resetPurchaseForm (): void {
purchaseForm.partnerId = null
purchaseForm.quantity = 1
purchaseForm.amount = 0
purchaseForm.remark = ''
purchaseFormRef.value?.clearValidate()
}
function purchaseHandle (): void {
resetPurchaseForm()
purchaseVisible.value = true
}
async function submitPurchase (): Promise<void> {
if (!purchaseFormRef.value) return
await purchaseFormRef.value.validate(async (valid) => {
if (!valid) return
const res = await purchasePartnerCard({
partnerId: purchaseForm.partnerId || '',
quantity: purchaseForm.quantity,
amount: Math.round(purchaseForm.amount * 100),
remark: purchaseForm.remark
})
if (res && res.code === 0) {
ElMessage.success({
message: '采购成功',
duration: 1500,
onClose: () => {
purchaseVisible.value = false
getDataList()
loadStats()
}
})
} else {
ElMessage.error(res?.msg || '采购失败')
}
})
}
// ===================== 回退卡 =====================
const revertVisible = ref<boolean>(false)
const revertFormRef = ref<FormInstance>()
interface RevertForm {
partnerId: string | null
quantity: number
}
const revertForm = reactive<RevertForm>({
partnerId: null,
quantity: 1
})
const revertRules: FormRules = {
partnerId: [{ required: true, message: '请选择合作商', trigger: 'change' }],
quantity: [{ required: true, message: '请输入回退数量', trigger: 'blur' }]
}
function revertHandle (): void {
revertForm.partnerId = null
revertForm.quantity = 1
revertFormRef.value?.clearValidate()
revertVisible.value = true
}
async function submitRevert (): Promise<void> {
if (!revertFormRef.value) return
await revertFormRef.value.validate(async (valid) => {
if (!valid) return
const res = await revertPartnerCard({
partnerId: revertForm.partnerId || '',
quantity: revertForm.quantity
})
if (res && res.code === 0) {
const reverted = (res as any).data?.reverted ?? revertForm.quantity
ElMessage.success(`回退成功:${reverted} 张`)
revertVisible.value = false
getDataList()
loadStats()
} else {
ElMessage.error(res?.msg || '回退失败')
}
})
}
onMounted(() => {
loadPartnerOptions()
getDataList()
loadStats()
})
</script>
<style scoped>
.mod-partner-card {
padding: 10px;
}
.stats-row {
margin-bottom: 16px;
}
.stat-card {
text-align: center;
padding: 10px 0;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #303133;
}
.stat-label {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div class="mod-partner-device">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-select v-model="dataForm.partnerId" placeholder="合作商" clearable filterable style="width:180px">
<el-option v-for="p in partnerOptions" :key="p.id" :label="p.name" :value="p.partnerId"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.deviceName" placeholder="设备名称" clearable style="width:160px"></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.mac" placeholder="MAC地址" clearable style="width:160px"></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%">
<el-table-column type="index" header-align="center" align="center" width="60" label="序号" />
<el-table-column header-align="center" align="center" min-width="140" label="合作商">
<template #default="scope">{{ partnerNameOf(scope.row.partnerId) }}</template>
</el-table-column>
<el-table-column prop="deviceName" header-align="center" align="center" min-width="140" label="设备名称" />
<el-table-column prop="mac" header-align="center" align="center" width="140" label="MAC" />
<el-table-column prop="deviceSn" header-align="center" align="center" min-width="160" label="设备SN" show-overflow-tooltip />
<el-table-column prop="deviceModel" header-align="center" align="center" width="120" label="型号" />
<el-table-column prop="status" header-align="center" align="center" width="100" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else-if="scope.row.status === 'INACTIVE'" size="small" type="danger">禁用</el-tag>
<el-tag v-else size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="storeName" header-align="center" align="center" min-width="140" label="绑定门店">
<template #default="scope">{{ scope.row.storeName || '-' }}</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="170" label="注册时间" />
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { getDeviceList, type DeviceInfo } from '@/api/manage/device'
import { getPartnerList, type PartnerInfo } from '@/api/manage/partner'
interface DataForm {
partnerId: string | null
deviceName: string
mac: string
}
const dataForm = reactive<DataForm>({ partnerId: null, deviceName: '', mac: '' })
const dataList = ref<DeviceInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
const partnerOptions = ref<PartnerInfo[]>([])
async function loadPartnerOptions (): Promise<void> {
try {
const res = await getPartnerList({ page: 1, limit: 500 })
if (res && res.code === 0) partnerOptions.value = res.data?.page?.list || []
} catch {
partnerOptions.value = []
}
}
function partnerNameOf (partnerId?: string): string {
if (!partnerId) return '-'
const p = partnerOptions.value.find(i => i.partnerId === partnerId)
return p?.name || String(partnerId)
}
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
// 固定 source=PARTNER,只查第三方接口注册的设备
const res = await getDeviceList({
page: pageIndex.value,
limit: pageSize.value,
source: 'PARTNER',
partnerId: dataForm.partnerId ?? undefined,
deviceName: dataForm.deviceName || undefined,
mac: dataForm.mac || undefined
})
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
onMounted(() => {
loadPartnerOptions()
getDataList()
})
</script>
<style scoped>
.mod-partner-device {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,283 @@
<template>
<div class="mod-partner">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.name" placeholder="合作伙伴名称" clearable></el-input>
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="状态" clearable style="width:120px">
<el-option label="启用" value="ACTIVE" />
<el-option label="禁用" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="addHandle()">新增</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%">
<el-table-column type="index" header-align="center" align="center" width="80" label="序号" />
<el-table-column prop="name" header-align="center" align="center" label="名称" />
<el-table-column prop="appKey" header-align="center" align="center" label="AppKey" />
<el-table-column prop="rateLimit" header-align="center" align="center" label="限流(次/分)" />
<el-table-column prop="status" header-align="center" align="center" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else size="small" type="danger">禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="180" label="创建时间" />
<el-table-column fixed="right" header-align="center" align="center" width="230" label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="viewSecretHandle(scope.row)">查看密钥</el-button>
<el-button link type="primary" size="small" @click="addOrUpdateHandle(scope.row)">修改</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper" />
</div>
<!-- 新增/修改弹窗 -->
<el-dialog v-model="addOrUpdateVisible" :title="addOrUpdateTitle" width="520px">
<el-form :model="addOrUpdateForm" :rules="addOrUpdateRules" ref="addOrUpdateFormRef" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="addOrUpdateForm.name" placeholder="合作伙伴名称"></el-input>
</el-form-item>
<el-form-item label="限流(次/分)" prop="rateLimit">
<el-input-number v-model="addOrUpdateForm.rateLimit" :min="0" controls-position="right" />
<span style="margin-left: 8px; color: #999">0=不限</span>
</el-form-item>
<el-form-item label="状态" prop="status" v-if="addOrUpdateForm.id">
<el-select v-model="addOrUpdateForm.status" style="width: 120px">
<el-option label="启用" value="ACTIVE" />
<el-option label="禁用" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="addOrUpdateForm.remark" type="textarea" :rows="2" placeholder="备注"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addOrUpdateVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm()">确定</el-button>
</template>
</el-dialog>
<!-- AppKey / AppSecret 展示弹窗 -->
<el-dialog v-model="secretVisible" title="AppKey / AppSecret" width="520px">
<el-descriptions :column="1" border>
<el-descriptions-item label="名称">{{ secretData.name }}</el-descriptions-item>
<el-descriptions-item label="AppKey">
<span style="word-break: break-all">{{ secretData.appKey }}</span>
<el-button link type="primary" size="small" @click="copyText(secretData.appKey)">复制</el-button>
</el-descriptions-item>
<el-descriptions-item label="AppSecret">
<span style="word-break: break-all">{{ secretData.appSecret }}</span>
<el-button link type="primary" size="small" @click="copyText(secretData.appSecret)">复制</el-button>
</el-descriptions-item>
</el-descriptions>
<div style="margin-top: 8px; color: #e6a23c; font-size: 12px">AppSecret 仅在新增时明文返回一次,请妥善保存。</div>
<template #footer>
<el-button type="primary" @click="secretVisible = false">知道了</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getPartnerList, savePartner, updatePartner, deletePartner, getPartnerInfo, type PartnerInfo } from '@/api/manage/partner'
interface DataForm {
name: string
status: string
}
const dataForm = reactive<DataForm>({ name: '', status: '' })
const dataList = ref<PartnerInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
const addOrUpdateVisible = ref<boolean>(false)
const addOrUpdateTitle = ref<string>('')
const addOrUpdateFormRef = ref<FormInstance>()
interface AddOrUpdateForm {
id?: number
name: string
rateLimit: number
status: 'ACTIVE' | 'DISABLED'
remark: string
}
const addOrUpdateForm = reactive<AddOrUpdateForm>({
name: '',
rateLimit: 0,
status: 'ACTIVE',
remark: ''
})
const addOrUpdateRules: FormRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }]
}
const secretVisible = ref<boolean>(false)
const secretData = reactive<{ name: string; appKey: string; appSecret: string }>({ name: '', appKey: '', appSecret: '' })
async function getDataList(): Promise<void> {
dataListLoading.value = true
try {
const res = await getPartnerList({
page: pageIndex.value,
limit: pageSize.value,
name: dataForm.name,
status: dataForm.status
})
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle(val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle(val: number): void {
pageIndex.value = val
getDataList()
}
function resetForm(): void {
addOrUpdateForm.id = undefined
addOrUpdateForm.name = ''
addOrUpdateForm.rateLimit = 0
addOrUpdateForm.status = 'ACTIVE'
addOrUpdateForm.remark = ''
addOrUpdateFormRef.value?.clearValidate()
}
function addHandle(): void {
resetForm()
addOrUpdateTitle.value = '新增合作伙伴'
addOrUpdateVisible.value = true
}
function addOrUpdateHandle(row: PartnerInfo): void {
resetForm()
addOrUpdateTitle.value = '修改合作伙伴'
addOrUpdateForm.id = row.id
addOrUpdateForm.name = row.name
addOrUpdateForm.rateLimit = row.rateLimit ?? 0
addOrUpdateForm.status = row.status
addOrUpdateForm.remark = row.remark || ''
addOrUpdateVisible.value = true
}
async function submitForm(): Promise<void> {
if (!addOrUpdateFormRef.value) return
await addOrUpdateFormRef.value.validate(async (valid) => {
if (!valid) return
const payload = {
id: addOrUpdateForm.id,
name: addOrUpdateForm.name,
rateLimit: addOrUpdateForm.rateLimit,
status: addOrUpdateForm.status,
remark: addOrUpdateForm.remark
}
if (addOrUpdateForm.id) {
const res = await updatePartner(payload)
if (res && res.code === 0) {
ElMessage.success({ message: '操作成功', duration: 1500, onClose: () => { addOrUpdateVisible.value = false; getDataList() } })
} else {
ElMessage.error(res?.msg || '操作失败')
}
} else {
// 新增:后端自动生成 appKey/appSecret,返回在 data 中
const res = await savePartner(payload)
if (res && res.code === 0) {
addOrUpdateVisible.value = false
getDataList()
const d = (res as any).data
if (d && (d.appKey || d.appSecret)) {
secretData.name = d.name || addOrUpdateForm.name
secretData.appKey = d.appKey || ''
secretData.appSecret = d.appSecret || ''
secretVisible.value = true
} else {
ElMessage.success('新增成功')
}
} else {
ElMessage.error(res?.msg || '操作失败')
}
}
})
}
async function viewSecretHandle(row: PartnerInfo): Promise<void> {
const res = await getPartnerInfo(row.id)
if (res && res.code === 0) {
const d = (res as any).data || {}
secretData.name = d.name || row.name
secretData.appKey = d.appKey || row.appKey || ''
secretData.appSecret = d.appSecret || ''
secretVisible.value = true
} else {
ElMessage.error(res?.msg || '查询失败')
}
}
function deleteHandle(id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deletePartner([id]).then((data) => {
if (data && data.code === 0) {
ElMessage.success({ message: '操作成功', duration: 1500, onClose: () => getDataList() })
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
}).catch(() => {})
}
function copyText(text: string): void {
if (!text) return
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制')).catch(() => ElMessage.warning('复制失败,请手动复制'))
} else {
ElMessage.warning('复制失败,请手动复制')
}
}
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.mod-partner {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<div class="mod-pay-config">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<org-tree-select v-model="dataForm.partnerId" placeholder="合作商" width="180px" />
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="状态" clearable style="width: 120px;">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="addHandle()">新增</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%;">
<el-table-column type="index" header-align="center" align="center" width="50" label="序号"></el-table-column>
<el-table-column prop="partnerId" header-align="center" align="center" label="合作商"></el-table-column>
<el-table-column prop="appId" header-align="center" align="center" label="应用ID"></el-table-column>
<el-table-column prop="mchId" header-align="center" align="center" label="商户号"></el-table-column>
<el-table-column prop="certSerialNo" header-align="center" align="center" label="证书序列号" show-overflow-tooltip></el-table-column>
<el-table-column prop="notifyUrl" header-align="center" align="center" label="回调地址" show-overflow-tooltip></el-table-column>
<el-table-column prop="status" header-align="center" align="center" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else-if="scope.row.status === 'INACTIVE'" size="small" type="danger">禁用</el-tag>
<el-tag v-else size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" header-align="center" align="center" label="备注" show-overflow-tooltip></el-table-column>
<el-table-column fixed="right" header-align="center" align="center" width="150" label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="editHandle(scope.row)">修改</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<el-dialog v-model="addOrUpdateVisible" :title="addOrUpdateTitle" width="500px">
<el-form :model="addOrUpdateForm" :rules="addOrUpdateRules" ref="addOrUpdateFormRef" label-width="120px">
<el-form-item label="合作商" prop="partnerId">
<org-tree-select v-model="addOrUpdateForm.partnerId" placeholder="请选择合作商" />
</el-form-item>
<el-form-item label="应用ID" prop="appId">
<el-input v-model="addOrUpdateForm.appId" placeholder="请输入应用ID"></el-input>
</el-form-item>
<el-form-item label="商户号" prop="mchId">
<el-input v-model="addOrUpdateForm.mchId" placeholder="请输入商户号"></el-input>
</el-form-item>
<el-form-item label="API V3密钥" prop="apiV3Key">
<el-input v-model="addOrUpdateForm.apiV3Key" placeholder="请输入API V3密钥"></el-input>
</el-form-item>
<el-form-item label="证书序列号" prop="certSerialNo">
<el-input v-model="addOrUpdateForm.certSerialNo" placeholder="请输入证书序列号"></el-input>
</el-form-item>
<el-form-item label="私钥路径" prop="privateKeyPath">
<el-input v-model="addOrUpdateForm.privateKeyPath" placeholder="请输入私钥路径"></el-input>
</el-form-item>
<el-form-item label="回调地址" prop="notifyUrl">
<el-input v-model="addOrUpdateForm.notifyUrl" placeholder="请输入回调地址"></el-input>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch
v-model="addOrUpdateForm.status"
active-value="ACTIVE"
inactive-value="INACTIVE"
active-text="启用"
inactive-text="禁用">
</el-switch>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="addOrUpdateForm.remark" placeholder="请输入备注"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addOrUpdateVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm()">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getPayConfigList, saveOrUpdatePayConfig, deletePayConfig, type PayConfigInfo } from '@/api/manage/payConfig'
const statusOptions = [
{ label: '启用', value: 'ACTIVE' },
{ label: '禁用', value: 'INACTIVE' }
]
const dataForm = reactive<{
partnerId: string | number
status: string
}>({
partnerId: '',
status: ''
})
const dataList = ref<PayConfigInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
const addOrUpdateVisible = ref<boolean>(false)
const addOrUpdateTitle = ref<string>('')
const addOrUpdateFormRef = ref<FormInstance>()
interface AddOrUpdateForm {
id: number | null
partnerId: number | null
appId: string
mchId: string
apiV3Key: string
certSerialNo: string
privateKeyPath: string
notifyUrl: string
status: string
remark: string
}
const addOrUpdateForm = reactive<AddOrUpdateForm>({
id: null,
partnerId: null,
appId: '',
mchId: '',
apiV3Key: '',
certSerialNo: '',
privateKeyPath: '',
notifyUrl: '',
status: 'ACTIVE',
remark: ''
})
const addOrUpdateRules: FormRules = {
partnerId: [{ required: true, message: '请选择合作商', trigger: 'change' }],
appId: [{ required: true, message: '应用ID不能为空', trigger: 'blur' }],
mchId: [{ required: true, message: '商户号不能为空', trigger: 'blur' }],
apiV3Key: [{ required: true, message: 'API V3密钥不能为空', trigger: 'blur' }],
notifyUrl: [{ required: true, message: '回调地址不能为空', trigger: 'blur' }]
}
function getDataList (): void {
dataListLoading.value = true
getPayConfigList({
page: pageIndex.value,
limit: pageSize.value,
partnerId: dataForm.partnerId ? Number(dataForm.partnerId) : undefined,
status: dataForm.status
}).then((res) => {
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
dataListLoading.value = false
}).catch(() => {
dataListLoading.value = false
})
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function resetForm (): void {
addOrUpdateForm.id = null
addOrUpdateForm.partnerId = null
addOrUpdateForm.appId = ''
addOrUpdateForm.mchId = ''
addOrUpdateForm.apiV3Key = ''
addOrUpdateForm.certSerialNo = ''
addOrUpdateForm.privateKeyPath = ''
addOrUpdateForm.notifyUrl = ''
addOrUpdateForm.status = 'ACTIVE'
addOrUpdateForm.remark = ''
}
function addHandle (): void {
resetForm()
addOrUpdateTitle.value = '新增'
addOrUpdateVisible.value = true
addOrUpdateFormRef.value?.clearValidate()
}
function editHandle (row: PayConfigInfo): void {
resetForm()
addOrUpdateTitle.value = '修改'
addOrUpdateVisible.value = true
addOrUpdateForm.id = row.id
addOrUpdateForm.partnerId = row.partnerId
addOrUpdateForm.appId = row.appId
addOrUpdateForm.mchId = row.mchId
addOrUpdateForm.apiV3Key = row.apiV3Key
addOrUpdateForm.certSerialNo = row.certSerialNo
addOrUpdateForm.privateKeyPath = row.privateKeyPath
addOrUpdateForm.notifyUrl = row.notifyUrl
addOrUpdateForm.status = row.status
addOrUpdateForm.remark = row.remark
}
function submitForm (): void {
addOrUpdateFormRef.value?.validate((valid) => {
if (valid) {
saveOrUpdatePayConfig({
id: addOrUpdateForm.id || undefined,
partnerId: addOrUpdateForm.partnerId ?? undefined,
appId: addOrUpdateForm.appId,
mchId: addOrUpdateForm.mchId,
apiV3Key: addOrUpdateForm.apiV3Key,
certSerialNo: addOrUpdateForm.certSerialNo,
privateKeyPath: addOrUpdateForm.privateKeyPath,
notifyUrl: addOrUpdateForm.notifyUrl,
status: addOrUpdateForm.status,
remark: addOrUpdateForm.remark
}).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
addOrUpdateVisible.value = false
getDataList()
}
})
} else {
ElMessage.error(res.msg)
}
})
}
})
}
function deleteHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deletePayConfig([id]).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
}
})
} else {
ElMessage.error(res.msg)
}
})
}).catch(() => {})
}
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.mod-pay-config {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,391 @@
<template>
<div class="mod-price">
<!-- 搜索栏 -->
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-select v-model="dataForm.status" placeholder="状态" clearable style="width: 120px;">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<org-tree-select v-model="dataForm.channelOrgId" placeholder="渠道" :hide-node-types="['PLATFORM', 'SERVICE_PROVIDER', 'STORE']" width="160px" />
</el-form-item>
<el-form-item>
<org-tree-select v-model="dataForm.storeOrgId" placeholder="门店" :hide-node-types="['PLATFORM', 'CHANNEL', 'SERVICE_PROVIDER']" width="160px" />
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="addHandle()">新增</el-button>
</el-form-item>
<el-form-item>
<el-button @click="effectiveVisible = true">生效价查询</el-button>
</el-form-item>
</el-form>
<!-- 列表 + 分页 -->
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%;">
<el-table-column type="index" header-align="center" align="center" width="50" label="序号"></el-table-column>
<el-table-column prop="name" header-align="center" align="center" min-width="120" label="策略名称"></el-table-column>
<el-table-column header-align="center" align="center" width="100" label="层级">
<template #default="scope">
<el-tag v-if="scope.row.storeOrgId" size="small" type="danger">门店</el-tag>
<el-tag v-else-if="scope.row.channelOrgId" size="small" type="warning">渠道</el-tag>
<el-tag v-else size="small" type="success">平台默认</el-tag>
</template>
</el-table-column>
<el-table-column prop="price" header-align="center" align="center" label="价格">
<template #default="scope">
¥{{ Number(scope.row.price || 0).toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="durationHours" header-align="center" align="center" label="时长">
<template #default="scope">
{{ scope.row.durationHours }}小时
</template>
</el-table-column>
<el-table-column header-align="center" align="center" label="渠道">
<template #default="scope">
{{ scope.row.channelOrgId ? scope.row.channelOrgId : '全部' }}
</template>
</el-table-column>
<el-table-column header-align="center" align="center" label="门店">
<template #default="scope">
{{ scope.row.storeOrgId ? scope.row.storeOrgId : '全部' }}
</template>
</el-table-column>
<el-table-column prop="status" header-align="center" align="center" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else-if="scope.row.status === 'INACTIVE'" size="small" type="danger">禁用</el-tag>
<el-tag v-else size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="180" label="创建时间"></el-table-column>
<el-table-column fixed="right" header-align="center" align="center" width="150" label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="editHandle(scope.row)">编辑</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 编辑弹窗 -->
<el-dialog v-model="addOrUpdateVisible" :title="addOrUpdateTitle" width="520px">
<el-form :model="addOrUpdateForm" :rules="addOrUpdateRules" ref="addOrUpdateFormRef" label-width="120px">
<el-form-item label="策略名称" prop="name">
<el-input v-model="addOrUpdateForm.name" placeholder="请输入策略名称"></el-input>
</el-form-item>
<el-form-item label="定价层级" prop="priceLevel">
<el-radio-group v-model="addOrUpdateForm.priceLevel" @change="onLevelChange">
<el-radio value="platform">平台默认</el-radio>
<el-radio value="channel">渠道</el-radio>
<el-radio value="store">门店</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="渠道" prop="channelOrgId" v-if="addOrUpdateForm.priceLevel !== 'platform'">
<org-tree-select v-model="addOrUpdateForm.channelOrgId" placeholder="请选择渠道" :hide-node-types="['PLATFORM', 'SERVICE_PROVIDER', 'STORE']" />
</el-form-item>
<el-form-item label="门店" prop="storeOrgId" v-if="addOrUpdateForm.priceLevel === 'store'">
<org-tree-select v-model="addOrUpdateForm.storeOrgId" placeholder="请选择门店" :hide-node-types="['PLATFORM', 'CHANNEL', 'SERVICE_PROVIDER']" />
</el-form-item>
<el-form-item label="价格(元)" prop="price">
<el-input-number v-model="addOrUpdateForm.price" :min="0.01" :precision="2" :step="0.01" controls-position="right" style="width: 100%;"></el-input-number>
</el-form-item>
<el-form-item label="时长(小时)" prop="durationHours">
<el-input-number v-model="addOrUpdateForm.durationHours" :min="1" :precision="0" :step="1" controls-position="right" style="width: 100%;"></el-input-number>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="addOrUpdateForm.status" style="width: 100%;">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addOrUpdateVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm()">确定</el-button>
</template>
</el-dialog>
<!-- 生效价查询弹窗 -->
<el-dialog v-model="effectiveVisible" title="生效价查询" width="480px">
<el-form label-width="100px">
<el-form-item label="渠道">
<org-tree-select v-model="effectiveForm.channelOrgId" placeholder="选择渠道" :hide-node-types="['PLATFORM', 'SERVICE_PROVIDER', 'STORE']" />
</el-form-item>
<el-form-item label="门店">
<org-tree-select v-model="effectiveForm.storeOrgId" placeholder="选择门店" :hide-node-types="['PLATFORM', 'CHANNEL', 'SERVICE_PROVIDER']" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="queryEffective" :loading="effectiveLoading">查询</el-button>
</el-form-item>
</el-form>
<el-descriptions :column="1" border v-if="effectiveResult" style="margin-top: 12px;">
<el-descriptions-item label="策略名称">{{ effectiveResult.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="层级">
<el-tag v-if="effectiveResult.storeOrgId" size="small" type="danger">门店</el-tag>
<el-tag v-else-if="effectiveResult.channelOrgId" size="small" type="warning">渠道</el-tag>
<el-tag v-else size="small" type="success">平台默认</el-tag>
</el-descriptions-item>
<el-descriptions-item label="价格">¥{{ Number(effectiveResult.price || 0).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="时长">{{ effectiveResult.durationHours }}小时</el-descriptions-item>
</el-descriptions>
<el-empty v-else-if="effectiveQueried" description="无生效中的定价策略" />
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getPriceList, saveOrUpdatePrice, deletePrice, getEffectivePrice, type PriceConfigInfo } from '@/api/manage/price'
import OrgTreeSelect from '@/components/org-tree-select/index.vue'
// ===================== 常量 =====================
const statusOptions = [
{ label: '启用', value: 'ACTIVE' },
{ label: '禁用', value: 'INACTIVE' }
]
// ===================== 列表 =====================
const dataForm = reactive<{
status: string
channelOrgId: string | number
storeOrgId: string | number
}>({
status: '',
channelOrgId: '',
storeOrgId: ''
})
const dataList = ref<PriceConfigInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
function getDataList (): void {
dataListLoading.value = true
getPriceList({
page: pageIndex.value,
limit: pageSize.value,
status: dataForm.status || undefined,
channelOrgId: dataForm.channelOrgId ? Number(dataForm.channelOrgId) : undefined,
storeOrgId: dataForm.storeOrgId ? Number(dataForm.storeOrgId) : undefined
}).then((res) => {
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
dataListLoading.value = false
}).catch(() => {
dataListLoading.value = false
ElMessage.error('获取列表失败')
})
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
// ===================== 编辑弹窗 =====================
const addOrUpdateVisible = ref<boolean>(false)
const addOrUpdateTitle = ref<string>('')
const addOrUpdateFormRef = ref<FormInstance>()
interface AddOrUpdateForm {
id: number | null
name: string
priceLevel: string
channelOrgId: number | null
storeOrgId: number | null
price: number
durationHours: number
status: string
}
const addOrUpdateForm = reactive<AddOrUpdateForm>({
id: null,
name: '',
priceLevel: 'platform',
channelOrgId: null,
storeOrgId: null,
price: 0,
durationHours: 1,
status: 'ACTIVE'
})
const addOrUpdateRules: FormRules = {
name: [{ required: true, message: '策略名称不能为空', trigger: 'blur' }],
price: [{ required: true, message: '价格不能为空', trigger: 'blur' }],
durationHours: [{ required: true, message: '时长不能为空', trigger: 'blur' }]
}
function onLevelChange (val: string): void {
if (val === 'platform') {
addOrUpdateForm.channelOrgId = null
addOrUpdateForm.storeOrgId = null
} else if (val === 'channel') {
addOrUpdateForm.storeOrgId = null
}
}
function resetForm (): void {
addOrUpdateForm.id = null
addOrUpdateForm.name = ''
addOrUpdateForm.priceLevel = 'platform'
addOrUpdateForm.channelOrgId = null
addOrUpdateForm.storeOrgId = null
addOrUpdateForm.price = 0
addOrUpdateForm.durationHours = 1
addOrUpdateForm.status = 'ACTIVE'
}
function addHandle (): void {
resetForm()
addOrUpdateTitle.value = '新增'
addOrUpdateVisible.value = true
addOrUpdateFormRef.value?.clearValidate()
}
function editHandle (row: PriceConfigInfo): void {
resetForm()
addOrUpdateTitle.value = '修改'
addOrUpdateVisible.value = true
addOrUpdateForm.id = row.id
addOrUpdateForm.name = row.name
addOrUpdateForm.channelOrgId = row.channelOrgId
addOrUpdateForm.storeOrgId = row.storeOrgId
addOrUpdateForm.price = row.price
addOrUpdateForm.durationHours = row.durationHours
addOrUpdateForm.status = row.status
if (row.storeOrgId) {
addOrUpdateForm.priceLevel = 'store'
} else if (row.channelOrgId) {
addOrUpdateForm.priceLevel = 'channel'
} else {
addOrUpdateForm.priceLevel = 'platform'
}
}
function submitForm (): void {
addOrUpdateFormRef.value?.validate((valid) => {
if (!valid) return
saveOrUpdatePrice({
id: addOrUpdateForm.id || undefined,
name: addOrUpdateForm.name,
channelOrgId: addOrUpdateForm.priceLevel !== 'platform' ? (addOrUpdateForm.channelOrgId || undefined) : undefined,
storeOrgId: addOrUpdateForm.priceLevel === 'store' ? (addOrUpdateForm.storeOrgId || undefined) : undefined,
price: addOrUpdateForm.price,
durationHours: addOrUpdateForm.durationHours,
status: addOrUpdateForm.status
}).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
addOrUpdateVisible.value = false
getDataList()
}
})
} else {
ElMessage.error(res.msg)
}
}).catch(() => {
ElMessage.error('操作失败')
})
})
}
function deleteHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deletePrice([id]).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
}
})
} else {
ElMessage.error(res.msg)
}
}).catch(() => {
ElMessage.error('删除失败')
})
}).catch(() => {})
}
// ===================== 生效价查询 =====================
const effectiveVisible = ref<boolean>(false)
const effectiveLoading = ref<boolean>(false)
const effectiveQueried = ref<boolean>(false)
const effectiveResult = ref<PriceConfigInfo | null>(null)
const effectiveForm = reactive<{ channelOrgId: number | null; storeOrgId: number | null }>({
channelOrgId: null,
storeOrgId: null
})
function queryEffective (): void {
effectiveLoading.value = true
effectiveResult.value = null
effectiveQueried.value = false
getEffectivePrice({
channelOrgId: effectiveForm.channelOrgId || undefined,
storeOrgId: effectiveForm.storeOrgId || undefined
}).then((res) => {
if (res && res.code === 0) {
effectiveResult.value = res.data?.config || null
}
effectiveQueried.value = true
effectiveLoading.value = false
}).catch(() => {
effectiveLoading.value = false
effectiveQueried.value = true
ElMessage.error('查询失败')
})
}
// ===================== 初始化 =====================
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.mod-price {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,246 @@
<template>
<div class="mod-profit-receiver">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<org-tree-select v-model="dataForm.orgId" placeholder="组织" width="200px" />
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.bindStatus" placeholder="绑定状态" clearable style="width: 120px;">
<el-option v-for="item in bindStatusOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="addHandle()">新增</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%;">
<el-table-column type="index" header-align="center" align="center" width="50" label="序号"></el-table-column>
<el-table-column prop="orgName" header-align="center" align="center" label="关联组织"></el-table-column>
<el-table-column prop="orgId" header-align="center" align="center" label="组织"></el-table-column>
<el-table-column prop="receiverType" header-align="center" align="center" label="接收方类型">
<template #default="scope">
<el-tag v-if="scope.row.receiverType === 'MERCHANT_ID'" size="small" type="warning">商户号</el-tag>
<el-tag v-else-if="scope.row.receiverType === 'PERSONAL_OPENID'" size="small" type="success">个人openid</el-tag>
<el-tag v-else size="small">{{ scope.row.receiverType }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="receiverAccount" header-align="center" align="center" label="收款账号"></el-table-column>
<el-table-column prop="receiverName" header-align="center" align="center" label="收款方名称"></el-table-column>
<el-table-column prop="bindStatus" header-align="center" align="center" label="绑定状态">
<template #default="scope">
<el-tag v-if="scope.row.bindStatus === 'PENDING'" size="small" type="warning">待确认</el-tag>
<el-tag v-else-if="scope.row.bindStatus === 'ACTIVE'" size="small" type="success">已绑定</el-tag>
<el-tag v-else-if="scope.row.bindStatus === 'FAILED'" size="small" type="danger">绑定失败</el-tag>
<el-tag v-else size="small">{{ scope.row.bindStatus }}</el-tag>
</template>
</el-table-column>
<el-table-column fixed="right" header-align="center" align="center" width="100" label="操作">
<template #default="scope">
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 新增弹窗 -->
<el-dialog v-model="addVisible" title="新增分账接收方" width="500px">
<el-form :model="addForm" :rules="addRules" ref="addFormRef" label-width="120px">
<el-form-item label="关联组织" prop="orgId">
<org-tree-select v-model="addForm.orgId" placeholder="请选择关联组织" />
</el-form-item>
<el-form-item label="接收方类型" prop="receiverType">
<el-select v-model="addForm.receiverType" placeholder="请选择接收方类型" style="width: 100%;">
<el-option label="商户号" value="MERCHANT_ID"></el-option>
<el-option label="个人openid" value="PERSONAL_OPENID"></el-option>
</el-select>
</el-form-item>
<el-form-item label="接收方账号" prop="receiverAccount">
<el-input v-model="addForm.receiverAccount" :placeholder="accountPlaceholder"></el-input>
</el-form-item>
<el-form-item label="收款方名称" prop="receiverName">
<el-input v-model="addForm.receiverName" placeholder="请输入收款方名称"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm()">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
getProfitReceiverList,
saveProfitReceiver,
deleteProfitReceiver,
type ProfitReceiver,
type ProfitReceiverSaveBody
} from '@/api/manage/profitReceiver'
const bindStatusOptions = [
{ label: '待确认', value: 'PENDING' },
{ label: '已绑定', value: 'ACTIVE' },
{ label: '绑定失败', value: 'FAILED' }
]
const dataForm = reactive<{
orgId: string | number
bindStatus: string
}>({
orgId: '',
bindStatus: ''
})
const dataList = ref<ProfitReceiver[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
const addVisible = ref<boolean>(false)
const addFormRef = ref<FormInstance>()
interface AddForm {
orgId: number | null
receiverType: 'MERCHANT_ID' | 'PERSONAL_OPENID' | ''
receiverAccount: string
receiverName: string
}
const addForm = reactive<AddForm>({
orgId: null,
receiverType: '',
receiverAccount: '',
receiverName: ''
})
const accountPlaceholder = computed<string>(() => {
if (addForm.receiverType === 'MERCHANT_ID') return '请输入商户号'
if (addForm.receiverType === 'PERSONAL_OPENID') return '请输入openid'
return '请输入接收方账号'
})
const addRules: FormRules = {
orgId: [{ required: true, message: '请选择关联组织', trigger: 'change' }],
receiverType: [{ required: true, message: '请选择接收方类型', trigger: 'change' }],
receiverAccount: [{ required: true, message: '请输入接收方账号', trigger: 'blur' }],
receiverName: [{ required: true, message: '请输入收款方名称', trigger: 'blur' }]
}
function getDataList (): void {
dataListLoading.value = true
getProfitReceiverList({
page: pageIndex.value,
limit: pageSize.value,
orgId: dataForm.orgId ? Number(dataForm.orgId) : undefined,
bindStatus: dataForm.bindStatus
}).then((res) => {
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
dataListLoading.value = false
}).catch(() => {
dataListLoading.value = false
})
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function resetForm (): void {
addForm.orgId = null
addForm.receiverType = ''
addForm.receiverAccount = ''
addForm.receiverName = ''
addFormRef.value?.clearValidate()
}
function addHandle (): void {
resetForm()
addVisible.value = true
}
function submitForm (): void {
addFormRef.value?.validate((valid) => {
if (!valid) return
const saveBody: ProfitReceiverSaveBody = {
orgId: addForm.orgId ?? 0,
receiverType: addForm.receiverType as 'MERCHANT_ID' | 'PERSONAL_OPENID',
receiverAccount: addForm.receiverAccount,
receiverName: addForm.receiverName
}
saveProfitReceiver(saveBody).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
addVisible.value = false
getDataList()
}
})
} else {
ElMessage.error(res.msg)
}
})
})
}
function deleteHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteProfitReceiver(id).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
}
})
} else {
ElMessage.error(res.msg)
}
})
}).catch(() => {})
}
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.mod-profit-receiver {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,407 @@
<template>
<div class="mod-profit-rule">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-select v-model="dataForm.scopeType" placeholder="适用范围" clearable style="width: 140px;">
<el-option label="全局" value="GLOBAL"></el-option>
<el-option label="渠道" value="CHANNEL"></el-option>
<el-option label="代理商" value="AGENT"></el-option>
<el-option label="门店" value="STORE"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="addHandle()">新增</el-button>
</el-form-item>
<el-form-item>
<el-button type="success" @click="previewHandle()">分润试算</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%;">
<el-table-column type="index" header-align="center" align="center" width="60" label="序号"></el-table-column>
<el-table-column prop="ruleName" header-align="center" align="center" min-width="160" label="规则名称"></el-table-column>
<el-table-column prop="scopeType" header-align="center" align="center" label="适用范围">
<template #default="scope">{{ getScopeLabel(scope.row.scopeType) }}</template>
</el-table-column>
<el-table-column prop="scopeOrgName" header-align="center" align="center" label="适用组织">
<template #default="scope">{{ scope.row.scopeOrgName || '-' }}</template>
</el-table-column>
<el-table-column prop="priority" header-align="center" align="center" label="优先级"></el-table-column>
<el-table-column prop="status" header-align="center" align="center" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else size="small" type="info">停用</el-tag>
</template>
</el-table-column>
<el-table-column prop="effectiveFrom" header-align="center" align="center" label="生效时间">
<template #default="scope">
{{ scope.row.effectiveFrom || '-' }} ~ {{ scope.row.effectiveTo || '长期' }}
</template>
</el-table-column>
<el-table-column fixed="right" header-align="center" align="center" width="160" label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="editHandle(scope.row)">修改</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 新增/修改弹窗 -->
<el-dialog v-model="addVisible" :title="addTitle" width="780px">
<el-form :model="addForm" :rules="addRules" ref="addFormRef" label-width="100px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="规则名称" prop="ruleName">
<el-input v-model="addForm.ruleName" placeholder="规则名称"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="适用范围" prop="scopeType">
<el-select v-model="addForm.scopeType" placeholder="适用范围" style="width: 100%;">
<el-option label="全局" value="GLOBAL"></el-option>
<el-option label="渠道" value="CHANNEL"></el-option>
<el-option label="代理商" value="AGENT"></el-option>
<el-option label="门店" value="STORE"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="适用组织" prop="scopeOrgId">
<org-tree-select v-model="addForm.scopeOrgId" placeholder="GLOBAL 时留空" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="优先级" prop="priority">
<el-input-number v-model="addForm.priority" :min="0" controls-position="right" style="width: 100%;"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-select v-model="addForm.status" style="width: 100%;">
<el-option label="启用" value="ACTIVE"></el-option>
<el-option label="停用" value="INACTIVE"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="生效起" prop="effectiveFrom">
<el-date-picker v-model="addForm.effectiveFrom" type="date" value-format="YYYY-MM-DD" placeholder="生效起" style="width: 100%;"></el-date-picker>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="生效止" prop="effectiveTo">
<el-date-picker v-model="addForm.effectiveTo" type="date" value-format="YYYY-MM-DD" placeholder="留空=长期" style="width: 100%;"></el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="分润明细">
<el-table :data="addForm.items" border size="small" style="width: 100%;">
<el-table-column label="角色" width="120">
<template #default="{ row }">
<el-select v-model="row.orgRole" size="small">
<el-option label="平台" value="PLATFORM"></el-option>
<el-option label="渠道" value="CHANNEL"></el-option>
<el-option label="代理商" value="AGENT"></el-option>
<el-option label="门店" value="STORE"></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="分润方式" width="120">
<template #default="{ row }">
<el-select v-model="row.shareType" size="small">
<el-option label="比例(%)" value="RATIO"></el-option>
<el-option label="固定(分)" value="FIXED"></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="数值" width="120">
<template #default="{ row }">
<el-input-number v-model="row.shareValue" :min="0" :controls="false" size="small" style="width: 100%;"></el-input-number>
</template>
</el-table-column>
<el-table-column label="排序" width="100">
<template #default="{ row }">
<el-input-number v-model="row.sortOrder" :min="0" :controls="false" size="small" style="width: 100%;"></el-input-number>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeItem($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-button size="small" type="primary" plain style="margin-top: 8px;" @click="addItem">+ 添加明细</el-button>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addVisible = false">取消</el-button>
<el-button type="primary" :loading="saveLoading" @click="submitForm">确定</el-button>
</template>
</el-dialog>
<!-- 分润试算弹窗 -->
<el-dialog v-model="previewVisible" title="分润试算" width="640px">
<el-form :inline="true" :model="previewForm">
<el-form-item label="门店">
<org-tree-select v-model="previewForm.storeOrgId" placeholder="请选择门店" :hide-node-types="['PLATFORM', 'CHANNEL', 'SERVICE_PROVIDER']" />
</el-form-item>
<el-form-item label="金额(元)">
<el-input-number v-model="previewForm.amount" :min="0" :precision="2" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="previewLoading" @click="doPreview">试算</el-button>
</el-form-item>
</el-form>
<el-table :data="previewResult.details" border size="small" style="width: 100%; margin-top: 12px;" v-if="previewResult">
<el-table-column prop="orgRole" label="角色"></el-table-column>
<el-table-column prop="orgName" label="组织"></el-table-column>
<el-table-column prop="shareType" label="方式">
<template #default="{ row }">{{ row.shareType === 'RATIO' ? '比例' : '固定' }}</template>
</el-table-column>
<el-table-column prop="shareValue" label="数值"></el-table-column>
<el-table-column prop="amount" label="金额(分)"></el-table-column>
</el-table>
<el-alert v-if="previewResult?.warning" :title="previewResult.warning" type="warning" :closable="false" style="margin-top: 12px;" />
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
getProfitRuleList,
getProfitRuleInfo,
saveOrUpdateProfitRule,
deleteProfitRule,
previewProfitRule,
type ProfitRule,
type ProfitRuleItem,
type ProfitRuleSaveBody
} from '@/api/manage/profitRule'
const dataForm = reactive<{ scopeType: string }>({ scopeType: '' })
const dataList = ref<ProfitRule[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
function getScopeLabel (t: string): string {
return ({ GLOBAL: '全局', CHANNEL: '渠道', AGENT: '代理商', STORE: '门店' } as Record<string, string>)[t] || t
}
function getDataList (): void {
dataListLoading.value = true
getProfitRuleList({
page: pageIndex.value,
limit: pageSize.value,
scopeType: dataForm.scopeType
}).then((res) => {
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
dataListLoading.value = false
}).catch(() => { dataListLoading.value = false })
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
// 新增/修改
const addVisible = ref<boolean>(false)
const addTitle = ref<string>('')
const addFormRef = ref<FormInstance>()
const saveLoading = ref<boolean>(false)
interface ItemRow {
orgRole: 'PLATFORM' | 'CHANNEL' | 'AGENT' | 'STORE'
shareType: 'RATIO' | 'FIXED'
shareValue: number
sortOrder: number
}
interface AddForm {
id?: number
ruleName: string
scopeType: 'GLOBAL' | 'CHANNEL' | 'AGENT' | 'STORE' | ''
scopeOrgId: number | null
priority: number
status: 'ACTIVE' | 'INACTIVE' | ''
effectiveFrom: string | null
effectiveTo: string | null
items: ItemRow[]
}
const addForm = reactive<AddForm>({
ruleName: '',
scopeType: '',
scopeOrgId: null,
priority: 0,
status: 'ACTIVE',
effectiveFrom: null,
effectiveTo: null,
items: []
})
const addRules: FormRules = {
ruleName: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
scopeType: [{ required: true, message: '请选择适用范围', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
function resetForm (): void {
addForm.id = undefined
addForm.ruleName = ''
addForm.scopeType = ''
addForm.scopeOrgId = null
addForm.priority = 0
addForm.status = 'ACTIVE'
addForm.effectiveFrom = null
addForm.effectiveTo = null
addForm.items = []
addFormRef.value?.clearValidate()
}
function addItem (): void {
addForm.items.push({ orgRole: 'PLATFORM', shareType: 'RATIO', shareValue: 0, sortOrder: addForm.items.length })
}
function removeItem (idx: number): void {
addForm.items.splice(idx, 1)
}
function addHandle (): void {
resetForm()
addTitle.value = '新增分润规则'
addVisible.value = true
}
function editHandle (row: ProfitRule): void {
resetForm()
addTitle.value = '修改分润规则'
addForm.id = row.id
addForm.ruleName = row.ruleName
addForm.scopeType = row.scopeType
addForm.scopeOrgId = row.scopeOrgId
addForm.priority = row.priority
addForm.status = row.status
addForm.effectiveFrom = row.effectiveFrom
addForm.effectiveTo = row.effectiveTo
// 拉取详情获取 items
getProfitRuleInfo(row.id).then((res) => {
const detail = res && res.data?.rule
if (res && res.code === 0 && detail) {
addForm.items = (detail.items || []).map((it: ProfitRuleItem) => ({
orgRole: it.orgRole,
shareType: it.shareType,
shareValue: it.shareValue,
sortOrder: it.sortOrder
}))
}
addVisible.value = true
})
}
function submitForm (): void {
addFormRef.value?.validate((valid) => {
if (!valid) return
if (addForm.scopeType === 'GLOBAL') addForm.scopeOrgId = null
const body: ProfitRuleSaveBody = {
id: addForm.id,
ruleName: addForm.ruleName,
scopeType: addForm.scopeType as 'GLOBAL' | 'CHANNEL' | 'AGENT' | 'STORE',
scopeOrgId: addForm.scopeOrgId,
priority: addForm.priority,
status: addForm.status as 'ACTIVE' | 'INACTIVE',
effectiveFrom: addForm.effectiveFrom,
effectiveTo: addForm.effectiveTo,
items: addForm.items
}
saveLoading.value = true
saveOrUpdateProfitRule(body).then((res) => {
if (res && res.code === 0) {
ElMessage.success({ message: '操作成功', duration: 1500, onClose: () => { addVisible.value = false; getDataList() } })
} else {
ElMessage.error(res?.msg || '操作失败')
}
saveLoading.value = false
}).catch(() => { saveLoading.value = false })
})
}
function deleteHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(() => {
deleteProfitRule(id).then((res) => {
if (res && res.code === 0) {
ElMessage.success({ message: '操作成功', duration: 1500, onClose: () => getDataList() })
} else {
ElMessage.error(res?.msg || '删除失败')
}
})
}).catch(() => {})
}
// 试算
const previewVisible = ref<boolean>(false)
const previewLoading = ref<boolean>(false)
const previewForm = reactive<{ storeOrgId: number | null; amount: number | null }>({ storeOrgId: null, amount: null })
const previewResult = ref<{ ruleName: string; details: any[]; warning: string | null } | null>(null)
function previewHandle (): void {
previewResult.value = null
previewVisible.value = true
}
function doPreview (): void {
if (!previewForm.storeOrgId || previewForm.amount == null) {
ElMessage.warning('请填写门店ID和金额')
return
}
previewLoading.value = true
previewProfitRule({ storeOrgId: previewForm.storeOrgId, amount: Math.round(previewForm.amount * 100) }).then((res) => {
if (res && res.code === 0) {
previewResult.value = {
ruleName: res.data?.ruleName,
details: res.data?.details || [],
warning: res.data?.warning
}
} else {
ElMessage.error(res?.msg || '试算失败')
}
previewLoading.value = false
}).catch(() => { previewLoading.value = false })
}
onMounted(() => { getDataList() })
</script>
<style scoped>
.mod-profit-rule {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,305 @@
<template>
<div class="mod-profit-sharing">
<!-- 统计概览 -->
<el-row :gutter="16" class="stats-row">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value">{{ stats.summary.totalCount }}</div>
<div class="stat-label">总分账笔数</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value">¥{{ formatFenToYuan(stats.summary.totalAmount) }}</div>
<div class="stat-label">分账总金额</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value" style="color: #67c23a;">{{ stats.summary.successCount }}</div>
<div class="stat-label">成功笔数</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value" style="color: #f56c6c;">{{ stats.summary.totalCount - stats.summary.successCount }}</div>
<div class="stat-label">失败笔数</div>
</div>
</el-card>
</el-col>
</el-row>
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.orderNo" placeholder="订单号" clearable style="width: 180px;"></el-input>
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="状态" clearable style="width: 120px;">
<el-option label="待分账" value="PENDING"></el-option>
<el-option label="成功" value="SUCCESS"></el-option>
<el-option label="失败" value="FAILED"></el-option>
<el-option label="部分成功" value="PARTIAL"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable>
</el-date-picker>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%;">
<el-table-column type="index" header-align="center" align="center" width="60" label="序号"></el-table-column>
<el-table-column prop="outOrderNo" header-align="center" align="center" min-width="180" label="分账单号" show-overflow-tooltip></el-table-column>
<el-table-column prop="payOrderNo" header-align="center" align="center" min-width="180" label="支付订单号" show-overflow-tooltip></el-table-column>
<el-table-column prop="totalAmount" header-align="center" align="center" label="订单金额">
<template #default="scope">¥{{ formatFenToYuan(scope.row.totalAmount) }}</template>
</el-table-column>
<el-table-column prop="status" header-align="center" align="center" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'PENDING'" size="small" type="info">待分账</el-tag>
<el-tag v-else-if="scope.row.status === 'SUCCESS'" size="small" type="success">成功</el-tag>
<el-tag v-else-if="scope.row.status === 'FAILED'" size="small" type="danger">失败</el-tag>
<el-tag v-else-if="scope.row.status === 'PARTIAL'" size="small" type="warning">部分成功</el-tag>
<el-tag v-else size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="ruleName" header-align="center" align="center" label="规则名称">
<template #default="scope">{{ scope.row.ruleName || '-' }}</template>
</el-table-column>
<el-table-column prop="storeName" header-align="center" align="center" label="门店">
<template #default="scope">{{ scope.row.storeName || '-' }}</template>
</el-table-column>
<el-table-column prop="channelName" header-align="center" align="center" label="渠道">
<template #default="scope">{{ scope.row.channelName || '-' }}</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="170" label="创建时间"></el-table-column>
<el-table-column fixed="right" header-align="center" align="center" width="120" label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="detailHandle(scope.row.id)">详情</el-button>
<el-button v-if="scope.row.status === 'FAILED' || scope.row.status === 'PARTIAL'" link type="warning" size="small" @click="retryHandle(scope.row.id)">重试</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="分账详情" width="760px" v-loading="detailLoading">
<el-descriptions :column="2" border v-if="detailData">
<el-descriptions-item label="分账单号">{{ detailData.outOrderNo }}</el-descriptions-item>
<el-descriptions-item label="支付订单号">{{ detailData.payOrderNo }}</el-descriptions-item>
<el-descriptions-item label="订单金额">¥{{ formatFenToYuan(detailData.totalAmount) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ getStatusLabel(detailData.status) }}</el-descriptions-item>
<el-descriptions-item label="规则名称">{{ detailData.ruleName || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detailData.createdAt || '-' }}</el-descriptions-item>
<el-descriptions-item label="组织链路" :span="2">{{ detailData.orgPathSnapshot || '-' }}</el-descriptions-item>
</el-descriptions>
<el-collapse style="margin-top: 12px;" v-if="detailData?.ruleSnapshot">
<el-collapse-item title="规则快照" name="rule">
<pre style="background: #f5f7fa; padding: 8px; border-radius: 4px; font-size: 12px; overflow-x: auto;">{{ formatRuleSnapshot(detailData.ruleSnapshot) }}</pre>
</el-collapse-item>
</el-collapse>
<div style="margin-top: 16px;" v-if="detailData?.details?.length">
<div style="margin-bottom: 8px; font-weight: bold;">分账明细</div>
<el-table :data="detailData.details" border size="small">
<el-table-column prop="orgRole" label="角色" width="80">
<template #default="{ row }">{{ getOrgRoleLabel(row.orgRole) }}</template>
</el-table-column>
<el-table-column prop="orgName" label="组织">
<template #default="{ row }">{{ row.orgName || '-' }}</template>
</el-table-column>
<el-table-column prop="receiverAccount" label="收款账号"></el-table-column>
<el-table-column prop="amount" label="分账金额" width="110">
<template #default="{ row }">¥{{ formatFenToYuan(row.amount) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag v-if="row.status === 'SUCCESS'" size="small" type="success">成功</el-tag>
<el-tag v-else-if="row.status === 'PENDING'" size="small" type="info">待处理</el-tag>
<el-tag v-else-if="row.status === 'FAILED'" size="small" type="danger">失败</el-tag>
<el-tag v-else size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="successTime" label="成功时间">
<template #default="{ row }">{{ row.successTime || '-' }}</template>
</el-table-column>
<el-table-column prop="failReason" label="失败原因">
<template #default="{ row }">{{ row.failReason || '-' }}</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button type="primary" @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getProfitSharingList,
getProfitSharingInfo,
getProfitSharingStats,
retryProfitSharing,
type ProfitSharingItem,
type ProfitSharingDetail,
type ProfitSharingStats
} from '@/api/manage/profitSharing'
const dataForm = reactive<{ orderNo: string; status: string }>({ orderNo: '', status: '' })
const dateRange = ref<[string, string] | []>([])
const dataList = ref<ProfitSharingItem[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
const stats = reactive<ProfitSharingStats>({
summary: { totalCount: 0, totalAmount: 0, successCount: 0, successAmount: 0 },
groups: []
})
const detailVisible = ref<boolean>(false)
const detailLoading = ref<boolean>(false)
const detailData = ref<ProfitSharingDetail | null>(null)
function formatFenToYuan (fen: number | null | undefined): string {
if (fen == null) return '0.00'
return (Number(fen) / 100).toFixed(2)
}
function getStatusLabel (status: string): string {
return ({ PENDING: '待分账', SUCCESS: '成功', FAILED: '失败', PARTIAL: '部分成功' } as Record<string, string>)[status] || status
}
function getOrgRoleLabel (role: string): string {
return ({ PLATFORM: '平台', CHANNEL: '渠道', AGENT: '代理商', STORE: '门店' } as Record<string, string>)[role] || role || '-'
}
function formatRuleSnapshot (snapshot: string | null | undefined): string {
if (!snapshot) return '-'
try { return JSON.stringify(JSON.parse(snapshot), null, 2) } catch { return snapshot }
}
function loadStats (): void {
getProfitSharingStats().then((res) => {
if (res && res.code === 0 && res.data?.stats) {
stats.summary = res.data.stats.summary
stats.groups = res.data.stats.groups || []
}
}).catch(() => {})
}
function getDataList (): void {
dataListLoading.value = true
const params: Record<string, any> = {
page: pageIndex.value,
limit: pageSize.value,
orderNo: dataForm.orderNo,
status: dataForm.status
}
if (dateRange.value && dateRange.value.length === 2) {
params.startDate = dateRange.value[0]
params.endDate = dateRange.value[1]
}
getProfitSharingList(params).then((res) => {
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
dataListLoading.value = false
}).catch(() => { dataListLoading.value = false })
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function detailHandle (id: number): void {
detailVisible.value = true
detailLoading.value = true
detailData.value = null
getProfitSharingInfo(id).then((res) => {
const detail = res && res.data?.sharing
if (res && res.code === 0 && detail) {
detailData.value = detail
} else {
ElMessage.error(res?.msg || '获取详情失败')
}
detailLoading.value = false
}).catch(() => { detailLoading.value = false })
}
function retryHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[重试分账]操作?`, '提示', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(() => {
retryProfitSharing(id).then((res) => {
if (res && res.code === 0) {
ElMessage.success({ message: '操作成功', duration: 1500, onClose: () => { getDataList(); loadStats() } })
} else {
ElMessage.error(res?.msg || '重试失败')
}
})
}).catch(() => {})
}
onMounted(() => { getDataList(); loadStats() })
</script>
<style scoped>
.mod-profit-sharing {
padding: 10px;
}
.stats-row {
margin-bottom: 16px;
}
.stat-card {
text-align: center;
padding: 10px 0;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #303133;
}
.stat-label {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,306 @@
<template>
<div class="mod-refund">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.refundNo" placeholder="退款单号" clearable style="width: 180px;"></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.payOrderNo" placeholder="原支付订单号" clearable style="width: 180px;"></el-input>
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="状态" clearable style="width: 120px;">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="createHandle()">发起退款</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table :data="dataList" border v-loading="dataListLoading" style="width: 100%;">
<el-table-column type="index" header-align="center" align="center" width="50" label="序号"></el-table-column>
<el-table-column prop="refundNo" header-align="center" align="center" label="退款单号"></el-table-column>
<el-table-column prop="payOrderNo" header-align="center" align="center" label="原支付订单号"></el-table-column>
<el-table-column prop="refundAmount" header-align="center" align="center" label="退款金额">
<template #default="scope">
{{ formatAmount(scope.row.refundAmount) }}
</template>
</el-table-column>
<el-table-column prop="totalAmount" header-align="center" align="center" label="原订单金额">
<template #default="scope">
{{ formatAmount(scope.row.totalAmount) }}
</template>
</el-table-column>
<el-table-column prop="status" header-align="center" align="center" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'PENDING'" size="small" type="info">待处理</el-tag>
<el-tag v-else-if="scope.row.status === 'PROCESSING'" size="small" type="warning">处理中</el-tag>
<el-tag v-else-if="scope.row.status === 'SUCCESS'" size="small" type="success">已退款</el-tag>
<el-tag v-else-if="scope.row.status === 'FAILED'" size="small" type="danger">退款失败</el-tag>
<el-tag v-else-if="scope.row.status === 'CLOSED'" size="small" type="info">已关闭</el-tag>
<el-tag v-else size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="reason" header-align="center" align="center" label="退款原因" show-overflow-tooltip></el-table-column>
<el-table-column prop="wxRefundId" header-align="center" align="center" label="微信退款单号"></el-table-column>
<el-table-column prop="refundedAt" header-align="center" align="center" label="退款时间">
<template #default="scope">
{{ scope.row.refundedAt || '' }}
</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" label="创建时间">
<template #default="scope">
{{ scope.row.createdAt || '' }}
</template>
</el-table-column>
<el-table-column fixed="right" header-align="center" align="center" width="150" label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="detailHandle(scope.row.refundNo)">详情</el-button>
<el-button v-if="scope.row.status === 'FAILED'" link type="warning" size="small" @click="retryHandle(scope.row.id)">重试</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 发起退款弹窗 -->
<el-dialog v-model="createVisible" title="发起退款" width="500px">
<el-form :model="createForm" :rules="createRules" ref="createFormRef" label-width="120px">
<el-form-item label="订单号" prop="orderNo">
<el-input v-model="createForm.orderNo" placeholder="请输入需退款的天卡订单号"></el-input>
</el-form-item>
<el-form-item label="退款原因" prop="reason">
<el-input v-model="createForm.reason" type="textarea" :rows="3" placeholder="请输入退款原因"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" @click="submitCreateForm()">确定</el-button>
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="退款详情" width="600px">
<el-descriptions :column="2" border>
<el-descriptions-item label="退款单号">{{ detailData.refundNo }}</el-descriptions-item>
<el-descriptions-item label="原支付订单号">{{ detailData.payOrderNo }}</el-descriptions-item>
<el-descriptions-item label="退款金额">{{ formatAmount(detailData.refundAmount) }}</el-descriptions-item>
<el-descriptions-item label="原订单金额">{{ formatAmount(detailData.totalAmount) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ getStatusLabel(detailData.status) }}</el-descriptions-item>
<el-descriptions-item label="微信退款单号">{{ detailData.wxRefundId || '-' }}</el-descriptions-item>
<el-descriptions-item label="退款原因" :span="2">{{ detailData.reason || '-' }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ detailData.operator || '-' }}</el-descriptions-item>
<el-descriptions-item label="错误信息">{{ detailData.errorMessage || '-' }}</el-descriptions-item>
<el-descriptions-item label="退款时间">{{ detailData.refundedAt || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detailData.createdAt || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">{{ detailData.updatedAt || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="primary" @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
getRefundList,
getRefundInfo,
applyRefund,
retryRefund,
type RefundItem
} from '@/api/manage/refund'
const statusOptions = [
{ label: '待处理', value: 'PENDING' },
{ label: '处理中', value: 'PROCESSING' },
{ label: '已退款', value: 'SUCCESS' },
{ label: '退款失败', value: 'FAILED' },
{ label: '已关闭', value: 'CLOSED' }
]
const dataForm = reactive<{
refundNo: string
payOrderNo: string
status: string
}>({
refundNo: '',
payOrderNo: '',
status: ''
})
const dataList = ref<RefundItem[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
// 发起退款
const createVisible = ref<boolean>(false)
const createFormRef = ref<FormInstance>()
interface CreateForm {
orderNo: string
reason: string
}
const createForm = reactive<CreateForm>({
orderNo: '',
reason: ''
})
const createRules: FormRules = {
orderNo: [{ required: true, message: '请输入订单号', trigger: 'blur' }],
reason: [{ required: true, message: '请输入退款原因', trigger: 'blur' }]
}
// 详情
const detailVisible = ref<boolean>(false)
const detailData = ref<RefundItem>({
id: 0,
refundNo: '',
payOrderNo: '',
refundAmount: 0,
totalAmount: 0,
status: 'PENDING',
reason: null,
wxRefundId: null,
operator: null,
errorMessage: null,
refundedAt: null,
createdAt: '',
updatedAt: ''
})
function formatAmount (fen: number | null | undefined): string {
if (fen === null || fen === undefined) return '¥0.00'
return '¥' + (fen / 100).toFixed(2)
}
function getStatusLabel (status: string): string {
const item = statusOptions.find((s) => s.value === status)
return item ? item.label : status
}
function getDataList (): void {
dataListLoading.value = true
getRefundList({
page: pageIndex.value,
limit: pageSize.value,
refundNo: dataForm.refundNo,
payOrderNo: dataForm.payOrderNo,
status: dataForm.status
}).then((res) => {
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
dataListLoading.value = false
}).catch(() => {
dataListLoading.value = false
})
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function resetCreateForm (): void {
createForm.orderNo = ''
createForm.reason = ''
createFormRef.value?.clearValidate()
}
function createHandle (): void {
resetCreateForm()
createVisible.value = true
}
function submitCreateForm (): void {
createFormRef.value?.validate((valid) => {
if (!valid) return
applyRefund({
orderNo: createForm.orderNo,
reason: createForm.reason
}).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
createVisible.value = false
getDataList()
}
})
} else {
ElMessage.error(res.msg)
}
})
})
}
function detailHandle (refundNo: string): void {
getRefundInfo(refundNo).then((res) => {
const detail = (res && res.data?.refund) || null
if (res && res.code === 0 && detail) {
detailData.value = detail
detailVisible.value = true
} else {
ElMessage.error(res?.msg || '获取详情失败')
}
})
}
function retryHandle (id: number): void {
ElMessageBox.confirm(`确定对退款单[id=${id}]进行[重试]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
retryRefund(id).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
}
})
} else {
ElMessage.error(res.msg)
}
})
}).catch(() => {})
}
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.mod-refund {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,479 @@
<template>
<div class="mod-stats-overview">
<!-- 顶部操作栏 -->
<div class="stats-overview-header">
<span class="stats-overview-title">统计概览</span>
<el-button type="primary" :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
</div>
<!-- 顶部统计卡片 -->
<el-row :gutter="20" class="stats-cards-row" v-loading="loading">
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover" class="stats-card stats-card--orders">
<div class="stats-card-body">
<div class="stats-card-icon">
<el-icon><Document /></el-icon>
</div>
<div class="stats-card-content">
<div class="stats-card-label">总订单数</div>
<div class="stats-card-value">{{ overview.totalOrders ?? 0 }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover" class="stats-card stats-card--today">
<div class="stats-card-body">
<div class="stats-card-icon">
<el-icon><Calendar /></el-icon>
</div>
<div class="stats-card-content">
<div class="stats-card-label">今日订单</div>
<div class="stats-card-value">{{ overview.todayOrders ?? 0 }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover" class="stats-card stats-card--revenue">
<div class="stats-card-body">
<div class="stats-card-icon">
<el-icon><Money /></el-icon>
</div>
<div class="stats-card-content">
<div class="stats-card-label">总收入 (元)</div>
<div class="stats-card-value">{{ formatMoney(overview.totalRevenue) }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover" class="stats-card stats-card--today-revenue">
<div class="stats-card-body">
<div class="stats-card-icon">
<el-icon><Wallet /></el-icon>
</div>
<div class="stats-card-content">
<div class="stats-card-label">今日收入 (元)</div>
<div class="stats-card-value">{{ formatMoney(overview.todayRevenue) }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 池概览 -->
<el-row :gutter="20" class="stats-pool-row" v-loading="loading">
<el-col :xs="24" :sm="8">
<el-card shadow="never" class="pool-summary-card">
<template #header><span>卡池概览</span></template>
<div class="pool-summary-list">
<div class="pool-summary-item">
<span class="pool-summary-label">总卡池</span>
<span class="pool-summary-value">{{ overview.totalPool ?? 0 }}</span>
</div>
<div class="pool-summary-item">
<span class="pool-summary-label">可用卡池</span>
<span class="pool-summary-value pool-summary-value--success">{{ overview.availablePool ?? 0 }}</span>
</div>
<div class="pool-summary-item">
<span class="pool-summary-label">已用卡池</span>
<span class="pool-summary-value pool-summary-value--primary">{{ overview.usedPool ?? 0 }}</span>
</div>
<div class="pool-summary-item">
<span class="pool-summary-label">激活失败</span>
<span class="pool-summary-value pool-summary-value--danger">{{ overview.activateFailed ?? 0 }}</span>
</div>
</div>
</el-card>
</el-col>
<!-- 库存状态统计 -->
<el-col :xs="24" :sm="8">
<el-card shadow="never" class="pool-stats-card">
<template #header><span>库存状态统计</span></template>
<div v-if="statusStatsList.length" class="pool-stats-list">
<div v-for="item in statusStatsList" :key="item.key" class="pool-stats-item">
<el-tag :type="getStatusTagType(item.key)" effect="light" class="pool-stats-tag">
{{ getStatusLabel(item.key) }}
</el-tag>
<span class="pool-stats-count">{{ item.value }}</span>
</div>
</div>
<el-empty v-else description="暂无数据" :image-size="60" />
</el-card>
</el-col>
<!-- 激活状态统计 -->
<el-col :xs="24" :sm="8">
<el-card shadow="never" class="pool-stats-card">
<template #header><span>激活状态统计</span></template>
<div v-if="activateStatsList.length" class="pool-stats-list">
<div v-for="item in activateStatsList" :key="item.key" class="pool-stats-item">
<el-tag :type="getActivateTagType(item.key)" effect="light" class="pool-stats-tag">
{{ getActivateLabel(item.key) }}
</el-tag>
<span class="pool-stats-count">{{ item.value }}</span>
</div>
</div>
<el-empty v-else description="暂无数据" :image-size="60" />
</el-card>
</el-col>
</el-row>
<!-- 分账统计 -->
<el-card shadow="never" class="profit-sharing-card" v-loading="loading">
<template #header><span>分账统计</span></template>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6">
<div class="profit-stat-item">
<div class="profit-stat-label">总分账笔数</div>
<div class="profit-stat-value">{{ profitSharingStats.summary?.totalCount ?? 0 }}</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="profit-stat-item">
<div class="profit-stat-label">分账总金额 (元)</div>
<div class="profit-stat-value">{{ formatFenToYuan(profitSharingStats.summary?.totalAmount) }}</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="profit-stat-item">
<div class="profit-stat-label">成功金额 (元)</div>
<div class="profit-stat-value profit-stat-value--success">{{ formatFenToYuan(profitSharingStats.summary?.successAmount) }}</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="profit-stat-item">
<div class="profit-stat-label">成功笔数</div>
<div class="profit-stat-value profit-stat-value--success">{{ profitSharingStats.summary?.successCount ?? 0 }}</div>
</div>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, Document, Calendar, Money, Wallet } from '@element-plus/icons-vue'
import { getOverviewStats, getPoolStats, type OverviewStats, type PoolStats } from '@/api/manage/stats'
import { getProfitSharingStats, type ProfitSharingStats } from '@/api/manage/profitSharing'
defineOptions({ name: 'StatsOverview' })
const loading = ref(false)
const overview = ref<Partial<OverviewStats>>({})
const poolStats = ref<PoolStats>({ statusStats: {}, activateStats: {} })
const profitSharingStats = ref<Partial<ProfitSharingStats>>({})
/** 金额格式化:分转元,如果值看起来是分(>100 或有小数则直接显示) */
function formatMoney (val: number | undefined | null): string {
if (val == null) return '0.00'
const n = Number(val)
if (Number.isNaN(n)) return '0.00'
return n.toFixed(2)
}
/** 分转元格式化(分账统计金额单位为分) */
function formatFenToYuan (fen: number | undefined | null): string {
if (fen == null) return '0.00'
const n = Number(fen)
if (Number.isNaN(n)) return '0.00'
return (n / 100).toFixed(2)
}
/** 库存状态标签文案映射 */
const STATUS_LABEL_MAP: Record<string, string> = {
UNUSED: '未使用',
USED: '已使用',
EXPIRED: '已过期',
DISABLED: '已禁用'
}
/** 激活状态标签文案映射 */
const ACTIVATE_LABEL_MAP: Record<string, string> = {
PENDING: '待激活',
SUCCESS: '激活成功',
FAILED: '激活失败',
ACTIVATED: '已激活'
}
function getStatusLabel (key: string): string {
return STATUS_LABEL_MAP[key] || key
}
function getActivateLabel (key: string): string {
return ACTIVATE_LABEL_MAP[key] || key
}
function getStatusTagType (key: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
switch (key) {
case 'UNUSED': return 'success'
case 'USED': return 'primary'
case 'EXPIRED': return 'warning'
case 'DISABLED': return 'danger'
default: return 'info'
}
}
function getActivateTagType (key: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
switch (key) {
case 'SUCCESS':
case 'ACTIVATED': return 'success'
case 'PENDING': return 'warning'
case 'FAILED': return 'danger'
default: return 'info'
}
}
/** 将 Record<string, number> 转为有序数组 */
const statusStatsList = computed(() => {
const stats = poolStats.value?.statusStats || {}
return Object.entries(stats).map(([key, value]) => ({ key, value }))
})
const activateStatsList = computed(() => {
const stats = poolStats.value?.activateStats || {}
return Object.entries(stats).map(([key, value]) => ({ key, value }))
})
async function loadOverview () {
const res = await getOverviewStats()
if (res && res.code === 0) {
overview.value = res.data?.overview || {}
}
}
async function loadPoolStats () {
const res2 = await getPoolStats()
if (res2 && res2.code === 0) {
poolStats.value = res2.data?.poolStats || { statusStats: {}, activateStats: {} }
}
}
async function loadProfitSharingStats () {
const res = await getProfitSharingStats()
if (res && res.code === 0) {
profitSharingStats.value = res.data?.stats || {}
}
}
async function loadAll () {
loading.value = true
try {
await Promise.all([loadOverview(), loadPoolStats(), loadProfitSharingStats()])
} catch (err: unknown) {
const e = err as { response?: { data?: { msg?: string } }; message?: string }
ElMessage.error(e?.response?.data?.msg ?? e?.message ?? '获取统计数据失败')
} finally {
loading.value = false
}
}
onMounted(() => {
loadAll()
})
</script>
<style scoped>
.mod-stats-overview {
padding: 0;
}
.stats-overview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.stats-overview-title {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.stats-cards-row {
margin-bottom: 20px;
}
.stats-cards-row .el-col {
margin-bottom: 12px;
}
.stats-card {
border-radius: 10px;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stats-card:hover {
transform: translateY(-2px);
}
.stats-card-body {
display: flex;
align-items: center;
gap: 16px;
}
.stats-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 12px;
font-size: 28px;
flex-shrink: 0;
}
.stats-card--orders .stats-card-icon {
background-color: #ecf5ff;
color: #409eff;
}
.stats-card--today .stats-card-icon {
background-color: #f0f9eb;
color: #67c23a;
}
.stats-card--revenue .stats-card-icon {
background-color: #fdf6ec;
color: #e6a23c;
}
.stats-card--today-revenue .stats-card-icon {
background-color: #fef0f0;
color: #f56c6c;
}
.stats-card-content {
flex: 1;
min-width: 0;
}
.stats-card-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.stats-card-value {
font-size: 26px;
font-weight: 700;
color: var(--el-text-color-primary);
line-height: 1.2;
word-break: break-all;
}
.stats-pool-row {
margin-bottom: 20px;
}
.stats-pool-row .el-col {
margin-bottom: 12px;
}
.pool-summary-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.pool-summary-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-radius: 8px;
background-color: var(--el-fill-color-light);
}
.pool-summary-label {
font-size: 14px;
color: var(--el-text-color-regular);
}
.pool-summary-value {
font-size: 20px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.pool-summary-value--success {
color: #67c23a;
}
.pool-summary-value--primary {
color: #409eff;
}
.pool-summary-value--danger {
color: #f56c6c;
}
.pool-stats-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.pool-stats-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-radius: 8px;
background-color: var(--el-fill-color-light);
}
.pool-stats-tag {
font-size: 13px;
}
.pool-stats-count {
font-size: 20px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.profit-sharing-card {
margin-bottom: 20px;
}
.profit-stat-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 14px;
border-radius: 8px;
background-color: var(--el-fill-color-light);
text-align: center;
}
.profit-stat-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.profit-stat-value {
font-size: 24px;
font-weight: 700;
color: var(--el-text-color-primary);
line-height: 1.2;
word-break: break-all;
}
.profit-stat-value--success {
color: #67c23a;
}
.profit-stat-value--danger {
color: #f56c6c;
}
</style>

View File

@@ -0,0 +1,260 @@
<template>
<div class="mod-store">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.name" placeholder="门店名称" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="addHandle()">新增</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
style="width: 100%;">
<el-table-column
type="index"
header-align="center"
align="center"
width="80"
label="序号">
</el-table-column>
<el-table-column
prop="name"
header-align="center"
align="center"
label="门店名称">
</el-table-column>
<el-table-column
prop="orgCode"
header-align="center"
align="center"
label="编码">
</el-table-column>
<el-table-column
prop="parentOrgName"
header-align="center"
align="center"
label="所属上级">
</el-table-column>
<el-table-column
prop="status"
header-align="center"
align="center"
label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else-if="scope.row.status === 'INACTIVE'" size="small" type="danger">禁用</el-tag>
<el-tag v-else size="small">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
header-align="center"
align="center"
width="180"
label="创建时间">
<template #default="scope">
{{ scope.row.createdAt || '' }}
</template>
</el-table-column>
<el-table-column
fixed="right"
header-align="center"
align="center"
width="150"
label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="addOrUpdateHandle(scope.row)">修改</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 弹窗, 新增 / 修改 -->
<el-dialog v-model="addOrUpdateVisible" :title="addOrUpdateTitle" width="500px">
<el-form :model="addOrUpdateForm" :rules="addOrUpdateRules" ref="addOrUpdateFormRef" label-width="100px">
<el-form-item label="上级组织" prop="parentId">
<org-tree-select v-model="addOrUpdateForm.parentId" placeholder="请选择上级组织" :hide-node-types="['PLATFORM', 'STORE']" />
</el-form-item>
<el-form-item label="门店名称" prop="name">
<el-input v-model="addOrUpdateForm.name" placeholder="门店名称"></el-input>
</el-form-item>
<el-form-item label="编码" prop="orgCode">
<el-input v-model="addOrUpdateForm.orgCode" placeholder="编码"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addOrUpdateVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm()">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getStoreList, saveOrUpdateStore, deleteStore, type StoreInfo } from '@/api/manage/store'
interface DataForm {
name: string
}
const dataForm = reactive<DataForm>({
name: ''
})
const dataList = ref<StoreInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
const addOrUpdateVisible = ref<boolean>(false)
const addOrUpdateTitle = ref<string>('')
const addOrUpdateFormRef = ref<FormInstance>()
interface AddOrUpdateForm {
id?: number
parentId: number | null
name: string
orgCode: string
}
const addOrUpdateForm = reactive<AddOrUpdateForm>({
parentId: null,
name: '',
orgCode: ''
})
const addOrUpdateRules: FormRules = {
parentId: [{ required: true, message: '请选择上级组织', trigger: 'change' }],
name: [{ required: true, message: '请输入门店名称', trigger: 'blur' }]
}
async function getDataList (): Promise<void> {
dataListLoading.value = true
try {
const res = await getStoreList({
page: pageIndex.value,
limit: pageSize.value,
name: dataForm.name
})
if (res && res.code === 0) {
dataList.value = res.data?.page?.list || []
totalPage.value = res.data?.page?.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
} finally {
dataListLoading.value = false
}
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function resetForm (): void {
addOrUpdateForm.id = undefined
addOrUpdateForm.parentId = null
addOrUpdateForm.name = ''
addOrUpdateForm.orgCode = ''
addOrUpdateFormRef.value?.clearValidate()
}
function addHandle (): void {
resetForm()
addOrUpdateTitle.value = '新增门店'
addOrUpdateVisible.value = true
}
function addOrUpdateHandle (row: StoreInfo): void {
resetForm()
addOrUpdateTitle.value = '修改门店'
addOrUpdateForm.id = row.id
addOrUpdateForm.parentId = row.parentId
addOrUpdateForm.name = row.name
addOrUpdateForm.orgCode = row.orgCode || ''
addOrUpdateVisible.value = true
}
async function submitForm (): Promise<void> {
if (!addOrUpdateFormRef.value) return
await addOrUpdateFormRef.value.validate(async (valid) => {
if (!valid) return
const res = await saveOrUpdateStore({
id: addOrUpdateForm.id,
parentId: addOrUpdateForm.parentId ?? 0,
name: addOrUpdateForm.name,
orgCode: addOrUpdateForm.orgCode
})
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
addOrUpdateVisible.value = false
getDataList()
}
})
} else {
ElMessage.error(res?.msg || '操作失败')
}
})
}
function deleteHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteStore([id]).then((data) => {
if (data && data.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
}
})
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
}).catch(() => {})
}
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.mod-store {
padding: 10px;
}
</style>

View File

@@ -0,0 +1,283 @@
<template>
<el-dialog
:title="!dataForm.id ? '新增媒资' : '修改媒资'"
:close-on-click-modal="false"
v-model="visible"
width="860px"
top="4vh"
class="mod-media-asset-dialog"
@closed="onClosed">
<el-form
:model="dataForm"
:rules="dataRule"
ref="formRef"
label-width="120px"
>
<el-tabs v-model="activeTab">
<el-tab-pane label="基本信息" name="base">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="媒资编码" prop="code">
<el-input v-model="dataForm.code" placeholder="唯一编码" :disabled="!!dataForm.id" maxlength="64" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="CP内容ID" prop="cpContentId">
<el-input v-model="dataForm.cpContentId" placeholder="可选" maxlength="64" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="名称" prop="name">
<el-input v-model="dataForm.name" placeholder="内部名称" maxlength="255" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="中文展示名" prop="showNameZh">
<el-input v-model="dataForm.showNameZh" placeholder="展示用" maxlength="255" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="英文展示名" prop="showNameEn">
<el-input v-model="dataForm.showNameEn" placeholder="展示用" maxlength="255" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="中文描述" prop="descriptionZh">
<el-input v-model="dataForm.descriptionZh" type="textarea" :rows="3" placeholder="中文描述" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="英文描述" prop="descriptionEn">
<el-input v-model="dataForm.descriptionEn" type="textarea" :rows="3" placeholder="英文描述" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="横版海报" prop="posterHorizonal">
<el-input v-model="dataForm.posterHorizonal" placeholder="图片 URL" maxlength="500" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="竖版海报" prop="posterVertical">
<el-input v-model="dataForm.posterVertical" placeholder="图片 URL" maxlength="500" show-word-limit />
</el-form-item>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="作品与分类" name="work">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="主分类" prop="categoryPrimary">
<el-input-number v-model="dataForm.categoryPrimary" :min="0" :controls="true" style="width: 100%" placeholder="主分类" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="二级分类" prop="categorySecondary">
<el-input v-model="dataForm.categorySecondary" placeholder="二级分类" maxlength="64" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="导演" prop="director">
<el-input v-model="dataForm.director" maxlength="255" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="地区" prop="region">
<el-input v-model="dataForm.region" maxlength="64" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="语言" prop="language">
<el-input v-model="dataForm.language" maxlength="32" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="发行年份" prop="releaseYear">
<el-input v-model="dataForm.releaseYear" maxlength="16" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="演员" prop="performer">
<el-input v-model="dataForm.performer" maxlength="500" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="标签" prop="tags">
<el-input v-model="dataForm.tags" placeholder="多标签可用逗号分隔" maxlength="500" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="总集数" prop="numberOfEpisodes">
<el-input-number v-model="dataForm.numberOfEpisodes" :min="0" :controls="true" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最新集数" prop="latestEpisode">
<el-input-number v-model="dataForm.latestEpisode" :min="0" :controls="true" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="状态与审核" name="state">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="是否上线" prop="isOnline">
<el-switch v-model="dataForm.isOnline" :active-value="1" :inactive-value="0" inline-prompt active-text="是" inactive-text="否" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否完结" prop="isComplete">
<el-switch v-model="dataForm.isComplete" :active-value="1" :inactive-value="0" inline-prompt active-text="是" inactive-text="否" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="已认证" prop="isVerified">
<el-switch v-model="dataForm.isVerified" :active-value="1" :inactive-value="0" inline-prompt active-text="是" inactive-text="否" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="已审核" prop="isAudit">
<el-switch v-model="dataForm.isAudit" :active-value="1" :inactive-value="0" inline-prompt active-text="是" inactive-text="否" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="批准文号" prop="approvalCode">
<el-input v-model="dataForm.approvalCode" maxlength="64" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="删除标记" prop="deleteFlag">
<el-select v-model="dataForm.deleteFlag" placeholder="删除标记" style="width: 100%">
<el-option label="未删除" value="UNDEL" />
<el-option label="已删除" value="DEL" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="dataFormSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script>
import { getMediaAssetInfo, saveMediaAsset } from '@/api/mediaAsset'
import { ElMessage } from 'element-plus'
function emptyForm () {
return {
id: 0,
code: '',
cpContentId: '',
name: '',
showNameZh: '',
showNameEn: '',
descriptionZh: '',
descriptionEn: '',
posterHorizonal: '',
posterVertical: '',
categoryPrimary: null,
categorySecondary: '',
director: '',
performer: '',
tags: '',
isOnline: 0,
isComplete: 0,
isVerified: 0,
isAudit: 0,
region: '',
language: '',
releaseYear: '',
numberOfEpisodes: 0,
latestEpisode: 0,
approvalCode: '',
deleteFlag: 'UNDEL'
}
}
function assignForm (target, source) {
if (!source) return
const keys = Object.keys(emptyForm())
for (const k of keys) {
if (Object.prototype.hasOwnProperty.call(source, k) && source[k] !== undefined && source[k] !== null) {
target[k] = source[k]
}
}
}
export default {
name: 'MediaAssetAddOrUpdate',
data () {
return {
visible: false,
activeTab: 'base',
submitLoading: false,
dataForm: emptyForm(),
dataRule: {
code: [{ required: true, message: '媒资编码不能为空', trigger: 'blur' }],
name: [{ required: true, message: '名称不能为空', trigger: 'blur' }]
}
}
},
methods: {
onClosed () {
this.activeTab = 'base'
},
init (id) {
this.dataForm = emptyForm()
this.dataForm.id = id || 0
this.visible = true
this.submitLoading = false
this.$nextTick(() => {
this.$refs.formRef && this.$refs.formRef.clearValidate()
if (this.dataForm.id) {
getMediaAssetInfo(this.dataForm.id).then(({ data }) => {
if (data && data.code === 0) {
const row = data.mediaAsset || data.media || data.entity
if (row) {
assignForm(this.dataForm, row)
} else {
ElMessage.error('未返回媒资数据,请检查接口字段名(期望 data.mediaAsset)')
}
}
})
}
})
},
dataFormSubmit () {
this.$refs.formRef.validate((valid) => {
if (!valid) return
this.submitLoading = true
const payload = { ...this.dataForm }
if (!payload.id) delete payload.id
saveMediaAsset(payload)
.then(({ data }) => {
this.submitLoading = false
if (data && data.code === 0) {
ElMessage.success({ message: '操作成功', duration: 1500, onClose: () => {
this.visible = false
this.$emit('refreshDataList')
} })
} else {
ElMessage.error(data.msg || '保存失败')
}
})
.catch(() => {
this.submitLoading = false
})
})
}
}
}
</script>
<style scoped>
.mod-media-asset-dialog :deep(.el-tabs__content) {
max-height: 60vh;
overflow-y: auto;
padding-right: 4px;
}
</style>

View File

@@ -0,0 +1,337 @@
<template>
<el-dialog
v-model="visible"
title="媒资详情"
width="960px"
top="5vh"
align-center
destroy-on-close
append-to-body
:close-on-click-modal="false"
class="mod-media-asset-detail-dialog"
@closed="onClosed"
>
<div v-loading="loading" class="detail-body">
<div class="detail-scroll">
<div class="detail-inner">
<el-empty
v-if="!loading && !raw && loadAttempted"
description="未找到媒资或加载失败"
/>
<template v-else-if="raw">
<section class="detail-block">
<div class="detail-block__title">基本信息</div>
<el-descriptions
:column="2"
border
size="small"
class="detail-desc"
>
<el-descriptions-item label="CP内容ID" :span="2">{{ t('cpContentId') }}</el-descriptions-item>
<el-descriptions-item label="名称" :span="2">{{ t('name') }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="detail-block">
<div class="detail-block__title">展示名称与描述</div>
<el-descriptions :column="1" border size="small" class="detail-desc">
<el-descriptions-item label="中文展示名">{{ t('showNameZh') }}</el-descriptions-item>
<el-descriptions-item label="英文展示名">{{ t('showNameEn') }}</el-descriptions-item>
<el-descriptions-item label="中文描述">
<p class="detail-paragraph">{{ t('descriptionZh') }}</p>
</el-descriptions-item>
<el-descriptions-item label="英文描述">
<p class="detail-paragraph">{{ t('descriptionEn') }}</p>
</el-descriptions-item>
</el-descriptions>
</section>
<section class="detail-block">
<div class="detail-block__title">海报</div>
<el-descriptions :column="1" border size="small" class="detail-desc">
<el-descriptions-item label="横版海报">
<div v-if="posterH" class="detail-poster-wrap">
<el-image
v-if="isHttpUrl(posterH)"
:src="posterH"
:preview-src-list="[posterH]"
fit="contain"
class="detail-poster detail-poster--h"
/>
<span v-else class="detail-as-text">{{ posterH }}</span>
</div>
<span v-else class="text-muted">-</span>
</el-descriptions-item>
<el-descriptions-item label="竖版海报">
<div v-if="posterV" class="detail-poster-wrap">
<el-image
v-if="isHttpUrl(posterV)"
:src="posterV"
:preview-src-list="[posterV]"
fit="contain"
class="detail-poster detail-poster--v"
/>
<span v-else class="detail-as-text">{{ posterV }}</span>
</div>
<span v-else class="text-muted">-</span>
</el-descriptions-item>
</el-descriptions>
</section>
<section class="detail-block">
<div class="detail-block__title">作品与分类</div>
<el-descriptions :column="2" border size="small" class="detail-desc">
<el-descriptions-item label="主分类">{{ n('categoryPrimary') }}</el-descriptions-item>
<el-descriptions-item label="二级分类">{{ t('categorySecondary') }}</el-descriptions-item>
<el-descriptions-item label="导演" :span="2">{{ t('director') }}</el-descriptions-item>
<el-descriptions-item label="演员" :span="2">{{ t('performer') }}</el-descriptions-item>
<el-descriptions-item label="标签" :span="2">{{ t('tags') }}</el-descriptions-item>
<el-descriptions-item label="地区">{{ t('region') }}</el-descriptions-item>
<el-descriptions-item label="语言">{{ t('language') }}</el-descriptions-item>
<el-descriptions-item label="发行年份">{{ t('releaseYear') }}</el-descriptions-item>
<el-descriptions-item label="总集数">{{ n('numberOfEpisodes') }}</el-descriptions-item>
<el-descriptions-item label="最新集数">{{ n('latestEpisode') }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="detail-block">
<div class="detail-block__title">状态与元数据</div>
<el-descriptions :column="2" border size="small" class="detail-desc">
<el-descriptions-item label="是否上线">{{ yn('isOnline') }}</el-descriptions-item>
<el-descriptions-item label="是否完结">{{ yn('isComplete') }}</el-descriptions-item>
<el-descriptions-item label="已认证">{{ yn('isVerified') }}</el-descriptions-item>
<el-descriptions-item label="已审核">{{ yn('isAudit') }}</el-descriptions-item>
<el-descriptions-item label="批准文号" :span="2">{{ t('approvalCode') }}</el-descriptions-item>
<el-descriptions-item label="删除标记">{{ t('deleteFlag') }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="detail-block detail-block--last">
<div class="detail-block__title">时间</div>
<el-descriptions :column="2" border size="small" class="detail-desc">
<el-descriptions-item label="创建时间" :span="1">{{ t('createdAt') }}</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="1">{{ t('updatedAt') }}</el-descriptions-item>
</el-descriptions>
</section>
</template>
</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
<el-button
v-if="mediaId != null && raw"
type="primary"
@click="onClickEdit"
>修改</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { getMediaAssetInfo } from '@/api/mediaAsset'
import { ElMessage } from 'element-plus'
const props = defineProps({
modelValue: { type: Boolean, default: false },
mediaId: { type: [Number, String], default: null }
})
const emit = defineEmits(['update:modelValue', 'edit'])
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const loading = ref(false)
const loadAttempted = ref(false)
const raw = ref(null)
function pick (o, key) {
if (!o) return undefined
if (key in o && o[key] !== undefined && o[key] !== null) return o[key]
return undefined
}
function t (key) {
const v = pick(raw.value, key)
return v === undefined || v === null || v === '' ? '-' : v
}
function n (key) {
const v = pick(raw.value, key)
return v === undefined || v === null ? '-' : v
}
function yn (key) {
const v = pick(raw.value, key)
if (v === 1 || v === true) return '是'
if (v === 0 || v === false) return '否'
return v === undefined || v === null ? '-' : String(v)
}
const posterH = computed(() => {
const o = raw.value
if (!o) return ''
return o.posterHorizonal || ''
})
const posterV = computed(() => {
const o = raw.value
if (!o) return ''
return o.posterVertical || ''
})
function isHttpUrl (u) {
return typeof u === 'string' && /^https?:\/\//i.test(u)
}
function load () {
if (props.mediaId == null || props.mediaId === '') {
loadAttempted.value = true
raw.value = null
return
}
loading.value = true
loadAttempted.value = true
getMediaAssetInfo(props.mediaId)
.then(({ data }) => {
if (data && data.code === 0) {
const row = data.mediaAsset || data.media || data.entity
raw.value = row || null
if (!row) {
ElMessage.warning('接口未返回媒资数据')
}
} else {
raw.value = null
ElMessage.error(data?.msg || '获取媒资详情失败')
}
})
.catch(() => {
raw.value = null
})
.finally(() => {
loading.value = false
})
}
function onClosed () {
raw.value = null
loadAttempted.value = false
}
function onClickEdit () {
if (props.mediaId == null) return
emit('edit', props.mediaId)
visible.value = false
}
watch(
() => [visible.value, props.mediaId],
([v, id]) => {
if (v && id != null && id !== '') {
load()
}
}
)
</script>
<style scoped>
/* 使用原生 overflow,避免 el-scrollbar 在弹窗 + max-height 下高度计算异常导致无法滚动 */
.detail-body {
min-height: 100px;
}
.detail-scroll {
width: 100%;
max-height: min(72vh, 720px);
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.detail-inner {
padding: 0 2px 8px 0;
box-sizing: border-box;
}
.detail-block {
margin-bottom: 20px;
}
.detail-block--last {
margin-bottom: 0;
}
.detail-block__title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0 0 10px 2px;
padding-bottom: 6px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.detail-desc {
width: 100%;
}
/* 表格式描述:避免标签列被压扁、内容撑破 */
.detail-desc :deep(.el-descriptions__label) {
width: 128px;
min-width: 128px;
max-width: 32%;
vertical-align: top;
word-break: keep-all;
}
.detail-desc :deep(.el-descriptions__content) {
word-break: break-word;
vertical-align: top;
min-width: 0;
}
.detail-desc :deep(.el-descriptions__body) {
width: 100%;
}
.detail-paragraph {
margin: 0;
white-space: pre-wrap;
line-height: 1.5;
max-width: 100%;
}
.detail-poster-wrap {
display: block;
min-height: 0;
}
.detail-poster {
display: block;
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
overflow: hidden;
}
.detail-poster--h {
max-width: min(100%, 400px);
max-height: 220px;
}
.detail-poster--v {
max-width: min(100%, 200px);
max-height: 280px;
}
.detail-as-text {
font-size: 13px;
color: var(--el-text-color-secondary);
word-break: break-all;
line-height: 1.5;
}
.text-muted {
color: var(--el-text-color-secondary);
}
</style>
<style>
/* 弹窗全局:内容区不挤压 footer */
.mod-media-asset-detail-dialog .el-dialog__body {
padding: 8px 16px 4px 16px;
overflow: hidden;
}
.mod-media-asset-detail-dialog .el-dialog__footer {
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<el-dialog
v-model="visible"
title="排行榜"
width="1000px"
top="4vh"
draggable
align-center
append-to-body
destroy-on-close
:close-on-click-modal="false"
class="media-rank-dialog"
@opened="onDialogOpened"
>
<div class="media-rank-dialog__body">
<rank-board-panel ref="panelRef" embed :auto-load="false" />
</div>
</el-dialog>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import RankBoardPanel from './rank-board-panel.vue'
const props = defineProps({
modelValue: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const panelRef = ref(null)
function onDialogOpened () {
nextTick(() => {
panelRef.value?.loadData?.()
})
}
</script>
<style scoped>
.media-rank-dialog__body {
max-height: min(80vh, 860px);
overflow: auto;
margin: -4px 0 0 0;
padding-right: 2px;
}
</style>
<style>
.media-rank-dialog .el-dialog__header {
cursor: move;
user-select: none;
}
.media-rank-dialog .el-dialog__body {
padding: 8px 12px 16px 12px;
}
</style>

View File

@@ -0,0 +1,441 @@
<template>
<div :class="['rank-board-panel', { 'is-embed': embed }]">
<el-card shadow="never" class="rank-section-card">
<template #header>
<span>搜索并加入排行榜</span>
</template>
<el-form :inline="true" @submit.prevent="doSearch" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
clearable
placeholder="媒资名称(模糊)"
style="width: 240px"
@keyup.enter="doSearch"
/>
</el-form-item>
<el-form-item label="排序号">
<el-input-number
v-model="addSortOrder"
:min="1"
:max="10"
:step="1"
controls-position="right"
style="width: 120px"
/>
<span class="sort-hint">加入时插入的排行位置(1–10,与 set 的 codeList 顺序一致)</span>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="searchLoading" @click="doSearch">搜索</el-button>
</el-form-item>
</el-form>
<el-table
:data="searchList"
v-loading="searchLoading"
border
size="small"
class="search-result-table"
max-height="280"
empty-text="请输入关键词后搜索"
>
<el-table-column prop="code" label="媒资编码" min-width="120" show-overflow-tooltip />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="showNameZh" label="中文展示名" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.showNameZh || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
:loading="addingCode === getRowCode(row)"
:disabled="!getRowCode(row) || isInBoard(row) || boardList.length >= 10"
@click="onAddToBoard(row)"
>
{{ isInBoard(row) ? '已在榜内' : (boardList.length >= 10 ? '名额已满' : '加入') }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never" class="rank-section-card">
<template #header>
<div class="rank-table-header">
<span>排行榜 Top10</span>
<div class="rank-header-actions">
<el-button
v-if="boardList.length"
type="danger"
link
size="small"
:loading="clearing"
@click="onClearAll"
>清空</el-button>
<el-button
type="primary"
link
size="small"
:loading="boardLoading"
@click="loadBoard"
>刷新</el-button>
</div>
</div>
</template>
<el-table
:data="boardList"
v-loading="boardLoading"
border
stripe
:empty-text="boardEmptyText"
style="width: 100%"
>
<el-table-column
type="index"
label="序"
width="56"
align="center"
:index="(i) => i + 1"
/>
<el-table-column prop="code" label="媒资编码" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.code || row.mediaCode || '-' }}</template>
</el-table-column>
<el-table-column label="名称" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ boardRowName(row) }}</template>
</el-table-column>
<el-table-column v-if="showSortCol" label="排序" width="80" align="center">
<template #default="{ row }">{{ row.sortOrder ?? row.sort_order ?? '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row, $index }">
<el-button
type="primary"
link
size="small"
:disabled="$index === 0 || !getRowCode(row) || isMoveLoading(row, 'up')"
:loading="isMoveLoading(row, 'up')"
@click="onMoveUp(row)"
>上移</el-button>
<el-button
type="primary"
link
size="small"
:disabled="$index >= boardList.length - 1 || !getRowCode(row) || isMoveLoading(row, 'down')"
:loading="isMoveLoading(row, 'down')"
@click="onMoveDown(row)"
>下移</el-button>
<el-button
type="danger"
link
size="small"
:disabled="!getRowCode(row) || isMoveLoading(row, 'rm')"
:loading="isMoveLoading(row, 'rm')"
@click="onRemoveOne(row)"
>移除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { getMediaAssetList } from '@/api/mediaAsset'
import {
getMediaAssetRankTop10,
setMediaAssetRank,
removeMediaAssetRank,
moveUpMediaAssetRank,
moveDownMediaAssetRank,
clearMediaAssetRank
} from '@/api/mediaAssetRank'
import { ElMessage, ElMessageBox } from 'element-plus'
const props = defineProps({
embed: { type: Boolean, default: false },
autoLoad: { type: Boolean, default: true }
})
defineOptions({ name: 'RankBoardPanel' })
const searchForm = reactive({ keyword: '' })
const addSortOrder = ref(1)
const searchList = ref([])
const searchLoading = ref(false)
const boardList = ref([])
const boardLoading = ref(false)
const loadBoardError = ref(false)
const addingCode = ref(null)
const clearing = ref(false)
const actionCode = ref(null)
const actionOp = ref(null) // 'up' | 'down' | 'rm'
const boardEmptyText = computed(() =>
loadBoardError.value ? '排行榜加载失败' : '排行榜为空,请搜索媒资并加入'
)
const showSortCol = computed(() =>
boardList.value.some(
(r) => r.sortOrder != null || r.sort_order != null
)
)
function getRowCode (row) {
return row?.code ?? row?.mediaCode ?? ''
}
const boardCodeSet = computed(() => {
const s = new Set()
for (const r of boardList.value) {
const c = getRowCode(r)
if (c) s.add(String(c))
}
return s
})
function isInBoard (row) {
const c = getRowCode(row)
if (!c) return false
return boardCodeSet.value.has(String(c))
}
function isMoveLoading (row, op) {
const c = getRowCode(row)
return actionCode.value === c && actionOp.value === op
}
function boardRowName (row) {
return (
row.name ||
row.title ||
row.showNameZh ||
row.mediaName ||
'-'
)
}
function normalizeTop10 (data) {
if (!data) return []
if (Array.isArray(data)) return data
const v =
data.list ||
data.records ||
data.data ||
data.top10 ||
data.page?.list
if (Array.isArray(v)) return v
return []
}
/** 由当前榜按表格顺序得到 codeList(与后端 set 的 codeList 顺序一致) */
function buildCodeListFromBoard () {
return boardList.value.map((r) => getRowCode(r)).filter(Boolean)
}
async function doSearch () {
const kw = (searchForm.keyword || '').trim()
if (!kw) {
searchList.value = []
ElMessage.warning('请输入关键词')
return
}
searchLoading.value = true
try {
const { data } = await getMediaAssetList({
page: 1,
limit: 50,
name: kw,
deleteFlag: 'UNDEL'
})
if (data && data.code === 0) {
searchList.value = data.page?.list || []
} else {
searchList.value = []
ElMessage.error(data?.msg || '搜索失败')
}
} catch {
searchList.value = []
} finally {
searchLoading.value = false
}
}
async function onAddToBoard (row) {
const code = getRowCode(row)
if (!code) {
ElMessage.error('该媒资缺少 code,无法加入')
return
}
if (boardList.value.length >= 10) {
ElMessage.warning('榜内最多 10 条,请先移出或清空')
return
}
if (isInBoard(row)) {
return
}
const baseCodes = buildCodeListFromBoard()
const p =
addSortOrder.value != null
? Math.min(Math.max(1, Number(addSortOrder.value)), 10)
: baseCodes.length + 1
const insertIndex = Math.min(p - 1, baseCodes.length)
const next = [...baseCodes]
next.splice(insertIndex, 0, String(code))
const codeList = next.slice(0, 10)
addingCode.value = code
try {
const { data } = await setMediaAssetRank(codeList)
if (data && data.code === 0) {
ElMessage.success('已更新排行榜')
await loadBoard()
} else {
ElMessage.error(data?.msg || '设置失败')
}
} finally {
addingCode.value = null
}
}
async function onMoveUp (row) {
const code = getRowCode(row)
if (!code) return
actionCode.value = code
actionOp.value = 'up'
try {
const { data } = await moveUpMediaAssetRank(code)
if (data && data.code === 0) {
ElMessage.success('已上移')
await loadBoard()
} else {
ElMessage.error(data?.msg || '上移失败')
}
} finally {
actionCode.value = null
actionOp.value = null
}
}
async function onMoveDown (row) {
const code = getRowCode(row)
if (!code) return
actionCode.value = code
actionOp.value = 'down'
try {
const { data } = await moveDownMediaAssetRank(code)
if (data && data.code === 0) {
ElMessage.success('已下移')
await loadBoard()
} else {
ElMessage.error(data?.msg || '下移失败')
}
} finally {
actionCode.value = null
actionOp.value = null
}
}
async function onRemoveOne (row) {
const code = getRowCode(row)
if (!code) return
actionCode.value = code
actionOp.value = 'rm'
try {
const { data } = await removeMediaAssetRank(code)
if (data && data.code === 0) {
ElMessage.success('已移出')
await loadBoard()
} else {
ElMessage.error(data?.msg || '移除失败')
}
} finally {
actionCode.value = null
actionOp.value = null
}
}
function onClearAll () {
ElMessageBox.confirm('确定清空整个排行榜?', '提示', {
type: 'warning',
confirmButtonText: '清空',
cancelButtonText: '取消'
})
.then(async () => {
clearing.value = true
try {
const { data } = await clearMediaAssetRank()
if (data && data.code === 0) {
ElMessage.success('已清空')
await loadBoard()
} else {
ElMessage.error(data?.msg || '清空失败')
}
} finally {
clearing.value = false
}
})
.catch(() => {})
}
async function loadBoard () {
boardLoading.value = true
loadBoardError.value = false
try {
const { data } = await getMediaAssetRankTop10()
if (data && data.code === 0) {
boardList.value = normalizeTop10(data)
} else {
boardList.value = []
loadBoardError.value = true
ElMessage.error(data?.msg || '获取排行榜失败')
}
} catch {
boardList.value = []
loadBoardError.value = true
} finally {
boardLoading.value = false
}
}
onMounted(() => {
if (props.autoLoad) {
loadBoard()
}
})
defineExpose({ loadData: () => loadBoard(), loadBoard })
</script>
<style scoped>
.sort-hint {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rank-board-panel.is-embed .rank-section-card {
margin-bottom: 8px;
}
.rank-section-card {
margin-bottom: 12px;
}
.search-form {
margin-bottom: 8px;
}
.search-result-table {
margin-top: 0;
}
.rank-table-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-weight: 600;
font-size: 14px;
}
.rank-header-actions {
display: flex;
align-items: center;
gap: 4px;
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<div class="mod-media-asset">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item label="名称">
<el-input v-model="dataForm.name" placeholder="名称(模糊)" clearable style="width: 180px" />
</el-form-item>
<el-form-item label="编码">
<el-input v-model="dataForm.code" placeholder="媒资编码" clearable style="width: 160px" />
</el-form-item>
<el-form-item label="上线">
<el-select v-model="dataForm.isOnline" placeholder="全部" clearable style="width: 100px">
<el-option label="是" :value="1" />
<el-option label="否" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getDataList()">查询</el-button>
<el-button type="primary" plain @click="addOrUpdateHandle()">新增</el-button>
<el-button type="success" plain @click="rankDialogVisible = true">排行榜</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
style="width: 100%">
<el-table-column prop="name" header-align="center" min-width="140" label="名称" show-overflow-tooltip />
<el-table-column prop="showNameZh" header-align="center" min-width="120" label="中文展示名" show-overflow-tooltip />
<el-table-column prop="categorySecondary" header-align="center" width="100" label="二级分类" show-overflow-tooltip />
<el-table-column header-align="center" align="center" width="72" label="上线">
<template #default="{ row }">
<el-tag :type="row.isOnline === 1 ? 'success' : 'info'" size="small">{{ row.isOnline === 1 ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column header-align="center" align="center" width="72" label="完结">
<template #default="{ row }">
<span>{{ row.isComplete === 1 ? '是' : '否' }}</span>
</template>
</el-table-column>
<el-table-column prop="region" header-align="center" width="90" label="地区" show-overflow-tooltip />
<el-table-column prop="releaseYear" header-align="center" width="88" label="发行年份" />
<el-table-column prop="numberOfEpisodes" header-align="center" align="center" width="80" label="总集" />
<el-table-column header-align="center" min-width="160" label="更新时间" show-overflow-tooltip>
<template #default="{ row }">{{ row.updatedAt ?? '-' }}</template>
</el-table-column>
<el-table-column fixed="right" header-align="center" align="center" width="200" label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">详情</el-button>
<el-button link type="primary" size="small" @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper" />
</div>
<add-or-update v-if="addOrUpdateVisible" ref="addOrUpdateRef" @refreshDataList="onAddOrUpdateSaved" />
<media-asset-detail-dialog
v-model="detailVisible"
:media-id="detailId"
@edit="onDetailEdit"
/>
<media-rank-dialog v-model="rankDialogVisible" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue'
import AddOrUpdate from './components/media-asset-add-or-update.vue'
import MediaAssetDetailDialog from './components/media-asset-detail-dialog.vue'
import MediaRankDialog from './components/media-rank-dialog.vue'
import { getMediaAssetList, deleteMediaAsset } from '@/api/mediaAsset'
import { ElMessage, ElMessageBox } from 'element-plus'
defineOptions({ name: 'MediaAsset' })
const dataForm = reactive({
name: '',
code: '',
isOnline: null,
deleteFlag: 'UNDEL'
})
const dataList = ref([])
const pageIndex = ref(1)
const pageSize = ref(10)
const totalPage = ref(0)
const dataListLoading = ref(false)
const addOrUpdateVisible = ref(false)
const addOrUpdateRef = ref(null)
const detailVisible = ref(false)
const detailId = ref(null)
const rankDialogVisible = ref(false)
function getDataList () {
dataListLoading.value = true
const params = {
page: pageIndex.value,
limit: pageSize.value,
name: dataForm.name || undefined,
code: dataForm.code || undefined
}
if (dataForm.isOnline === 0 || dataForm.isOnline === 1) {
params.isOnline = dataForm.isOnline
}
if (dataForm.deleteFlag) {
params.deleteFlag = dataForm.deleteFlag
}
getMediaAssetList(params)
.then(({ data }) => {
if (data && data.code === 0) {
dataList.value = data.page?.list || []
totalPage.value = data.page?.totalCount ?? 0
} else {
dataList.value = []
totalPage.value = 0
}
})
.catch(() => {
dataList.value = []
totalPage.value = 0
})
.finally(() => {
dataListLoading.value = false
})
}
function sizeChangeHandle (val) {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val) {
pageIndex.value = val
getDataList()
}
function goDetail (id) {
detailId.value = id
detailVisible.value = true
}
function onDetailEdit (id) {
addOrUpdateHandle(id)
}
function onAddOrUpdateSaved () {
getDataList()
}
function addOrUpdateHandle (id) {
addOrUpdateVisible.value = true
nextTick(() => {
addOrUpdateRef.value?.init?.(id)
})
}
function deleteHandle (id) {
if (id === undefined || id === null) {
return
}
ElMessageBox.confirm(
`确定对[id=${id}]进行[删除]操作?`,
'提示',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
.then(() => {
return deleteMediaAsset([id])
})
.then(({ data }) => {
if (data && data.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => { getDataList() }
})
} else {
ElMessage.error(data?.msg)
}
})
.catch(() => {})
}
onMounted(() => {
getDataList()
})
</script>
<style scoped>
.table-page-wrap {
background: #fff;
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<div class="mod-rank-board">
<rank-board-panel />
</div>
</template>
<script setup>
import RankBoardPanel from './components/rank-board-panel.vue'
defineOptions({ name: 'MediaRankBoard' })
</script>
<style scoped>
.mod-rank-board {
padding: 0;
}
</style>

View File

@@ -0,0 +1,120 @@
/** 组织类型 */
export type OrgType = 'PLATFORM' | 'CHANNEL' | 'SERVICE_PROVIDER' | 'STORE'
/** 组织状态 */
export type OrgStatus = 'ACTIVE' | 'DISABLED'
/** 代理服务商扩展信息(由渠道等服务商侧维护) */
export interface ServiceProviderProfile {
/** 联系电话(仅限手机或带区号的固话) */
contactPhone: string
/** 服务商(名称或对接说明) */
servicePartner: string
/** 微信二维码图片 URL */
wechatQrImageUrl: string
}
/** 门店档案 */
export interface StoreProfile {
storeName: string
legalPerson: string
licenseNo: string
licenseImageUrl: string
region: string
address: string
phone: string
remark: string
}
/** 组织表单数据 */
export interface OrgDataForm {
id: number
parentId: string | number | null
parentName: string
name: string
type: OrgType | string
orderNo: number
canHoldInventory: boolean
activationPrefix: string
/** 代理服务商:开拓人员(选填) */
developmentStaff: string
/** 代理服务商:联系方式与微信二维码 */
serviceProviderProfile: ServiceProviderProfile
status: OrgStatus | string
loginMobile: string
loginPassword: string
confirmPassword: string
storeProfile: StoreProfile
}
/** 组织树节点 */
export interface OrgTreeNode {
id: string | number
name: string
type: string
parentId?: string | number
children?: OrgTreeNode[]
[key: string]: unknown
}
/** 组织信息响应 */
export interface OrgInfoResponse {
code?: number
org?: OrgInfo
data?: OrgInfo
}
/** 组织信息 */
export interface OrgInfo {
id?: string | number
parentId?: string | number
name?: string
type?: string
orderNo?: number
activationPrefix?: string
developmentStaff?: string
canHoldInventory?: boolean | number | string
status?: string
loginMobile?: string
mobile?: string
storeProfile?: StoreProfileData
/** 代理服务商字段:接口层平铺字段 */
contactPhone?: string
servicePartner?: string
wechatQrImageUrl?: string
/** 兼容旧接口:曾与 storeProfile 同级的嵌套包 */
serviceProviderProfile?: ServiceProviderProfileData
}
/** 门店档案数据 */
export interface StoreProfileData {
storeName?: string
legalPerson?: string
licenseNo?: string
licenseImageUrl?: string
region?: string
address?: string
phone?: string
remark?: string
}
/** 代理服务商扩展(接口载荷) */
export interface ServiceProviderProfileData {
contactPhone?: string
/** 旧字段兼容:历史数据仍为 contactInfo */
contactInfo?: string
servicePartner?: string
wechatQrImageUrl?: string
}
/** 省市区数据 */
export interface RegionData {
value: string
label: string
children?: RegionData[]
}
/** 初始化选项 */
export interface InitOptions {
typeOnly?: string
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
/** 表单数据 */
export interface FormData {
allocateParentOrgId: string | number | null
toChannelOrgId: string | number | null
cardType: string
count: number
}
/** 当前卡统计 */
export interface CurrentCardStats {
cardType: string
cardTypeName?: string
totalCount: number
usedCount: number
availableCount: number
}
/** 卡统计响应 */
export interface CardStatsResponse {
code?: number
data?: {
current?: {
cardType: string
totalCount?: number
usedCount?: number
availableCount?: number
}
children?: CurrentCardStats[]
}
msg?: string
}

View File

@@ -0,0 +1,455 @@
<template>
<el-dialog
:model-value="modelValue"
title="分配卡"
width="520px"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="onClosed">
<el-form :model="form" :rules="rules" ref="formRef" label-width="108px">
<el-form-item label="销售方" prop="allocateParentOrgId">
<el-tree-select
v-model="form.allocateParentOrgId"
:data="sellerTreeData"
:props="{ label: 'label', value: 'id', disabled: 'disabled' }"
node-key="id"
placeholder="请选择销售方(平台/渠道/代理商)"
filterable
:clearable="false"
default-expand-all
:render-after-expand="false"
check-strictly
style="width: 100%"
@focus="fetchOrgTreeData" />
</el-form-item>
<el-form-item label="购买方" prop="toChannelOrgId">
<el-select
v-model="form.toChannelOrgId"
:placeholder="receiverPlaceholder"
:disabled="form.allocateParentOrgId == null || form.allocateParentOrgId === ''"
filterable
clearable
style="width: 100%"
@focus="fetchOrgTreeData">
<el-option
v-for="item in receiverOptions"
:key="item.id"
:label="item.label"
:value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="卡类型" prop="cardType">
<el-select v-model="form.cardType" placeholder="请选择" style="width: 100%">
<el-option label="年卡" value="YEAR" />
<el-option label="季卡" value="QUARTER" />
<el-option label="月卡" value="MONTH" />
<el-option label="周卡" value="WEEK" />
<el-option label="天卡" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="分配数量" prop="count">
<el-input-number v-model="form.count" :min="1" :max="9999" controls-position="right" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="submit">确定分配</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { getOrgListData, parseOrgListDataBody } from '@/api/org'
import { allocateCard } from '@/api/cardFlow'
import { treeDataTranslate } from '@/utils'
import type { FormData } from './org-allocate-card-dialog.data'
defineOptions({ name: 'OrgAllocateCardDialog' })
const props = defineProps({
modelValue: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'success'])
const userStore = useUserStore()
const TYPE_LABEL: Record<string, string> = {
PLATFORM: '平台',
CHANNEL: '渠道',
SERVICE_PROVIDER: '代理商',
STORE: '门店'
}
interface OrgItem {
id: string | number
orgId?: string | number
name?: string
type?: string
orgType?: string
parentId?: string | number
parentName?: string
label?: string
disabled?: boolean
children?: OrgItem[]
}
const orgFlatList = ref<OrgItem[]>([])
const sellerTreeData = ref<OrgItem[]>([])
/** 可选销售方 id(平台/渠道/代理商) */
const allowedSellerIdSet = ref<Set<string | number>>(new Set())
const form = reactive<FormData>({
allocateParentOrgId: null,
toChannelOrgId: null,
cardType: 'YEAR',
count: 1
})
const rules: FormRules = {
allocateParentOrgId: [{ required: true, message: '请选择销售方(须为平台/渠道/代理商)', trigger: 'change' }],
toChannelOrgId: [{ required: true, message: '请选择购买方(直属下一级,且不能为门店)', trigger: 'change' }],
cardType: [{ required: true, message: '请选择卡类型', trigger: 'change' }],
count: [{ required: true, message: '请输入分配数量', trigger: 'blur' }]
}
const loading = ref(false)
const formRef = ref<FormInstance | null>(null)
const receiverPlaceholder = computed(() =>
form.allocateParentOrgId == null || form.allocateParentOrgId === ''
? '请先选择销售方'
: '直属下一级,不含门店'
)
/** 购买方不允许为门店 */
function isStoreOrg (row: OrgItem): boolean {
return rowType(row) === 'STORE'
}
const receiverOptions = computed(() => {
const list = orgFlatList.value
const pid = form.allocateParentOrgId
if (!list.length || pid == null || pid === '') return []
return directChildren(list, pid)
.filter((o) => !isStoreOrg(o))
.map((o) => ({
id: o.id,
label: optionLabel(o)
}))
})
watch(
() => form.allocateParentOrgId,
() => {
form.toChannelOrgId = null
}
)
watch(
() => props.modelValue,
(visible) => {
if (visible) {
form.allocateParentOrgId = null
form.toChannelOrgId = null
form.cardType = 'YEAR'
form.count = 1
orgFlatList.value = []
sellerTreeData.value = []
allowedSellerIdSet.value = new Set()
fetchOrgTreeData()
}
}
)
function rowType (row: OrgItem): string {
return String(row?.type ?? row?.orgType ?? '').toUpperCase()
}
/** 销售方仅允许平台 / 渠道 / 代理商 */
function isSellerOrg (row: OrgItem): boolean {
const t = rowType(row)
return t === 'PLATFORM' || t === 'CHANNEL' || t === 'SERVICE_PROVIDER'
}
function optionLabel (row: OrgItem): string {
const t = TYPE_LABEL[rowType(row)] || rowType(row) || '-'
return `${row.name || '-'}(${t})`
}
function resolvePlatformOrgId (list: OrgItem[]): string | number | null {
const hit = list.find((o) => rowType(o) === 'PLATFORM')
return hit?.id ?? hit?.orgId ?? null
}
function parentKeyOf (o: OrgItem): string | null {
const p = o.parentId
if (p == null || p === '' || String(p) === '0') return null
return String(p)
}
/**
* 当前账号在 list 内可视范围的根:从用户组织沿 parent 一直找到顶层(parent 为空或不在 list 内则停)。
* 门店等叶子组织若只取「向下子树」会不含上级渠道/平台,销售方 el-tree 会为空。
*/
function resolveDataScopeRootId (list: OrgItem[], userOrgId: string | number | null): string | number | null {
if (userOrgId == null || userOrgId === '' || String(userOrgId) === '0') return null
const byId = new Map<string, OrgItem>(list.map((o) => [String(o.id), o]))
let cur: string | number | null | undefined = userOrgId
let top: string | number | null = null
const seen = new Set<string>()
while (cur != null && cur !== '' && String(cur) !== '0') {
const key = String(cur)
if (seen.has(key)) break
seen.add(key)
top = cur
const row = byId.get(key)
if (!row) break
const p = row.parentId
if (p == null || p === '' || String(p) === '0') break
if (!byId.has(String(p))) break
cur = p
}
return top
}
function collectSubtreeIncludingSelf (list: OrgItem[], rootId: string | number): OrgItem[] {
if (rootId == null || rootId === '') return []
const byParent = new Map<string | null, OrgItem[]>()
for (const o of list) {
const pk = parentKeyOf(o)
if (!byParent.has(pk)) byParent.set(pk, [])
byParent.get(pk)!.push(o)
}
const out: OrgItem[] = []
const seen = new Set<string>()
const q = [String(rootId)]
while (q.length) {
const id = q.shift()
if (!id || seen.has(id)) continue
seen.add(id)
const node = list.find((n) => String(n.id) === id)
if (!node) continue
out.push(node)
const kids = byParent.get(id) || []
for (const k of kids) q.push(String(k.id))
}
return out
}
function mergeSubtreesFromTopRoots (list: OrgItem[]): OrgItem[] {
const roots = list.filter((o) => parentKeyOf(o) == null)
const m = new Map<string, OrgItem>()
for (const r of roots) {
for (const n of collectSubtreeIncludingSelf(list, r.id)) {
m.set(String(n.id), n)
}
}
return [...m.values()]
}
function directChildren (list: OrgItem[], parentIdForChildren: string | number): OrgItem[] {
return list.filter((o) => String(o.parentId) === String(parentIdForChildren))
}
/** 从扁平列表中去掉门店,并把 parent 链路上连续的门店跳过,便于重新建树 */
function flatListHidingStores (flatList: OrgItem[]): OrgItem[] {
if (!flatList || !flatList.length) return []
const storeIds = new Set(
flatList.filter((o) => isStoreOrg(o)).map((o) => String(o.id))
)
if (!storeIds.size) {
return flatList.map((n) => ({ ...n, parentId: n.parentId }))
}
const byId = new Map(flatList.map((o) => [String(o.id), o]))
function climbPastStores (pid: string | number | null | undefined): string | number | undefined {
let p = pid
while (
p != null &&
p !== '' &&
String(p) !== '0' &&
storeIds.has(String(p))
) {
const parent = byId.get(String(p))
if (!parent) break
p = parent.parentId
}
if (p == null || p === '' || String(p) === '0') return undefined
return p
}
return flatList
.filter((o) => !isStoreOrg(o))
.map((o) => {
const newPid = climbPastStores(o.parentId)
return { ...o, parentId: newPid }
})
}
/** 销售方树:分级展示,不包含门店 */
function buildSellerTreeData (flatSubtree: OrgItem[]): OrgItem[] {
if (!flatSubtree || !flatSubtree.length) return []
const withoutStores = flatListHidingStores(flatSubtree)
if (!withoutStores.length) return []
const tree = treeDataTranslate(withoutStores, 'id', 'parentId') as OrgItem[]
return decorateSellerTreeNodes(tree)
}
function decorateSellerTreeNodes (nodes: OrgItem[]): OrgItem[] {
if (!nodes || !Array.isArray(nodes)) return []
return nodes.map((node) => {
const label = optionLabel(node)
const disabled = !isSellerOrg(node)
const item = { ...node, label, disabled }
if (node.children && (node.children as OrgItem[]).length > 0) {
item.children = decorateSellerTreeNodes(node.children as OrgItem[])
}
return item
})
}
function resolveAllocatorRootId (list: OrgItem[]): string | number | null {
const p = resolvePlatformOrgId(list)
if (p != null) return p
const roots = list.filter((o) => parentKeyOf(o) == null)
if (roots.length === 1) return roots[0].id
return null
}
interface ApiResponse {
code?: number
list?: OrgItem[]
data?: OrgItem[]
msg?: string
}
function fetchOrgTreeData () {
getOrgListData()
.then((res: any) => {
const data = res.data as ApiResponse
if (!data || data.code !== 0) {
orgFlatList.value = []
sellerTreeData.value = []
allowedSellerIdSet.value = new Set()
return
}
const list = parseOrgListDataBody(data)
orgFlatList.value = list
let ancestorNodes: OrgItem[]
let defaultParentId: string | number | null
if (!userStore.isPlatform) {
const oid = userStore.orgId
if (oid == null || String(oid) === '0') {
sellerTreeData.value = []
allowedSellerIdSet.value = new Set()
return
}
const scopeRoot = resolveDataScopeRootId(list, oid)
if (scopeRoot == null || scopeRoot === '') {
sellerTreeData.value = []
allowedSellerIdSet.value = new Set()
return
}
ancestorNodes = collectSubtreeIncludingSelf(list, scopeRoot)
defaultParentId = oid
} else {
const root = resolveAllocatorRootId(list)
if (root != null) {
ancestorNodes = collectSubtreeIncludingSelf(list, root)
defaultParentId = root
} else {
ancestorNodes = mergeSubtreesFromTopRoots(list)
const roots = list.filter((o) => parentKeyOf(o) == null)
defaultParentId = roots.length ? roots[0].id : null
}
}
const sellerNodes = (ancestorNodes || []).filter(isSellerOrg)
sellerTreeData.value = buildSellerTreeData(ancestorNodes || [])
allowedSellerIdSet.value = new Set(sellerNodes.map((o) => String(o.id)))
if (!sellerNodes.length) {
sellerTreeData.value = []
if (props.modelValue) {
form.allocateParentOrgId = null
form.toChannelOrgId = null
}
return
}
const sellerIdSet = allowedSellerIdSet.value
let defSellerId = defaultParentId
if (!sellerIdSet.has(String(defSellerId))) {
defSellerId = sellerNodes[0].id
}
if (props.modelValue && (form.allocateParentOrgId == null || form.allocateParentOrgId === '')) {
form.allocateParentOrgId = defSellerId
} else if (props.modelValue && form.allocateParentOrgId != null && !sellerIdSet.has(String(form.allocateParentOrgId))) {
form.allocateParentOrgId = defSellerId
form.toChannelOrgId = null
}
})
.catch(() => {
orgFlatList.value = []
sellerTreeData.value = []
allowedSellerIdSet.value = new Set()
})
}
function onClosed () {
form.allocateParentOrgId = null
form.toChannelOrgId = null
form.cardType = 'YEAR'
form.count = 1
}
function submit () {
if (!userStore.isPlatform) {
const oid = userStore.orgId
if (oid == null || String(oid) === '0') {
ElMessage.warning('当前账号未关联组织')
return
}
}
formRef.value?.validate((valid: boolean) => {
if (!valid) return
const allowedA = allowedSellerIdSet.value
if (!allowedA.has(String(form.allocateParentOrgId))) {
ElMessage.warning('销售方须为权限范围内的平台/渠道/代理商')
fetchOrgTreeData()
return
}
const allowedTo = new Set(receiverOptions.value.map((o) => String(o.id)))
if (!allowedTo.has(String(form.toChannelOrgId))) {
ElMessage.warning('购买方须为所选销售方的直属下一级,且不能为门店')
return
}
// 调出方由服务端根据目标组织的可持仓上级解析,仅需 toChannelOrgId / cardType / count
const payload = {
toChannelOrgId: Number(form.toChannelOrgId),
cardType: form.cardType,
count: form.count
}
loading.value = true
allocateCard(payload)
.then(({ data }: { data: ApiResponse & { allocatedCount?: number } }) => {
if (data && data.code === 0) {
ElMessage.success(`分配成功,分配数量:${data.allocatedCount ?? form.count}`)
emit('update:modelValue', false)
emit('success')
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
.catch(() => ElMessage.error('请求失败'))
.finally(() => {
loading.value = false
})
})
}
</script>

View File

@@ -0,0 +1,118 @@
/** 在组织树中查找从根到目标节点的 id 路径 */
export function findPathIdsInTree<T extends { id: string | number; children?: T[] }> (
nodes: T[] | undefined,
targetId: string | number,
path: Array<string | number> = []
): Array<string | number> | null {
if (!nodes?.length) return null
const sid = String(targetId)
for (const n of nodes) {
const next = [...path, n.id]
if (String(n.id) === sid) return next
const sub = findPathIdsInTree(n.children, targetId, next)
if (sub) return sub
}
return null
}
/** 将 id 路径拼成 mpath(如 1/6/23) */
export function buildMpathFromPathIds (pathIds: Array<string | number> | null | undefined): string {
if (!pathIds?.length) return ''
return pathIds.map(String).join('/')
}
/** 解析 mpath 第二级(如 "1/6/23" → "6") */
export function parseMpathSecondLevel (mPath: string | null | undefined): string | null {
if (!mPath || typeof mPath !== 'string') return null
const parts = mPath.split('/').filter(Boolean)
if (parts.length < 2) return null
return parts[1]
}
/** mpath 第二级为 2 或 6 时的 APK 文件名格式提示 */
export interface ApkFileNameFormatHint {
keyPrefix: string
touchPrefix: string
}
export function getApkFileNameFormatHint (secondLevel: string | null): ApkFileNameFormatHint | null {
if (secondLevel === '6') {
return {
keyPrefix: 'ruigeerRemoteRelease',
touchPrefix: 'ruigeerTouchRelease'
}
}
if (secondLevel === '2') {
return {
keyPrefix: 'aishiRemoteRelease',
touchPrefix: 'aishiTouchRelease'
}
}
return null
}
/** 包名是否包含应匹配的前缀(不区分大小写) */
export function apkFileNameHasExpectedPrefix (fileName: string, prefix: string): boolean {
if (!prefix) return true
if (!fileName) return false
return fileName.toLowerCase().includes(prefix.toLowerCase())
}
/** 在包名中高亮应匹配的字符片段 */
export interface ApkFileNameSegment {
text: string
matched: boolean
}
export function splitApkFileNameByPrefix (fileName: string, prefix: string): ApkFileNameSegment[] {
if (!fileName) return [{ text: '-', matched: false }]
if (!prefix) return [{ text: fileName, matched: false }]
const idx = fileName.toLowerCase().indexOf(prefix.toLowerCase())
if (idx === -1) return [{ text: fileName, matched: false }]
const segments: ApkFileNameSegment[] = []
if (idx > 0) segments.push({ text: fileName.slice(0, idx), matched: false })
segments.push({ text: fileName.slice(idx, idx + prefix.length), matched: true })
if (idx + prefix.length < fileName.length) {
segments.push({ text: fileName.slice(idx + prefix.length), matched: false })
}
return segments
}
/** 版本表单 */
export interface VersionForm {
orgId: string | number | null
orgName: string
verCode: string
verName: string
touchUrl: string
keyUrl: string
compulsory: boolean
}
/** 版本信息响应 */
export interface VersionInfoResponse {
code?: number
data?: {
verCode?: string
verName?: string
touchUrl?: string
keyUrl?: string
compulsory?: boolean | string
}
}
/** 上传接口统一响应(外层 code/msg/data) */
export interface UploadResponse {
code?: number
msg?: string
data?: string | {
url?: string
path?: string
storagePath?: string
storage_path?: string
}
url?: string
path?: string
storagePath?: string
storage_path?: string
}

View File

@@ -0,0 +1,726 @@
<template>
<el-dialog
v-model="visible"
title="版本管理"
width="480px"
:close-on-click-modal="false"
@closed="resetForm">
<div v-loading="loading">
<el-form :model="form" label-width="150px">
<el-form-item :label="targetLabel">
<span>{{ form.orgName || '-' }}</span>
</el-form-item>
<el-form-item label="版本号" required>
<el-input v-model="form.verCode" placeholder="请输入版本号" clearable @input="onVerCodeInput" />
</el-form-item>
<el-form-item label="版本名称">
<el-input v-model="form.verName" placeholder="请输入版本名称" clearable />
</el-form-item>
<el-form-item label="触摸版下载地址">
<el-input v-model="form.touchUrl" placeholder="请输入或上传 APK 后自动填充" clearable>
<template #append>
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
accept=".apk"
:disabled="uploadTouchLoading"
:on-change="(f: any) => handleApkUpload(f, 'touch')"
>
<el-button type="primary" size="small" :loading="uploadTouchLoading">上传APK</el-button>
</el-upload>
</template>
</el-input>
</el-form-item>
<el-form-item label="按键版下载地址">
<el-input v-model="form.keyUrl" placeholder="请输入或上传 APK 后自动填充" clearable>
<template #append>
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
accept=".apk"
:disabled="uploadKeyLoading"
:on-change="(f: any) => handleApkUpload(f, 'key')"
>
<el-button type="primary" size="small" :loading="uploadKeyLoading">上传APK</el-button>
</el-upload>
</template>
</el-input>
</el-form-item>
<el-form-item label="是否强制">
<el-switch v-model="form.compulsory" />
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="danger" :loading="deleteLoading" @click="handleDelete">删除版本</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 包名未匹配:额外警告,5 秒后才可继续确认 -->
<el-dialog
v-model="apkMismatchVisible"
title="文件名不匹配警告"
width="520px"
append-to-body
:close-on-click-modal="false"
:show-close="apkMismatchCanProceed"
@closed="resetApkMismatchWarning">
<el-alert type="error" :closable="false" show-icon title="文件名中未包含应匹配的字符" class="apk-mismatch-alert">
<template #default>
当前上传为<strong>{{ apkMismatch.versionLabel }}</strong>,文件名应包含以下字符:
</template>
</el-alert>
<div class="apk-confirm-block apk-confirm-block--match-warn">
<div class="apk-confirm-block__label">应匹配的字符</div>
<div class="apk-confirm-block__value">{{ apkMismatch.expectedPrefix }}</div>
</div>
<div class="apk-confirm-block apk-confirm-block--name apk-confirm-block--name-mismatch">
<div class="apk-confirm-block__label">当前文件名</div>
<div class="apk-confirm-block__value">{{ apkMismatch.fileName }}</div>
</div>
<p class="apk-mismatch-tip">请确认是否仍要继续上传,继续后将进入上传确认流程。</p>
<template #footer>
<el-button @click="cancelApkMismatchWarning">取消上传</el-button>
<el-button type="danger" :disabled="!apkMismatchCanProceed" @click="proceedAfterApkMismatchWarning">
{{ apkMismatchButtonText }}
</el-button>
</template>
</el-dialog>
<!-- APK 上传确认:包名 + 文件名格式(mpath 第二级为 2/6 时)两项勾选,5 秒后可提交 -->
<el-dialog
v-model="apkUploadConfirmVisible"
title="上传确认"
width="540px"
append-to-body
:close-on-click-modal="false"
@closed="resetApkUploadConfirm">
<p class="apk-confirm-tip">请仔细核对以下信息,勾选全部确认项后才可上传:</p>
<el-alert
v-if="apkUploadPrefixMismatched"
type="error"
:closable="false"
show-icon
class="apk-confirm-mismatch-alert"
title="文件名中未包含应匹配的字符,请再次核对" />
<div class="apk-confirm-block apk-confirm-block--name" :class="{ 'apk-confirm-block--name-mismatch': apkUploadPrefixMismatched }">
<div class="apk-confirm-block__label">文件名</div>
<div class="apk-confirm-block__value">
<template v-for="(seg, index) in apkUploadFileNameSegments" :key="index">
<span v-if="seg.matched" class="apk-confirm-block__match">{{ seg.text }}</span>
<span v-else>{{ seg.text }}</span>
</template>
</div>
</div>
<el-checkbox v-model="apkUploadConfirm.packageNameChecked" class="apk-confirm-check">
我已确认以上文件名正确
</el-checkbox>
<template v-if="apkUploadFormatHint">
<div class="apk-confirm-block apk-confirm-block--format">
<div class="apk-confirm-block__label">应使用的文件名格式</div>
<div class="apk-confirm-format-row" :class="{ 'is-current': apkUploadConfirm.which === 'key' }">
<span class="apk-confirm-format-row__type">按键版</span>
<span class="apk-confirm-format-row__name">{{ apkUploadFormatHint.keyPrefix }}</span>
</div>
<div class="apk-confirm-format-row" :class="{ 'is-current': apkUploadConfirm.which === 'touch' }">
<span class="apk-confirm-format-row__type">触摸版</span>
<span class="apk-confirm-format-row__name">{{ apkUploadFormatHint.touchPrefix }}</span>
</div>
</div>
<el-checkbox v-model="apkUploadConfirm.formatNameChecked" class="apk-confirm-check">
我已确认当前上传({{ apkUploadConfirm.versionLabel }})的文件名格式正确
</el-checkbox>
</template>
<p class="apk-confirm-target">
上传至 <strong>{{ apkUploadConfirm.versionLabel }}</strong>
</p>
<template #footer>
<el-button @click="apkUploadConfirmVisible = false">取消</el-button>
<el-button type="primary" :disabled="!apkUploadCanSubmit" @click="submitApkUploadConfirm">
{{ apkUploadConfirmButtonText }}
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts">
export default {
name: 'OrgVersionDialog'
}
</script>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getOrgVersionInfo, saveOrgVersion, deleteOrgVersion, getOrgInfo } from '@/api/org'
import type { OrgVersionInfo } from '@/api/org'
import { uploadOssKeepFileName } from '@/api/sys'
import { uploadedUrlKeepsFileName } from '@/utils/ossCosUpload'
import type { ApiResponse } from '@/types/api'
import type { VersionForm, UploadResponse } from './org-version-dialog.data'
import {
parseMpathSecondLevel,
getApkFileNameFormatHint,
apkFileNameHasExpectedPrefix,
splitApkFileNameByPrefix
} from './org-version-dialog.data'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
orgId: {
type: [Number, String],
default: null
},
orgName: {
type: String,
default: ''
},
targetLabel: {
type: String,
default: '组织'
},
mPath: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue', 'success'])
const visible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val)
})
const loading = ref(false)
const submitLoading = ref(false)
const deleteLoading = ref(false)
const uploadTouchLoading = ref(false)
const uploadKeyLoading = ref(false)
const orgMPath = ref('')
const form = reactive<VersionForm>({
orgId: null,
orgName: '',
verCode: '',
verName: '',
touchUrl: '',
keyUrl: '',
compulsory: false
})
function resetForm () {
form.orgId = null
form.orgName = ''
form.verCode = ''
form.verName = ''
form.touchUrl = ''
form.keyUrl = ''
form.compulsory = false
}
function onVerCodeInput (val: string) {
form.verCode = val.replace(/[^0-9]/g, '')
}
/** 从上传接口响应中解析存储路径(兼容多种后端字段) */
function pickStoragePath (payload: UploadResponse): string {
if (!payload || typeof payload !== 'object') return ''
const d = payload.data
if (typeof d === 'string' && d.trim()) return d.trim()
if (d && typeof d === 'object') {
const p = d.url ?? d.path ?? d.storagePath ?? d.storage_path ?? ''
return typeof p === 'string' ? p.trim() : ''
}
const top = payload.url ?? payload.path ?? payload.storagePath ?? payload.storage_path ?? ''
return typeof top === 'string' ? top.trim() : ''
}
const APK_UPLOAD_CONFIRM_DELAY_SEC = 5
const APK_MISMATCH_WARN_DELAY_SEC = 5
const apkMismatchVisible = ref(false)
const apkMismatchSecondsLeft = ref(APK_MISMATCH_WARN_DELAY_SEC)
const apkMismatchTimer = ref<ReturnType<typeof setInterval> | null>(null)
const apkMismatch = reactive({
fileName: '',
versionLabel: '',
expectedPrefix: '',
which: 'touch' as 'touch' | 'key',
rawFile: null as File | null
})
const apkUploadConfirmVisible = ref(false)
const apkUploadConfirmSecondsLeft = ref(APK_UPLOAD_CONFIRM_DELAY_SEC)
const apkUploadConfirmTimer = ref<ReturnType<typeof setInterval> | null>(null)
const apkUploadConfirm = reactive({
fileName: '',
versionLabel: '',
which: 'touch' as 'touch' | 'key',
packageNameChecked: false,
formatNameChecked: false,
rawFile: null as File | null
})
const apkUploadFormatHint = computed(() =>
getApkFileNameFormatHint(parseMpathSecondLevel(orgMPath.value))
)
/** 当前上传类型应出现在包名中的匹配字符 */
const apkUploadExpectedPrefix = computed(() => {
const hint = apkUploadFormatHint.value
if (!hint) return ''
return apkUploadConfirm.which === 'key' ? hint.keyPrefix : hint.touchPrefix
})
const apkUploadFileNameSegments = computed(() =>
splitApkFileNameByPrefix(apkUploadConfirm.fileName, apkUploadExpectedPrefix.value)
)
const apkUploadPrefixMismatched = computed(() => {
if (!apkUploadExpectedPrefix.value) return false
return !apkFileNameHasExpectedPrefix(apkUploadConfirm.fileName, apkUploadExpectedPrefix.value)
})
const apkMismatchCanProceed = computed(() => apkMismatchSecondsLeft.value <= 0)
const apkMismatchButtonText = computed(() => {
if (apkMismatchCanProceed.value) return '我已知晓,继续确认'
return `我已知晓,继续确认 (${apkMismatchSecondsLeft.value}s)`
})
function resolveExpectedPrefix (which: 'touch' | 'key'): string {
const hint = getApkFileNameFormatHint(parseMpathSecondLevel(orgMPath.value))
if (!hint) return ''
return which === 'key' ? hint.keyPrefix : hint.touchPrefix
}
const apkUploadCanSubmit = computed(() => {
if (apkUploadConfirmSecondsLeft.value > 0) return false
if (!apkUploadConfirm.packageNameChecked) return false
if (apkUploadFormatHint.value && !apkUploadConfirm.formatNameChecked) return false
return true
})
const apkUploadConfirmButtonText = computed(() => {
if (apkUploadConfirmSecondsLeft.value > 0) {
return `确定上传 (${apkUploadConfirmSecondsLeft.value}s)`
}
if (!apkUploadConfirm.packageNameChecked) return '请先确认包名'
if (apkUploadFormatHint.value && !apkUploadConfirm.formatNameChecked) return '请先确认文件名格式'
return '确定上传'
})
function clearApkMismatchTimer () {
if (apkMismatchTimer.value) {
clearInterval(apkMismatchTimer.value)
apkMismatchTimer.value = null
}
}
function startApkMismatchTimer () {
clearApkMismatchTimer()
apkMismatchSecondsLeft.value = APK_MISMATCH_WARN_DELAY_SEC
apkMismatchTimer.value = setInterval(() => {
if (apkMismatchSecondsLeft.value <= 1) {
apkMismatchSecondsLeft.value = 0
clearApkMismatchTimer()
return
}
apkMismatchSecondsLeft.value -= 1
}, 1000)
}
function resetApkMismatchWarning () {
clearApkMismatchTimer()
apkMismatchSecondsLeft.value = APK_MISMATCH_WARN_DELAY_SEC
apkMismatch.fileName = ''
apkMismatch.versionLabel = ''
apkMismatch.expectedPrefix = ''
apkMismatch.which = 'touch'
apkMismatch.rawFile = null
}
function openApkMismatchWarning (raw: File, which: 'touch' | 'key', expectedPrefix: string) {
apkMismatch.fileName = raw.name
apkMismatch.which = which
apkMismatch.versionLabel = which === 'touch' ? '触摸版' : '按键版'
apkMismatch.expectedPrefix = expectedPrefix
apkMismatch.rawFile = raw
apkMismatchVisible.value = true
startApkMismatchTimer()
}
function cancelApkMismatchWarning () {
apkMismatchVisible.value = false
}
function proceedAfterApkMismatchWarning () {
if (!apkMismatchCanProceed.value || !apkMismatch.rawFile) return
const raw = apkMismatch.rawFile
const which = apkMismatch.which
apkMismatchVisible.value = false
openApkUploadConfirm(raw, which)
}
function clearApkUploadConfirmTimer () {
if (apkUploadConfirmTimer.value) {
clearInterval(apkUploadConfirmTimer.value)
apkUploadConfirmTimer.value = null
}
}
function startApkUploadConfirmTimer () {
clearApkUploadConfirmTimer()
apkUploadConfirmSecondsLeft.value = APK_UPLOAD_CONFIRM_DELAY_SEC
apkUploadConfirmTimer.value = setInterval(() => {
if (apkUploadConfirmSecondsLeft.value <= 1) {
apkUploadConfirmSecondsLeft.value = 0
clearApkUploadConfirmTimer()
return
}
apkUploadConfirmSecondsLeft.value -= 1
}, 1000)
}
function resetApkUploadConfirm () {
clearApkUploadConfirmTimer()
apkUploadConfirmSecondsLeft.value = APK_UPLOAD_CONFIRM_DELAY_SEC
apkUploadConfirm.fileName = ''
apkUploadConfirm.versionLabel = ''
apkUploadConfirm.which = 'touch'
apkUploadConfirm.packageNameChecked = false
apkUploadConfirm.formatNameChecked = false
apkUploadConfirm.rawFile = null
}
function openApkUploadConfirm (raw: File, which: 'touch' | 'key') {
apkUploadConfirm.fileName = raw.name
apkUploadConfirm.which = which
apkUploadConfirm.versionLabel = which === 'touch' ? '触摸版' : '按键版'
apkUploadConfirm.packageNameChecked = false
apkUploadConfirm.formatNameChecked = false
apkUploadConfirm.rawFile = raw
apkUploadConfirmVisible.value = true
startApkUploadConfirmTimer()
}
function submitApkUploadConfirm () {
if (!apkUploadCanSubmit.value || !apkUploadConfirm.rawFile) return
const raw = apkUploadConfirm.rawFile
const which = apkUploadConfirm.which
apkUploadConfirmVisible.value = false
doApkUpload(raw, which)
}
function doApkUpload (raw: File, which: 'touch' | 'key') {
const loadingRef = which === 'touch' ? uploadTouchLoading : uploadKeyLoading
loadingRef.value = true
uploadOssKeepFileName(raw)
.then((res: UploadResponse) => {
if (res && res.code === 0) {
const path = pickStoragePath(res)
if (path) {
if (which === 'touch') form.touchUrl = path
else form.keyUrl = path
if (!uploadedUrlKeepsFileName(path, raw.name)) {
ElMessage.warning(`上传成功,但地址未保留原始文件名「${raw.name}」,请确认后端 OSS 接口支持 keepOriginalFileName`)
} else {
ElMessage.success('上传成功')
}
} else {
ElMessage.error('未返回存储路径')
}
} else {
ElMessage.error(res?.msg || '上传失败')
}
})
.catch(() => ElMessage.error('上传失败'))
.finally(() => { loadingRef.value = false })
}
function handleApkUpload (file: { raw?: File }, which: 'touch' | 'key') {
const raw = file?.raw
if (!raw) return
const name = (raw.name || '').toLowerCase()
if (!name.endsWith('.apk')) {
ElMessage.warning('请选择 APK 文件')
return
}
const expectedPrefix = resolveExpectedPrefix(which)
if (expectedPrefix && !apkFileNameHasExpectedPrefix(raw.name, expectedPrefix)) {
openApkMismatchWarning(raw, which, expectedPrefix)
return
}
openApkUploadConfirm(raw, which)
}
function loadOrgMPath () {
orgMPath.value = props.mPath || ''
if (orgMPath.value || !props.orgId) {
return Promise.resolve()
}
return getOrgInfo(Number(props.orgId))
.then((res: any) => {
const data = res?.data
const info = (data && data.code === 0 && data.org)
? data.org
: (data?.data || data?.org || data)
const fromApi = String(info?.mPath ?? info?.mpath ?? '')
if (fromApi) orgMPath.value = fromApi
})
.catch(() => {})
}
function extractVersionInfoFromResponse (res: unknown): Partial<OrgVersionInfo> {
const axiosLike = res as { data?: ApiResponse<OrgVersionInfo> }
const body = axiosLike.data && typeof axiosLike.data === 'object' && 'code' in axiosLike.data
? axiosLike.data
: (res as ApiResponse<OrgVersionInfo>)
return body?.code === 0 && body.data ? body.data : {}
}
function loadVersionInfo () {
if (!props.orgId) return
loading.value = true
Promise.all([
getOrgVersionInfo(Number(props.orgId)),
loadOrgMPath()
])
.then(([versionRes]) => {
const info = extractVersionInfoFromResponse(versionRes)
form.verCode = info.verCode ?? ''
form.verName = info.verName ?? ''
form.touchUrl = info.touchUrl ?? ''
form.keyUrl = info.keyUrl ?? ''
form.compulsory = info.compulsory === true || String(info.compulsory ?? '') === 'true'
})
.catch(() => {})
.finally(() => { loading.value = false })
}
function handleDelete () {
if (!form.orgId) return
ElMessageBox.confirm(`确定删除该${props.targetLabel}的版本配置?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteLoading.value = true
deleteOrgVersion(Number(form.orgId))
.then(({ data }) => {
if (data && data.code === 0) {
ElMessage.success('删除成功')
visible.value = false
emit('success')
} else {
ElMessage.error(data?.msg || '删除失败')
}
})
.catch(() => ElMessage.error('请求失败'))
.finally(() => { deleteLoading.value = false })
}).catch(() => {})
}
function handleSubmit () {
const verCode = (form.verCode || '').trim()
if (!verCode) {
ElMessage.warning('请输入版本号')
return
}
submitLoading.value = true
saveOrgVersion({
orgId: Number(form.orgId),
verCode,
verName: (form.verName || '').trim() || undefined,
touchUrl: (form.touchUrl || '').trim(),
keyUrl: (form.keyUrl || '').trim(),
compulsory: !!form.compulsory
})
.then(({ data }) => {
if (data && data.code === 0) {
ElMessage.success('操作成功')
visible.value = false
emit('success')
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
.catch(() => ElMessage.error('请求失败'))
.finally(() => { submitLoading.value = false })
}
watch(() => props.modelValue, (val) => {
if (val && props.orgId) {
form.orgId = props.orgId
form.orgName = props.orgName || '-'
orgMPath.value = props.mPath || ''
form.verCode = ''
form.verName = ''
form.touchUrl = ''
form.keyUrl = ''
form.compulsory = false
loadVersionInfo()
}
})
</script>
<style scoped lang="scss">
.apk-confirm-tip {
margin: 0 0 12px;
color: var(--el-text-color-regular);
font-size: 14px;
}
.apk-confirm-mismatch-alert {
margin-bottom: 12px;
}
.apk-mismatch-alert {
margin-bottom: 12px;
}
.apk-mismatch-tip {
margin: 12px 0 0;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.5;
}
.apk-confirm-block {
margin-bottom: 12px;
padding: 14px 16px;
border-radius: 8px;
text-align: center;
&__label {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 1px;
}
&__value {
font-size: 22px;
font-weight: 700;
word-break: break-all;
line-height: 1.4;
}
&--name {
background: #fef0f0;
border: 2px solid #f56c6c;
.apk-confirm-block__label {
color: #f56c6c;
}
.apk-confirm-block__value {
color: #c45656;
}
}
&--name-mismatch {
border-color: #f56c6c;
box-shadow: 0 0 0 1px rgba(245, 108, 108, 0.35);
}
&--match-warn {
background: #ecf5ff;
border: 2px solid #409eff;
.apk-confirm-block__label {
color: #409eff;
}
.apk-confirm-block__value {
color: #337ecc;
}
}
&__match {
color: #409eff;
background: rgba(64, 158, 255, 0.18);
padding: 0 4px;
border-radius: 4px;
box-decoration-break: clone;
}
&--format {
background: #fdf6ec;
border: 2px solid #e6a23c;
.apk-confirm-block__label {
color: #e6a23c;
}
}
}
.apk-confirm-format-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
padding: 8px 10px;
border-radius: 6px;
&__type {
min-width: 52px;
color: var(--el-text-color-regular);
font-size: 14px;
}
&__name {
color: #b88230;
font-size: 18px;
font-weight: 700;
word-break: break-all;
}
&.is-current {
background: rgba(230, 162, 60, 0.16);
outline: 2px solid #e6a23c;
.apk-confirm-format-row__name {
color: #d48806;
font-size: 20px;
}
}
}
.apk-confirm-check {
display: flex;
align-items: flex-start;
height: auto;
margin: 0 0 14px;
white-space: normal;
:deep(.el-checkbox__label) {
font-weight: 600;
line-height: 1.5;
}
}
.apk-confirm-target {
margin: 4px 0 0;
color: var(--el-text-color-regular);
font-size: 14px;
strong {
color: var(--el-color-primary);
font-size: 15px;
}
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<el-dialog
:model-value="modelValue"
title="卡分配/销售"
width="520px"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="onClosed">
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="购买方" prop="toChannelOrgId">
<el-tree-select
v-model="form.toChannelOrgId"
:data="orgTreeData"
:props="{ label: 'label', value: 'id' }"
node-key="id"
placeholder="平台/渠道/代理商,不含门店"
filterable
style="width: 100%"
:render-after-expand="false"
default-expand-all
check-strictly=true
@focus="loadOrgTree" />
</el-form-item>
<el-form-item label="卡类型" prop="cardType">
<el-select v-model="form.cardType" placeholder="请选择" style="width: 100%">
<el-option label="年卡" value="YEAR" />
<el-option label="季卡" value="QUARTER" />
<el-option label="月卡" value="MONTH" />
<el-option label="周卡" value="WEEK" />
<el-option label="天卡" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="分配数量" prop="count">
<el-input-number v-model="form.count" :min="1" :max="9999" controls-position="right" style="width: 160px" />
</el-form-item>
<el-form-item label="激活码(可选)" prop="activationCode">
<el-input v-model="form.activationCode" placeholder="分配到门店且不填时系统自动生成" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="submit">提交分配/销售</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getOrgListData, parseOrgListDataBody } from '@/api/org'
import { allocateCard } from '@/api/cardFlow'
import { treeDataTranslate } from '@/utils'
defineOptions({ name: 'ShopAllocateDialog' })
const props = defineProps({
modelValue: { type: Boolean, default: false },
/** 打开时默认选中的目标组织(如当前选中的渠道/代理商 id) */
defaultToOrgId: { type: [String, Number], default: null }
})
const emit = defineEmits(['update:modelValue', 'success'])
const orgTreeData = ref([])
/** 完整扁平列表,提交时校验购买方类型 */
const orgFlatForValidate = ref([])
const form = reactive({
toChannelOrgId: null,
cardType: 'YEAR',
count: 1,
activationCode: ''
})
const rules = {
toChannelOrgId: [{ required: true, message: '请选择购买方(不能为门店)', trigger: 'change' }],
cardType: [{ required: true, message: '请选择卡类型', trigger: 'change' }],
count: [{ required: true, message: '请输入分配数量', trigger: 'blur' }]
}
const loading = ref(false)
const formRef = ref(null)
const typeMap = { PLATFORM: '平台', CHANNEL: '渠道', SERVICE_PROVIDER: '代理商', STORE: '门店' }
watch(() => [props.modelValue, props.defaultToOrgId], ([visible, defaultId]) => {
if (visible) {
form.toChannelOrgId = defaultId != null && defaultId !== '' ? defaultId : null
form.cardType = 'YEAR'
form.count = 1
form.activationCode = ''
}
})
/** 为树节点递归添加 label(name + 类型),供 el-tree-select 展示 */
function normalizeTreeLabel (nodes) {
if (!nodes || !Array.isArray(nodes)) return []
return nodes.map(node => {
const typeLabel = typeMap[String(node.type || '').toUpperCase()] || node.type || '-'
const label = `${node.name || '-'}(${typeLabel})`
const item = { ...node, label }
if (node.children && node.children.length > 0) {
item.children = normalizeTreeLabel(node.children)
}
return item
})
}
/** 组织列表(按数据范围)返回扁平 list,用 parentId 转成树供 el-tree-select 使用 */
function loadOrgTree () {
if (orgTreeData.value.length > 0) return
getOrgListData()
.then(({ data }) => {
if (!data || data.code !== 0) {
orgTreeData.value = []
return
}
const list = parseOrgListDataBody(data)
orgFlatForValidate.value = list
const noStores = list.filter((o) => String(o.type || '').toUpperCase() !== 'STORE')
const tree = treeDataTranslate(noStores, 'id', 'parentId')
orgTreeData.value = normalizeTreeLabel(tree)
if (form.toChannelOrgId != null && form.toChannelOrgId !== '') {
const allowed = new Set()
function walk (nodes) {
if (!nodes || !Array.isArray(nodes)) return
for (const n of nodes) {
allowed.add(String(n.id))
walk(n.children)
}
}
walk(orgTreeData.value)
if (!allowed.has(String(form.toChannelOrgId))) form.toChannelOrgId = null
}
})
.catch(() => { orgTreeData.value = [] })
}
function onClosed () {
form.toChannelOrgId = null
form.cardType = 'YEAR'
form.count = 1
form.activationCode = ''
}
function submit () {
formRef.value?.validate((valid) => {
if (!valid) return
const pick = orgFlatForValidate.value.find((n) => String(n.id) === String(form.toChannelOrgId))
if (pick && String(pick.type || '').toUpperCase() === 'STORE') {
ElMessage.warning('购买方不能为门店')
return
}
const payload = {
toChannelOrgId: Number(form.toChannelOrgId),
cardType: form.cardType,
count: form.count
}
if (form.activationCode && String(form.activationCode).trim()) {
payload.activationCode = form.activationCode.trim()
}
loading.value = true
allocateCard(payload)
.then(({ data }) => {
if (data && data.code === 0) {
ElMessage.success(`操作成功,订单号:${data.orderNo || '-'},分配数量:${data.allocatedCount ?? '-'}`)
emit('update:modelValue', false)
emit('success')
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
.catch(() => ElMessage.error('请求失败'))
.finally(() => { loading.value = false })
})
}
</script>

View File

@@ -0,0 +1,15 @@
/** 总统计数据 */
export interface TotalStats {
allocatableCount: number
activatedCount: number
pendingActivationCount: number
}
/** 按类型统计 */
export interface ByTypeStats {
cardType: string
cardTypeName: string
allocatableCount: number
activatedCount: number
pendingActivationCount: number
}

View File

@@ -0,0 +1,426 @@
<template>
<div v-if="visible" class="shop-card-stats">
<div class="stats-header" @click="toggleExpand">
<div class="stats-title">{{ label }}</div>
<div class="stats-toggle-btn">
<el-icon :class="{ 'is-expanded': isExpanded }">
<ArrowDown />
</el-icon>
</div>
</div>
<el-collapse-transition>
<div v-show="isExpanded">
<el-row :gutter="12">
<!-- 可分配 -->
<el-col :xs="24" :sm="8" :md="8" :lg="8">
<div class="stats-card stats-card--allocatable">
<div class="stats-card-head">
<span class="stats-card-label">可分配</span>
</div>
<div class="stats-card-value-row">
<span v-if="!loading" class="stats-card-num">{{ formatNum(totalStats.allocatableCount) }}</span>
<span v-else class="stats-card-num stats-card-num--loading">—</span>
<span class="stats-card-unit">张</span>
</div>
</div>
</el-col>
<!-- 已激活 -->
<el-col :xs="24" :sm="8" :md="8" :lg="8">
<div class="stats-card stats-card--activated">
<div class="stats-card-head">
<span class="stats-card-label">已激活</span>
</div>
<div class="stats-card-value-row">
<span v-if="!loading" class="stats-card-num">{{ formatNum(totalStats.activatedCount) }}</span>
<span v-else class="stats-card-num stats-card-num--loading">—</span>
<span class="stats-card-unit">张</span>
</div>
</div>
</el-col>
<!-- 待激活 -->
<el-col :xs="24" :sm="8" :md="8" :lg="8">
<div class="stats-card stats-card--pending">
<div class="stats-card-head">
<span class="stats-card-label">待激活</span>
</div>
<div class="stats-card-value-row">
<span v-if="!loading" class="stats-card-num">{{ formatNum(totalStats.pendingActivationCount) }}</span>
<span v-else class="stats-card-num stats-card-num--loading">—</span>
<span class="stats-card-unit">张</span>
</div>
</div>
</el-col>
</el-row>
</div>
</el-collapse-transition>
<!-- 按卡类型统计 -->
<div v-if="byTypeStats.length > 0" class="stats-by-type">
<div class="stats-by-type-title">按卡类型统计</div>
<div class="stats-by-type-grid">
<div
v-for="item in byTypeStats"
:key="item.cardType"
class="stats-type-card">
<div class="stats-type-card-header">
<span class="stats-type-name">{{ item.cardTypeName }}</span>
</div>
<div class="stats-type-card-body">
<div class="stats-type-item">
<span class="stats-type-item-label">可分配</span>
<span v-if="!loading" class="stats-type-item-value">{{ formatNum(item.allocatableCount) }}</span>
<span v-else class="stats-type-item-value stats-type-item-value--loading">—</span>
</div>
<div class="stats-type-item">
<span class="stats-type-item-label">已激活</span>
<span v-if="!loading" class="stats-type-item-value">{{ formatNum(item.activatedCount) }}</span>
<span v-else class="stats-type-item-value stats-type-item-value--loading">—</span>
</div>
<div class="stats-type-item">
<span class="stats-type-item-label">待激活</span>
<span v-if="!loading" class="stats-type-item-value">{{ formatNum(item.pendingActivationCount) }}</span>
<span v-else class="stats-type-item-value stats-type-item-value--loading">—</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ByTypeStats } from './shop-card-stats.data'
interface CardStatsByOrg {
[key: string]: {
cardType?: string
cardTypeName?: string
totalCount?: number
activatedCount?: number
pendingActivationCount?: number
allocatableCount?: number
}
}
defineOptions({ name: 'ShopCardStats' })
const isExpanded = ref(false)
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
}
const props = defineProps({
visible: {
type: Boolean,
default: false
},
label: {
type: String,
default: '当前组织卡统计'
},
loading: {
type: Boolean,
default: false
},
totalStats: {
type: Object,
default: () => ({
allocatableCount: 0,
activatedCount: 0,
pendingActivationCount: 0
})
},
byTypeStats: {
type: Array as () => ByTypeStats[],
default: () => []
},
cardStatsByOrg: { type: Object as () => CardStatsByOrg, required: true }
})
function formatNum(n: number | string): string {
const v = Number(n)
if (Number.isNaN(v)) return '0'
return Math.trunc(v).toLocaleString('zh-CN')
}
</script>
<style lang="scss" scoped>
.shop-card-stats {
padding: 8px 12px;
background: linear-gradient(180deg, #fbfdff 0%, #f7f9fc 100%);
border-radius: 6px;
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
&:hover {
.stats-toggle-btn {
color: #409eff;
}
}
}
.stats-title {
color: #303133;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.2px;
}
.stats-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: #909399;
transition: color 0.2s ease;
.el-icon {
transition: transform 0.3s ease;
&.is-expanded {
transform: rotate(180deg);
}
}
}
.el-collapse-transition + div,
.el-collapse-transition > div {
margin-top: 8px;
}
.stats-card {
position: relative;
background: #ffffff;
border: 1px solid #e6ebf2;
border-radius: 8px;
padding: 10px 12px;
min-height: 70px;
box-sizing: border-box;
box-shadow: 0 4px 12px rgba(19, 33, 68, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(19, 33, 68, 0.08);
}
}
.stats-card--allocatable {
border-top: 3px solid #409eff;
}
.stats-card--activated {
border-top: 3px solid #67c23a;
}
.stats-card--pending {
border-top: 3px solid #e6a23c;
}
.stats-card-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.stats-card-label {
font-size: 12px;
color: #303133;
font-weight: 600;
}
.stats-card-value-row {
display: flex;
align-items: baseline;
gap: 4px;
}
.stats-card-num {
font-size: 20px;
font-weight: 700;
color: #303133;
line-height: 1.1;
letter-spacing: -0.01em;
&--loading {
color: #c0c4cc;
}
}
.stats-card-unit {
font-size: 12px;
color: #909399;
}
.stats-by-type {
margin-top: 12px;
padding: 12px;
background: #ffffff;
border-radius: 8px;
border: 1px solid #e6ebf2;
box-shadow: 0 4px 12px rgba(19, 33, 68, 0.04);
}
.stats-by-type-title {
color: #303133;
font-size: 13px;
font-weight: 600;
margin-bottom: 12px;
padding-left: 8px;
border-left: 4px solid #409eff;
}
.stats-by-type-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.stats-type-card {
position: relative;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #e6ebf2;
border-radius: 8px;
padding: 12px;
box-shadow: 0 4px 12px rgba(19, 33, 68, 0.06);
transition: all 0.3s ease;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #409eff 0%, #67c23a 50%, #e6a23c 100%);
opacity: 0.8;
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(19, 33, 68, 0.12);
border-color: #d0d7e0;
&::before {
opacity: 1;
}
}
}
.stats-type-card-header {
margin-bottom: 8px;
text-align: center;
}
.stats-type-name {
font-size: 13px;
font-weight: 700;
color: #303133;
letter-spacing: 0.5px;
}
.stats-type-card-body {
display: flex;
justify-content: space-between;
gap: 6px;
}
.stats-type-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 1;
padding: 6px 4px;
background: rgba(64, 158, 255, 0.04);
border-radius: 6px;
transition: background 0.2s ease;
&:hover {
background: rgba(64, 158, 255, 0.08);
}
&:nth-child(2) {
background: rgba(103, 194, 58, 0.04);
&:hover {
background: rgba(103, 194, 58, 0.08);
}
}
&:nth-child(3) {
background: rgba(230, 162, 60, 0.04);
&:hover {
background: rgba(230, 162, 60, 0.08);
}
}
}
.stats-type-item-label {
font-size: 10px;
color: #606266;
font-weight: 500;
}
.stats-type-item-value {
font-size: 14px;
font-weight: 700;
color: #303133;
&--loading {
color: #c0c4cc;
}
}
@media (max-width: 1200px) {
.stats-by-type-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 992px) {
.stats-by-type-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.stats-card {
margin-bottom: 12px;
}
.stats-by-type-grid {
grid-template-columns: repeat(2, 1fr);
}
.stats-type-card-body {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.stats-type-item {
flex-direction: row;
justify-content: space-between;
width: 100%;
}
}
@media (max-width: 480px) {
.stats-by-type-grid {
grid-template-columns: 1fr;
}
}
}
</style>

View File

@@ -0,0 +1,25 @@
/** 卡查询表单 */
export interface CardsForm {
cardNo: string
cardType: string
inventoryStatus: string
usageStatus: string
}
/** 卡数据 */
export interface CardItem {
id: string | number
cardNo?: string
cardType?: string
usageStatus?: string
expireAt?: string
}
/** 卡列表响应 */
export interface CardsResponse {
code?: number
page?: {
list?: CardItem[]
totalCount?: number
}
}

View File

@@ -0,0 +1,200 @@
<template>
<el-dialog
:model-value="modelValue"
:title="`门店绑定卡 - ${storeName || ''}`"
width="900px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
@closed="onClosed">
<el-form :inline="true" :model="cardsForm" @keyup.enter="getList()" style="margin-bottom: 12px;">
<el-form-item>
<el-input v-model="cardsForm.cardNo" placeholder="卡号" clearable style="width: 140px" />
</el-form-item>
<el-form-item>
<el-select v-model="cardsForm.cardType" placeholder="卡类型" clearable style="width: 100px">
<el-option label="年" value="YEAR" />
<el-option label="季" value="QUARTER" />
<el-option label="月" value="MONTH" />
<el-option label="周" value="WEEK" />
<el-option label="天" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item>
<el-select v-model="cardsForm.usageStatus" placeholder="使用状态" clearable style="width: 110px">
<el-option label="未使用" value="UNUSED" />
<el-option label="已绑定" value="BOUND" />
<el-option label="已生效" value="ACTIVE" />
<el-option label="已过期" value="EXPIRED" />
<el-option label="激活失败" value="ACTIVATION_FAILED" />
<el-option label="激活中" value="ACTIVATING" />
<el-option label="待迁移" value="WAITING_MIGRATION" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="getList()">查询</el-button>
</el-form-item>
</el-form>
<el-table :data="cardsList" border v-loading="loading" row-key="id" max-height="400">
<el-table-column header-align="center" align="center" width="70" label="序号">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="cardNo" header-align="center" align="center" min-width="110" label="卡号" show-overflow-tooltip>
<template #default="{ row }">
<div class="copy-card-no-cell" title="点击复制卡号" @click="copyCardNo(row)">
{{ row.cardNo || '-' }}
</div>
</template>
</el-table-column>
<el-table-column prop="cardType" header-align="center" align="center" width="80" label="卡类型">
<template #default="scope">
<el-tag v-if="scope.row.cardType === 'YEAR'" size="small">年</el-tag>
<el-tag v-else-if="scope.row.cardType === 'QUARTER'" size="small" type="success">季</el-tag>
<el-tag v-else-if="scope.row.cardType === 'MONTH'" size="small" type="warning">月</el-tag>
<el-tag v-else-if="scope.row.cardType === 'WEEK'" size="small" type="info">周</el-tag>
<span v-else>{{ scope.row.cardType === "OTHER" ? "天" : '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="usageStatus" header-align="center" align="center" width="90" label="使用状态">
<template #default="scope">
<el-tag v-if="scope.row.usageStatus === 'UNUSED'" size="small">未使用</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'BOUND'" size="small" type="info">已绑定</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'ACTIVE'" size="small" type="success">已激活</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'EXPIRED'" size="small" type="danger">已过期</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'ACTIVATION_FAILED'" size="small" type="danger">激活失败</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'ACTIVATING'" size="small" type="warning">激活中</el-tag>
<el-tag v-else-if="scope.row.usageStatus === 'WAITING_MIGRATION'" size="small" type="primary">待迁移</el-tag>
<span v-else>{{ scope.row.usageStatus || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="expireAt" header-align="center" align="center" width="165" label="过期时间">
<template #default="scope">
{{ scope.row.expireAt || '-' }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="totalPage > 0"
@size-change="onSizeChange"
@current-change="onCurrentChange"
:current-page="pageIndex"
:page-sizes="[10, 20, 50]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next"
style="margin-top: 12px;" />
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getStoreCards } from '@/api/store'
import type { CardsForm, CardItem, CardsResponse } from './shop-cards-dialog.data'
defineOptions({ name: 'ShopCardsDialog' })
const props = defineProps({
modelValue: { type: Boolean, default: false },
storeId: { type: [String, Number], default: null },
storeName: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const cardsForm = reactive<CardsForm>({
cardNo: '',
cardType: '',
inventoryStatus: '',
usageStatus: ''
})
const cardsList = ref<CardItem[]>([])
const pageIndex = ref(1)
const pageSize = ref(10)
const totalPage = ref(0)
const loading = ref(false)
watch(() => [props.modelValue, props.storeId], ([visible, storeId]) => {
if (visible && storeId) {
cardsForm.cardNo = ''
cardsForm.cardType = ''
cardsForm.inventoryStatus = ''
cardsForm.usageStatus = ''
pageIndex.value = 1
getList()
}
})
function getList () {
if (!props.storeId) return
loading.value = true
const params = {
page: pageIndex.value,
limit: pageSize.value,
cardNo: cardsForm.cardNo || undefined,
cardType: cardsForm.cardType || undefined,
inventoryStatus: cardsForm.inventoryStatus || undefined,
usageStatus: cardsForm.usageStatus || undefined
}
getStoreCards(Number(props.storeId), params)
.then(({ data }: { data: CardsResponse }) => {
if (data && data.code === 0 && data.page) {
cardsList.value = data.page.list || []
totalPage.value = data.page.totalCount || 0
} else {
cardsList.value = []
totalPage.value = 0
}
})
.catch(() => {})
.finally(() => { loading.value = false })
}
function onSizeChange (val: number) {
pageSize.value = val
pageIndex.value = 1
getList()
}
function onCurrentChange (val: number) {
pageIndex.value = val
getList()
}
function onClosed () {
cardsList.value = []
}
async function copyCardNo (row: CardItem) {
const text = String(row?.cardNo || '').trim()
if (!text || text === '-') {
ElMessage.warning('暂无卡号可复制')
return
}
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'readonly')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
ElMessage.success(`已复制:${text}`)
} catch (err: any) {
ElMessage.error(err?.message || '复制失败,请手动复制')
}
}
</script>
<style scoped>
.copy-card-no-cell {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,8 @@
/** 渠道代理商树节点 */
export interface ChannelAgentNode {
id: string | number
name: string
type: string
disabled?: boolean
children?: ChannelAgentNode[]
}

View File

@@ -0,0 +1,142 @@
<template>
<div class="shop-left">
<div class="shop-left-header">
<span>渠道 / 代理商</span>
</div>
<div class="shop-left-body" v-loading="loading">
<div
class="shop-left-scroll"
:class="{ 'shop-left-scroll--scrollbar-visible': scrollbarVisible }"
@scroll.passive="onScrollableScroll">
<el-tree
:data="channelTreeData"
:props="{ label: 'name', value: 'id', disabled: 'disabled' }"
node-key="id"
:current-node-key="currentNodeKey"
:expand-on-click-node="false"
highlight-current
default-expand-all
@current-change="onSelect">
<template #default="{ node, data }">
<span class="channel-agent-node">
<el-tag v-if="data.type === 'CHANNEL'" size="small" type="success">渠道</el-tag>
<el-tag v-else-if="data.type === 'SERVICE_PROVIDER'" size="small" type="warning">代理</el-tag>
<el-tag v-else-if="data.type === 'PLATFORM'" size="small" type="info">平台</el-tag>
<span class="node-name">{{ node.label }}</span>
</span>
</template>
</el-tree>
<el-empty v-if="!loading && channelTreeData.length === 0" description="暂无渠道" :image-size="60" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useFlashScrollbar } from '@/composables/useFlashScrollbar'
import { extractChannelAgentTree } from '../utils/extract-channel-agent-tree'
import type { ChannelAgentNode } from './shop-channel-tree.data'
defineOptions({ name: 'ShopChannelTree' })
const { scrollbarVisible, onScrollableScroll } = useFlashScrollbar()
const props = defineProps({
treeData: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
/** 与父级选中同步,用于默认选中第一级渠道时高亮 */
currentNodeKey: {
type: [String, Number],
default: null
}
})
const emit = defineEmits(['select'])
const channelTreeData = computed<ChannelAgentNode[]>(() => extractChannelAgentTree(props.treeData as unknown as import('../utils/extract-channel-agent-tree').OrgTreeNode[]))
function onSelect (node: ChannelAgentNode) {
if (!node) return
emit('select', node)
}
</script>
<style lang="scss" scoped>
.shop-left {
width: 100%;
min-width: 0;
flex: 1;
min-height: 0;
max-height: 100%;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: 4px;
background: var(--el-fill-color-blank);
display: flex;
flex-direction: column;
}
.shop-left-header {
flex-shrink: 0;
padding: 12px 16px;
font-weight: 600;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.shop-left-body {
flex: 1;
min-height: 0;
padding: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.shop-left-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
scrollbar-color: transparent transparent;
-ms-overflow-style: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
&--scrollbar-visible {
scrollbar-width: thin;
scrollbar-color: var(--el-scrollbar-bg-color, var(--el-fill-color-darker)) var(--el-scrollbar-hover-bg-color, var(--el-fill-color));
}
&--scrollbar-visible::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&--scrollbar-visible::-webkit-scrollbar-thumb {
background-color: var(--el-scrollbar-bg-color, rgba(144, 147, 153, 0.5));
border-radius: 4px;
}
&--scrollbar-visible::-webkit-scrollbar-thumb:hover {
background-color: var(--el-scrollbar-hover-bg-color, rgba(144, 147, 153, 0.7));
}
&--scrollbar-visible::-webkit-scrollbar-track {
background: transparent;
}
}
.channel-agent-node {
display: inline-flex;
align-items: center;
gap: 6px;
.node-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -0,0 +1,19 @@
/** 激活码项 */
export interface ActivationCodeItem {
id: string | number
code?: string
cardType?: string
maxDeviceCount?: number
}
/** 表单数据 */
export interface FormData {
cardType: string
maxDeviceCount: number
}
/** 激活码结果 */
export interface ActivationCodeResult {
list: ActivationCodeItem[]
contractId?: string | number | null
}

View File

@@ -0,0 +1,233 @@
<template>
<el-dialog
:model-value="modelValue"
title="修改剩余可激活设备数量"
width="440px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
@closed="onClosed">
<div v-loading="contractLoading">
<p v-if="storeName" class="dialog-tip">
<span class="store-name-tag">门店:{{ storeName }}</span>
</p>
<el-form :model="form" :label-width="labelWidth">
<el-form-item label="卡类型">
<el-select v-model="form.cardType" placeholder="请选择" style="width: 100%" @change="onCardTypeChange">
<!-- 显示现有激活码 -->
<el-option
v-for="item in activationList"
:key="item.id"
:label="`${item.code || '-'}(${cardTypeLabel(item.cardType ?? '')})`"
:value="item.cardType" />
<!-- 显示未存在的卡类型选项 -->
<el-option
v-for="[value, label] in availableCardTypes"
:key="value"
:label="label"
:value="value" />
</el-select>
</el-form-item>
<el-form-item label="剩余可激活设备数量">
<el-input-number v-model="form.maxDeviceCount" :min="0" :max="9999" controls-position="right" style="width: 160px" />
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button
type="primary"
:loading="submitLoading"
:disabled="!canSubmit"
@click="submit">
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { updateActivationCodeMaxDeviceCount, saveStoreActivationCode } from '@/api/contract'
import type { ActivationCodeItem, FormData } from './shop-max-device-dialog.data'
defineOptions({ name: 'ShopMaxDeviceDialog' })
const props = defineProps({
modelValue: { type: Boolean, default: false },
storeOrgId: { type: [String, Number], default: null },
storeName: { type: String, default: '' },
/** 返回 Promise<{ list, contractId? }> */
loadActivationCodes: { type: Function, required: true }
})
const emit = defineEmits(['update:modelValue', 'success'])
const labelWidth = '180px'
const cardTypeMap: Record<string, string> = { YEAR: '年卡', QUARTER: '季卡', MONTH: '月卡', WEEK: '周卡', OTHER: '天卡' }
function cardTypeLabel (t: string): string { return cardTypeMap[t] || t || '-' }
const contractLoading = ref(false)
const activationList = ref<ActivationCodeItem[]>([])
const contractId = ref<string | number | null>(null)
const form = reactive<FormData>({ cardType: 'YEAR', maxDeviceCount: 10 })
const submitLoading = ref(false)
const alreadyHas = computed(() => {
return activationList.value.some(c => c.cardType === form.cardType)
})
const canSubmit = computed(() => {
return !!form.cardType
})
const availableCardTypes = computed<[string, string][]>(() => {
const existingTypes = activationList.value
.map((item: ActivationCodeItem) => item.cardType)
.filter((t): t is string => !!t && typeof t === 'string')
return Object.entries(cardTypeMap).filter(([value]) => !existingTypes.includes(value))
})
watch(() => [props.modelValue, props.storeOrgId], ([visible, storeOrgId]) => {
if (visible && storeOrgId) {
activationList.value = []
contractId.value = null
form.cardType = 'YEAR'
form.maxDeviceCount = 10
contractLoading.value = true
props.loadActivationCodes(storeOrgId as string | number).then((result: any) => {
const res = result && typeof result === 'object' && !Array.isArray(result)
? result
: { list: Array.isArray(result) ? result : [], contractId: null }
activationList.value = res.list || []
contractId.value = res.contractId ?? null
if (activationList.value.length === 1) {
const first = activationList.value[0]
form.cardType = first.cardType ?? 'YEAR'
form.maxDeviceCount = first.maxDeviceCount ?? 10
} else if (activationList.value.length > 1) {
const yearCard = activationList.value.find((item: ActivationCodeItem) => item.cardType === 'YEAR')
if (yearCard) {
form.maxDeviceCount = yearCard.maxDeviceCount ?? 10
}
}
}).finally(() => {
contractLoading.value = false
})
}
})
function onCardTypeChange () {
// 检查是否已存在该卡类型的激活码
const existingCard = activationList.value.find(item => item.cardType === form.cardType)
if (existingCard) {
form.maxDeviceCount = existingCard.maxDeviceCount ?? 10
} else {
// 不存在该卡类型的激活码,重置设备数量
form.maxDeviceCount = 10
}
}
function onClosed () {
activationList.value = []
contractId.value = null
form.cardType = 'YEAR'
form.maxDeviceCount = 10
}
/** 仅在调整已有激活码且列表中有明文时附带 code;新建不传,由后端生成 */
const activeCodeSubmitFields = (): Record<string, string> => {
if (!alreadyHas.value) return {}
const row = activationList.value.find(c => c.cardType === form.cardType)
const raw = row?.code ?? ''
const c = String(raw ?? '').trim()
return c ? { code: c } : {}
}
function mergedActivationBody (): { cardType: string; maxDeviceCount: number; code?: string } {
return { ...activationTypeAndMaxBody(), ...activeCodeSubmitFields() }
}
interface ApiResponse {
code?: number
msg?: string
}
/** 卡类型与设备上限(camelCase) */
function activationTypeAndMaxBody (): { cardType: string; maxDeviceCount: number } {
const ct = String(form.cardType ?? '').trim() || 'YEAR'
const maxDev = Number(form.maxDeviceCount)
return {
cardType: ct,
maxDeviceCount: maxDev
}
}
/** 新建激活码请求体 */
function activationCodeCreateBody (): Record<string, string | number | boolean> {
return {
...mergedActivationBody(),
isContract: false
}
}
function submit () {
if (!props.storeOrgId) return
submitLoading.value = true
if (alreadyHas.value) {
const codeItem = activationList.value.find(c => c.cardType === form.cardType)
if (!codeItem?.id) {
ElMessage.warning('未找到对应激活码记录')
submitLoading.value = false
return
}
/** PUT 路径上为门店组织 ID(与 GET 列表 / POST 新建一致),非激活码主键 id */
updateActivationCodeMaxDeviceCount(Number(props.storeOrgId), mergedActivationBody())
.then(({ data }: { data: ApiResponse }) => {
if (data && data.code === 0) {
ElMessage.success('调整成功')
emit('update:modelValue', false)
emit('success')
} else {
ElMessage.error(data?.msg || '调整失败')
}
})
.catch(() => ElMessage.error('请求失败'))
.finally(() => { submitLoading.value = false })
} else {
saveStoreActivationCode(Number(props.storeOrgId), activationCodeCreateBody())
.then(({ data }: { data: ApiResponse }) => {
if (data && data.code === 0) {
ElMessage.success('新增成功')
emit('update:modelValue', false)
emit('success')
} else {
ElMessage.error(data?.msg || '新增失败')
}
})
.catch(() => ElMessage.error('请求失败'))
.finally(() => { submitLoading.value = false })
}
}
</script>
<style lang="scss" scoped>
.dialog-tip {
margin-bottom: 12px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.store-name-tag {
display: inline-block;
padding: 4px 12px;
background-color: var(--el-color-danger-light-9);
color: var(--el-color-danger);
font-weight: 600;
font-size: 14px;
border-radius: 4px;
border: 1px solid var(--el-color-danger-light-7);
}
</style>

View File

@@ -0,0 +1,16 @@
/** 激活码项 */
export interface ActivationCodeItem {
id: string | number
code?: string
cardType?: string
}
/** 激活码响应 */
export interface ActivationCodeResponse {
code?: number
activationCode?: string
data?: {
activationCode?: string
}
msg?: string
}

View File

@@ -0,0 +1,103 @@
<template>
<el-dialog
:model-value="modelValue"
title="重新生成权益激活码"
width="440px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
@closed="onClosed">
<div v-loading="contractLoading">
<p v-if="storeName" class="dialog-tip">门店:{{ storeName }}</p>
<el-form v-if="activationList.length > 0" label-width="100px">
<el-form-item label="选择激活码">
<el-select v-model="selectedCacId" placeholder="请选择" style="width: 100%">
<el-option
v-for="item in activationList"
:key="item.id"
:label="`${item.code || '-'}(${cardTypeLabel(item.cardType ?? '')})`"
:value="item.id" />
</el-select>
</el-form-item>
</el-form>
<el-empty v-else-if="!contractLoading" description="暂无权益激活卡,请联系管理员分配" />
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitLoading" :disabled="!selectedCacId" @click="submit">重新生成</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { regenerateActivationCode } from '@/api/contract'
import type { ActivationCodeItem, ActivationCodeResponse } from './shop-regenerate-code-dialog.data'
defineOptions({ name: 'ShopRegenerateCodeDialog' })
const props = defineProps({
modelValue: { type: Boolean, default: false },
storeOrgId: { type: [String, Number], default: null },
storeName: { type: String, default: '' },
loadActivationCodes: { type: Function, required: true }
})
const emit = defineEmits(['update:modelValue', 'success'])
const cardTypeMap: Record<string, string> = { YEAR: '年卡', QUARTER: '季卡', MONTH: '月卡', WEEK: '周卡', OTHER: '天卡' }
function cardTypeLabel (t: string): string { return cardTypeMap[t] || t || '-' }
const contractLoading = ref(false)
const activationList = ref<ActivationCodeItem[]>([])
const selectedCacId = ref<string | number | null>(null)
const submitLoading = ref(false)
watch(() => [props.modelValue, props.storeOrgId], ([visible, storeOrgId]) => {
if (visible && storeOrgId) {
activationList.value = []
selectedCacId.value = null
contractLoading.value = true
props.loadActivationCodes(storeOrgId as string | number).then((list: ActivationCodeItem[]) => {
activationList.value = list
if (list.length === 1) selectedCacId.value = list[0].id
}).finally(() => {
contractLoading.value = false
})
}
})
function onClosed () {
activationList.value = []
selectedCacId.value = null
}
function submit () {
const cacId = selectedCacId.value
if (!cacId) return
submitLoading.value = true
regenerateActivationCode(Number(cacId))
.then(({ data }: { data: ActivationCodeResponse }) => {
if (data && data.code === 0) {
const newCode = data.activationCode ?? data.data?.activationCode ?? '-'
ElMessage.success(`重新生成成功,新激活码:${newCode}`)
emit('update:modelValue', false)
emit('success')
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
.catch(() => ElMessage.error('请求失败'))
.finally(() => { submitLoading.value = false })
}
</script>
<style lang="scss" scoped>
.dialog-tip {
margin-bottom: 12px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,49 @@
/** 门店查询表单 */
export interface DataForm {
name: string
activationCode: string
status: string
}
/** 卡类型统计 */
export interface CardStatsByType {
cardType: string
totalCount?: number
usedCount?: number
}
/** 门店档案 */
export interface StoreProfile {
storeName?: string
region?: string
address?: string
phone?: string
}
/** 门店行数据 */
export interface ShopRow {
id: string | number
name?: string
orgId?: string | number
status?: string
cardStatsByType?: CardStatsByType[]
storeProfile?: StoreProfile
deviceCount?: number
boundDeviceCount?: number
terminalCount?: number
storeCards?: number | unknown[]
activationCodeGroup?: string
yearActivationCode?: string
quarterActivationCode?: string
monthActivationCode?: string
weekActivationCode?: string
otherActivationCode?: string
rootChannelActivationPrefix?: string
channelName?: string
primaryAgentName?: string
secondaryAgentName?: string
region?: string
address?: string
phone?: string
[key: string]: unknown
}

View File

@@ -0,0 +1,408 @@
<template>
<div class="shop-right">
<div class="shop-right-header">
<el-form :inline="true" :model="dataForm" @keyup.enter="$emit('search')">
<el-form-item>
<el-input v-model="dataForm.name" placeholder="门店名称" clearable style="width: 160px" />
</el-form-item>
<el-form-item>
<el-input v-model="dataForm.activationCode" placeholder="激活码(后5位)" clearable style="width: 160px" />
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="状态" clearable style="width: 100px">
<el-option label="全部" value="" />
<el-option label="启用" value="ACTIVE" />
<el-option label="禁用" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="$emit('search')">查询</el-button>
<el-button type="primary" :disabled="!canAddStore" @click="$emit('add')">添加门店</el-button>
<slot name="toolbarActions" />
</el-form-item>
</el-form>
</div>
<div
class="shop-right-body"
:class="{ 'shop-right-body--scrollbar-visible': scrollbarVisible }"
@scroll.passive="onScrollableScroll">
<el-empty v-if="!selectedChannelId" description="请从左侧选择渠道或代理商" :image-size="80" />
<template v-else-if="selectedChannelId">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
row-key="id"
style="width: 100%">
<el-table-column header-align="center" align="center" width="80" label="序号">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="name" header-align="center" min-width="120" label="门店名称" show-overflow-tooltip />
<el-table-column prop="activationCodeGroup" header-align="center" align="center" min-width="140" show-overflow-tooltip>
<template #header>
<el-dropdown @command="handleCardTypeChange" size="small">
<span class="activation-code-header">
{{ selectedCardTypeLabel }}<el-icon class="el-icon--right"><arrow-down /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="YEAR">年卡</el-dropdown-item>
<el-dropdown-item command="QUARTER">季卡</el-dropdown-item>
<el-dropdown-item command="MONTH">月卡</el-dropdown-item>
<el-dropdown-item command="WEEK">周卡</el-dropdown-item>
<el-dropdown-item command="DAY">天卡</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template #default="scope">
<span class="copy-activation-code-cell" title="点击复制激活码" @click="copyActivationCode(scope.row)">
{{ activationCodeText(scope.row) }}
</span>
</template>
</el-table-column>
<el-table-column prop="storeCards" header-align="center" align="center" width="120" label="已使用/总量(卡)">
<template #default="scope">
<template v-if="getCardStatsByType(scope.row, selectedCardType)">
<span class="card-used-count">{{ getCardStatsByType(scope.row, selectedCardType)?.usedCount ?? 0 }}</span>
<span>/</span>
<span class="card-total-count">{{ getCardStatsByType(scope.row, selectedCardType)?.totalCount ?? 0 }}</span>
</template>
<template v-else>
<span class="card-used-count">0</span>
<span>/</span>
<span class="card-total-count">0</span>
</template>
</template>
</el-table-column>
<el-table-column prop="channelName" header-align="center" align="center" width="140" label="渠道" show-overflow-tooltip />
<el-table-column prop="primaryAgentName" header-align="center" align="center" width="140" label="一级代理商" show-overflow-tooltip />
<el-table-column prop="secondaryAgentName" header-align="center" align="center" width="140" label="二级代理商" show-overflow-tooltip />
<el-table-column prop="region" header-align="center" align="center" width="140" label="省/市/县" show-overflow-tooltip />
<el-table-column prop="address" header-align="center" min-width="160" label="详细地址" show-overflow-tooltip />
<el-table-column prop="phone" header-align="center" align="center" width="120" label="联系电话" />
<el-table-column prop="status" header-align="center" align="center" width="80" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else-if="scope.row.status === 'DISABLED'" size="small" type="danger">禁用</el-tag>
<span v-else>{{ scope.row.status || '-' }}</span>
</template>
</el-table-column>
<el-table-column fixed="right" header-align="center" align="center" min-width="400" label="操作" class-name="shop-op-col">
<template #default="scope">
<div class="shop-table-actions">
<el-button
link
type="primary"
size="small"
class="shop-action-btn"
@click="$emit('max-device', scope.row)">
创建激活码
</el-button>
<el-button link type="primary" size="small" class="shop-action-btn" @click="$emit('regenerate-code', scope.row)">
重置激活码
</el-button>
<el-button link type="primary" size="small" class="shop-action-btn" @click="goTerminalManage(scope.row)">
终端管理
</el-button>
<el-button link type="primary" size="small" class="shop-action-btn" @click="$emit('edit', scope.row.id)">
修改信息
</el-button>
<el-dropdown class="shop-table-actions__more" trigger="hover" @command="(cmd: string) => onMoreMenuCommand(cmd, scope.row)">
<el-button link type="primary" size="small" class="shop-action-btn shop-more-trigger">
更多
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view-cards">查看卡</el-dropdown-item>
<el-dropdown-item v-if="showVersionAndDeviceLimit" command="version-manage">版本管理</el-dropdown-item>
<el-dropdown-item command="delete">删除门店</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="totalPage > 0"
@size-change="$emit('size-change', $event)"
@current-change="$emit('current-change', $event)"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 12px" />
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useFlashScrollbar } from '@/composables/useFlashScrollbar'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue'
import type { ShopRow, CardStatsByType } from './shop-store-list.data'
defineOptions({ name: 'ShopStoreList' })
const { scrollbarVisible, onScrollableScroll } = useFlashScrollbar()
const userStore = useUserStore()
const router = useRouter()
const emit = defineEmits(['search', 'size-change', 'current-change', 'add', 'edit', 'view-cards', 'regenerate-code', 'max-device', 'version-manage', 'delete'])
/** 代理商不展示版本管理、修改设备上限(可激活设备数) */
const showVersionAndDeviceLimit = computed(() => !userStore.isAgent)
/** 门店已绑定终端数量 */
function storeDeviceCount (row: ShopRow) {
const raw =
row?.deviceCount ??
row?.boundDeviceCount ??
row?.terminalCount
const n = Number(raw)
return Number.isFinite(n) && n >= 0 ? n : 0
}
/** 是否存在权益卡(已分配/已使用卡量) */
function storeHasBenefitCards (row: ShopRow) {
const list = row?.cardStatsByType
if (Array.isArray(list) && list.length) {
return list.some((s) => {
const total = s.totalCount ?? 0
const used = s.usedCount ?? 0
return Number(total) > 0 || Number(used) > 0
})
}
const sc = row?.storeCards
if (typeof sc === 'number' && Number.isFinite(sc)) return sc > 0
if (Array.isArray(sc)) return sc.length > 0
return false
}
function canDeleteStore (row: ShopRow) {
return storeDeviceCount(row) === 0 && !storeHasBenefitCards(row)
}
function goTerminalManage (row: ShopRow) {
const id = row?.orgId ?? row?.id
if (id == null) {
ElMessage.warning('无法跳转:缺少门店标识')
return
}
router.push({ name: 'device-device', query: { storeOrgId: String(id) } }).catch(() => {})
}
function onMoreMenuCommand (cmd: string, row: ShopRow) {
switch (cmd) {
case 'view-cards':
emit('view-cards', row)
break
case 'delete':
if (!canDeleteStore(row)) {
const reasons = []
if (storeDeviceCount(row) > 0) reasons.push('存在已绑定终端')
if (storeHasBenefitCards(row)) reasons.push('存在权益卡')
ElMessage.warning(reasons.length ? `无法删除:${reasons.join(';')}` : '当前门店不满足删除条件')
return
}
emit('delete', row)
break
case 'version-manage':
emit('version-manage', row)
break
default:
break
}
}
const selectedCardType = ref('YEAR')
const cardTypeMap: Record<string, string> = {
YEAR: '年卡',
QUARTER: '季卡',
MONTH: '月卡',
WEEK: '周卡',
DAY: '天卡'
}
const selectedCardTypeLabel = computed(() => '激活码' + cardTypeMap[selectedCardType.value])
function handleCardTypeChange(command: string) {
selectedCardType.value = command
}
function getCardStatsByType(row: ShopRow, cardType: string) {
if (!row.cardStatsByType || !Array.isArray(row.cardStatsByType)) return null
// 前端 DAY 对应后端 OTHER
const targetType = cardType === 'DAY' ? 'OTHER' : cardType
return row.cardStatsByType.find((stat: CardStatsByType) => stat.cardType === targetType) || null
}
const props = defineProps({
selectedChannelId: { type: [String, Number], default: null },
selectedChannelType: { type: String, default: null },
dataList: { type: Array, default: () => [] },
dataListLoading: { type: Boolean, default: false },
dataForm: { type: Object, required: true },
pageIndex: { type: Number, default: 1 },
pageSize: { type: Number, default: 10 },
totalPage: { type: Number, default: 0 }
})
/** 判断是否允许添加门店:必须选择渠道或代理商,不能选择平台 */
const canAddStore = computed(() => {
const type = props.selectedChannelType
return type === 'CHANNEL' || type === 'SERVICE_PROVIDER'
})
const cardTypeFieldMap: Record<string, string> = {
YEAR: 'yearActivationCode',
QUARTER: 'quarterActivationCode',
MONTH: 'monthActivationCode',
WEEK: 'weekActivationCode',
DAY: 'otherActivationCode'
}
function activationCodeText (row: ShopRow) {
const fieldName = cardTypeFieldMap[selectedCardType.value]
const code = row?.[fieldName]
if (!code || code === '-') return '-'
const str = String(code)
return row.rootChannelActivationPrefix ? row.rootChannelActivationPrefix + "-" + str : str
}
async function copyActivationCode (row: ShopRow) {
const text = String(activationCodeText(row) || '').trim()
if (!text || text === '-') {
ElMessage.warning('暂无激活码可复制')
return
}
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'readonly')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
ElMessage.success(`已复制:${text}`)
} catch (err: any) {
ElMessage.error(err?.message || '复制失败,请手动复制')
}
}
</script>
<style lang="scss" scoped>
.shop-right {
flex: 1;
margin-left: 16px;
border: 1px solid var(--el-border-color);
border-radius: 4px;
background: var(--el-fill-color-blank);
display: flex;
flex-direction: column;
min-width: 0;
}
.shop-right-header {
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.shop-right-body {
flex: 1;
padding: 16px;
overflow: auto;
scrollbar-width: none;
scrollbar-color: transparent transparent;
-ms-overflow-style: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
&--scrollbar-visible {
scrollbar-width: thin;
scrollbar-color: var(--el-scrollbar-bg-color, var(--el-fill-color-darker)) var(--el-scrollbar-hover-bg-color, var(--el-fill-color));
}
&--scrollbar-visible::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&--scrollbar-visible::-webkit-scrollbar-thumb {
background-color: var(--el-scrollbar-bg-color, rgba(144, 147, 153, 0.5));
border-radius: 4px;
}
&--scrollbar-visible::-webkit-scrollbar-thumb:hover {
background-color: var(--el-scrollbar-hover-bg-color, rgba(144, 147, 153, 0.7));
}
&--scrollbar-visible::-webkit-scrollbar-track {
background: transparent;
}
}
.card-used-count {
color: var(--el-color-success);
}
.card-total-count {
color: var(--el-color-danger);
}
.copy-activation-code-cell {
cursor: pointer;
color: inherit;
}
.activation-code-header {
cursor: pointer;
color: var(--el-text-color-regular);
display: inline-flex;
align-items: center;
}
.activation-code-header:hover {
color: var(--el-color-primary);
}
.shop-table-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 4px 8px;
padding: 4px 0;
line-height: 1.5;
}
.shop-table-actions__more {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.shop-more-trigger {
display: inline-flex;
align-items: center;
}
.shop-table-actions :deep(.shop-action-btn.is-link) {
padding: 2px 6px;
margin: 0;
height: auto;
font-size: 12px;
}
.shop-table-actions :deep(.shop-action-btn.is-link + .shop-action-btn.is-link) {
margin-left: 0;
}
/* 操作列单元格略增内边距,避免与边框贴死 */
:deep(.el-table__body td.shop-op-col .cell) {
padding-top: 6px;
padding-bottom: 6px;
}
</style>

View File

@@ -0,0 +1,45 @@
/** 组织类型 */
export type OrgType = 'PLATFORM' | 'CHANNEL' | 'SERVICE_PROVIDER' | 'STORE'
/** 组织状态 */
export type OrgStatus = 'ACTIVE' | 'DISABLED'
/** 查询表单数据 */
export interface DataForm {
name: string
type: string
status: string
}
/** 回退卡表单 */
export interface RollbackForm {
cardType: string
count: number
}
/** 组织树节点 */
export interface OrgNode {
id: string | number
name: string
type: OrgType | string
orgCode?: string
code?: string
orderNo?: number
status: OrgStatus | string
createdAt?: string
parentId?: string | number
children?: OrgNode[]
[key: string]: unknown
}
/** 过滤选项 */
export interface FilterOptions {
name: string
type: string
status: string
}
/** AddOrUpdate 组件实例方法 */
export interface AddOrUpdateInstance {
init: (id?: string | number | null, parentId?: string | number | null) => void
}

457
src/views/modules/org/org.vue Executable file
View File

@@ -0,0 +1,457 @@
<template>
<div class="mod-org">
<div class="org-content">
<el-form :inline="true" :model="dataForm" @keyup.enter="handleQuery">
<el-form-item>
<el-input v-model="dataForm.name" placeholder="组织名称" clearable style="width: 160px" />
</el-form-item>
<el-form-item>
<el-select v-model="dataForm.status" placeholder="状态" style="width: 100px">
<el-option label="全部" value="ALL" />
<el-option label="启用" value="ACTIVE" />
<el-option label="禁用" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">查询</el-button>
<el-button type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="filteredTreeData"
border
v-loading="treeLoading"
row-key="id"
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
style="width: 100%">
<el-table-column prop="name" header-align="left" min-width="200" label="代理商名称" show-overflow-tooltip>
<template #default="scope">
<span class="org-tree-name">
<el-tag v-if="scope.row.type === 'PLATFORM'" size="small" type="info">平台</el-tag>
<el-tag v-else-if="scope.row.type === 'CHANNEL'" size="small" type="success">渠道</el-tag>
<el-tag v-else-if="scope.row.type === 'SERVICE_PROVIDER'" size="small" type="warning">代理</el-tag>
<el-tag v-else-if="String(scope.row.type || '').toUpperCase() === 'STORE'" size="small">门店</el-tag>
<span v-else class="org-type-placeholder" />
<span class="org-name-text">{{ scope.row.name }}</span>
</span>
</template>
</el-table-column>
<el-table-column prop="type" header-align="center" align="center" width="100" label="类型">
<template #default="scope">
<el-tag v-if="scope.row.type === 'PLATFORM'" size="small">平台</el-tag>
<el-tag v-else-if="scope.row.type === 'CHANNEL'" size="small" type="success">渠道</el-tag>
<el-tag v-else-if="scope.row.type === 'SERVICE_PROVIDER'" size="small" type="warning">代理服务商</el-tag>
<el-tag v-else-if="String(scope.row.type || '').toUpperCase() === 'STORE'" size="small" type="info">门店</el-tag>
<span v-else>{{ scope.row.type || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="orgCode" header-align="center" align="center" width="140" label="组织编码" show-overflow-tooltip />
<el-table-column prop="orderNo" header-align="center" align="center" width="70" label="排序" />
<el-table-column prop="status" header-align="center" align="center" width="80" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 'ACTIVE'" size="small" type="success">启用</el-tag>
<el-tag v-else-if="scope.row.status === 'DISABLED'" size="small" type="danger">禁用</el-tag>
<span v-else>{{ scope.row.status || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="createdAt" header-align="center" align="center" width="165" label="创建时间">
<template #default="scope">
{{ scope.row.createdAt || '-' }}
</template>
</el-table-column>
<el-table-column fixed="right" header-align="center" align="center" width="200" label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="addOrUpdateHandle(null, scope.row.id)">新增子级</el-button>
<el-button
v-if="canShowEditForRow(scope.row)"
link
type="primary"
size="small"
@click="addOrUpdateHandle(scope.row.id)">
修改
</el-button>
<el-button
v-if="canRollbackCard(scope.row)"
link
type="warning"
size="small"
@click="openRollbackCard(scope.row)">
回退卡
</el-button>
<el-button v-if="!isAgent" link type="info" size="small" @click="openVersionDialog(scope.row)">版本管理</el-button>
<el-tooltip
:content="deleteDisabledReason(scope.row)"
:disabled="!deleteDisabledReason(scope.row)">
<span class="pl-3">
<el-button
v-if="canShowDeleteForRow(scope.row)"
link
type="danger"
size="small"
:disabled="idsWithChildren.has(scope.row.id)"
@click="deleteHandle(scope.row.id)">
删除
</el-button>
</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<div class="tree-table-footer">
<span class="tree-total">共 {{ filteredTreeCount }} 条</span>
</div>
</div>
</div>
<!-- 弹窗:新增 / 修改 -->
<add-or-update v-show="addOrUpdateVisible" ref="addOrUpdateRef" @refreshDataList="onRefreshList" />
<!-- 弹窗:版本管理(代理商无入口) -->
<org-version-dialog
v-if="!isAgent"
v-model="versionDialogVisible"
:org-id="versionOrgId ?? undefined"
:org-name="versionOrgName"
:m-path="versionOrgMPath"
target-label="组织"
@success="onRefreshList" />
<el-dialog
v-model="rollbackDialogVisible"
title="回退卡"
width="420px"
destroy-on-close
:close-on-click-modal="false"
@closed="onRollbackDialogClosed">
<el-form ref="rollbackFormRef" :model="rollbackForm" :rules="rollbackRules" label-width="90px">
<el-form-item label="卡类型" prop="cardType">
<el-select v-model="rollbackForm.cardType" placeholder="请选择卡类型" style="width: 100%">
<el-option label="年卡" value="YEAR" />
<el-option label="季卡" value="QUARTER" />
<el-option label="月卡" value="MONTH" />
<el-option label="周卡" value="WEEK" />
<el-option label="天卡" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="回退数量" prop="count">
<el-input-number v-model="rollbackForm.count" :min="1" :max="9999" controls-position="right" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rollbackDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="rollbackLoading" @click="submitRollbackCard">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { useUserStore } from '@/stores/user'
import AddOrUpdate from './components/org-add-or-update.vue'
import OrgVersionDialog from './components/org-version-dialog.vue'
import { findPathIdsInTree, buildMpathFromPathIds } from './components/org-version-dialog.data'
import { treeDataTranslate } from '@/utils'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { OrgInfo } from '@/api/org'
import { getOrgTree as getOrgTreeApi, deleteOrg } from '@/api/org'
import { rollbackCard } from '@/api/cardFlow'
import type { DataForm, RollbackForm, OrgNode, FilterOptions, AddOrUpdateInstance } from './org.data'
defineOptions({ name: 'Org' })
const userStore = useUserStore()
const isAgent = computed(() => userStore.isAgent)
const isChannelUser = computed(() => userStore.isChannel)
const dataForm = reactive<DataForm>({
name: '',
type: 'ALL',
status: 'ALL'
})
const dataListSelections = ref<OrgNode[]>([])
const treeData = ref<OrgNode[]>([])
const treeLoading = ref(false)
const addOrUpdateVisible = ref(false)
const versionDialogVisible = ref(false)
const versionOrgId = ref<string | number | null>(null)
const versionOrgName = ref('')
const versionOrgMPath = ref('')
const rollbackDialogVisible = ref(false)
const rollbackLoading = ref(false)
const rollbackFormRef = ref<FormInstance | null>(null)
const rollbackFromChannelOrgId = ref<string | number | null>(null)
const rollbackForm = reactive<RollbackForm>({
cardType: '',
count: 1
})
const rollbackRules = reactive<FormRules<RollbackForm>>({
cardType: [{ required: true, message: '请选择卡类型', trigger: 'change' }],
count: [{ required: true, message: '请输入回退数量', trigger: 'change' }]
})
/** 有子组织的节点 id 集合(用于禁止删除) */
const idsWithChildren = ref<Set<string | number>>(new Set())
/** 一级渠道:父为平台,或作为树根的渠道节点(渠道商视角下不可删) */
const channelFirstLevelIds = ref<Set<string | number>>(new Set())
const addOrUpdateRef = ref<AddOrUpdateInstance | null>(null)
/** 根据搜索条件过滤树,保留匹配节点及其祖先路径 */
function filterTree(nodes: OrgNode[] | undefined, { name, type, status }: FilterOptions): OrgNode[] {
if (!nodes || !Array.isArray(nodes)) return []
const result: OrgNode[] = []
for (const node of nodes) {
const matchesName = !name || (node.name && String(node.name).toLowerCase().includes(String(name).toLowerCase()))
const matchesType = !type || type === 'ALL' || (node.type != null && String(node.type).toUpperCase() === String(type).toUpperCase())
const matchesStatus = !status || status === 'ALL' || node.status === status
const matches = matchesName && matchesType && matchesStatus
const filteredChildren = filterTree(node.children, { name, type, status })
if (matches || filteredChildren.length > 0) {
result.push({ ...node, children: filteredChildren })
}
}
return result
}
const filteredTreeData = computed(() => filterTree(treeData.value, dataForm))
/** 过滤后树的节点总数(用于底部统计) */
function countTreeNodes(nodes: OrgNode[] | undefined): number {
if (!nodes || !Array.isArray(nodes)) return 0
return nodes.reduce((sum, n) => sum + 1 + countTreeNodes(n.children), 0)
}
const filteredTreeCount = computed(() => countTreeNodes(filteredTreeData.value))
/** 仅渠道可回退卡;代理商(SERVICE_PROVIDER)不提供该入口 */
const canRollbackCard = (row: OrgNode): boolean => {
return String(row?.type || '').toUpperCase() === 'CHANNEL'
}
function collectIdsWithChildren(nodes: OrgNode[] | undefined): Set<string | number> {
const set = new Set<string | number>()
if (!nodes || !Array.isArray(nodes)) return set
for (const node of nodes) {
if (node.children && node.children.length > 0) {
set.add(node.id)
collectIdsWithChildren(node.children).forEach(id => set.add(id))
}
}
return set
}
/** 一级渠道 id:挂在平台下的渠道,或树顶层渠道(无上级平台行时) */
function collectChannelFirstLevelIds(nodes: OrgNode[] | undefined): Set<string | number> {
const set = new Set<string | number>()
function walk(arr: OrgNode[] | undefined, parentType: string | null) {
if (!arr || !Array.isArray(arr)) return
for (const n of arr) {
const t = String(n.type || '').toUpperCase()
if (t === 'CHANNEL' && (parentType == null || parentType === 'PLATFORM')) {
set.add(n.id)
}
walk(n.children, t)
}
}
walk(nodes, null)
return set
}
function isChannelFirstLevelRow(row: OrgNode): boolean {
return String(row?.type || '').toUpperCase() === 'CHANNEL' && channelFirstLevelIds.value.has(row.id)
}
/** 渠道商用户不展示一级渠道的删除入口 */
function canShowDeleteForRow(row: OrgNode): boolean {
if (isChannelUser.value && isChannelFirstLevelRow(row)) return false
return true
}
/** 渠道用户不可修改「当前渠道」:优先与登录用户的 orgId 比对,无 orgId 时与一级渠道节点对齐 */
function canShowEditForRow(row: OrgNode): boolean {
if (!isChannelUser.value) return true
if (String(row?.type || '').toUpperCase() !== 'CHANNEL') return true
const oid = userStore.orgId
if (oid != null && String(oid) !== '0') {
return String(row.id) !== String(oid)
}
return !isChannelFirstLevelRow(row)
}
function deleteDisabledReason(row: OrgNode): string {
if (idsWithChildren.value.has(row.id)) return '存在子集时不允许删除'
return ''
}
function handleQuery() {
getTreeData()
}
/** 从 getOrgTree 的 Promise 结果中解析列表(兼容 Axios 的 { data } 与多种 body 形态) */
function parseOrgTreePayload(res: unknown): OrgInfo[] {
const body =
res !== null &&
typeof res === 'object' &&
'data' in res &&
(res as { data: unknown }).data != null
? (res as { data: unknown }).data
: res
if (Array.isArray(body)) return body
if (!body || typeof body !== 'object') return []
const b = body as { code?: number; data?: any; list?: OrgInfo[]; tree?: OrgInfo[] }
if (b.code === 0) {
if (Array.isArray(b.data)) return b.data
if (b.data && typeof b.data === 'object') {
if (Array.isArray(b.data.tree)) return b.data.tree
if (Array.isArray(b.data.list)) return b.data.list
}
if (Array.isArray(b.tree)) return b.tree
}
if (Array.isArray(b.list)) return b.list
return []
}
function getTreeData() {
treeLoading.value = true
getOrgTreeApi()
.then((res) => {
const list = parseOrgTreePayload(res)
treeData.value =
list.length > 0 && list[0] != null && list[0].children !== undefined
? (list as OrgNode[])
: (treeDataTranslate(list, 'id', 'parentId') as OrgNode[])
idsWithChildren.value = collectIdsWithChildren(treeData.value)
channelFirstLevelIds.value = collectChannelFirstLevelIds(treeData.value)
})
.catch(() => {})
.finally(() => {
treeLoading.value = false
})
}
function addOrUpdateHandle(id?: string | number | null, parentId?: string | number | null) {
addOrUpdateVisible.value = true
nextTick(() => {
const child = addOrUpdateRef.value
if (child && typeof child.init === 'function') {
child.init(id, parentId)
}
})
}
function deleteHandle(id: string | number) {
const ids = id != null ? [id] : dataListSelections.value.map(item => item.id)
if (isChannelUser.value && ids.some(i => channelFirstLevelIds.value.has(i))) {
ElMessage.warning('不允许删除本级渠道组织')
return
}
const withChildren = ids.filter(i => idsWithChildren.value.has(i))
if (withChildren.length > 0) {
ElMessage.warning('以下组织存在子集,不允许删除:' + withChildren.join('、'))
return
}
ElMessageBox.confirm(`确定对[id=${ids.join(',')}]进行${id != null ? '删除' : '批量删除'}操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteOrg(ids.map(id => typeof id === 'number' ? id : Number(id))).then(({ data }) => {
if (data && data.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => getTreeData()
})
} else {
ElMessage.error(data?.msg || '操作失败')
}
})
}).catch(() => {})
}
function onRefreshList() {
getTreeData()
}
function openRollbackCard(row: OrgNode) {
const type = String(row?.type || '').toUpperCase()
if (type !== 'CHANNEL') {
ElMessage.warning('仅渠道组织允许回退卡')
return
}
rollbackFromChannelOrgId.value = row.id
rollbackDialogVisible.value = true
}
function onRollbackDialogClosed() {
rollbackFromChannelOrgId.value = null
rollbackForm.cardType = ''
rollbackForm.count = 1
rollbackFormRef.value?.clearValidate?.()
}
interface RollbackResponse {
code?: number
rollbackCount?: number
msg?: string
}
function submitRollbackCard() {
rollbackFormRef.value?.validate((valid: boolean) => {
if (!valid) return
if (!rollbackFromChannelOrgId.value) {
ElMessage.warning('缺少回退来源组织,请重新点击回退卡')
return
}
rollbackLoading.value = true
rollbackCard({
fromOrgId: Number(rollbackFromChannelOrgId.value),
cardType: rollbackForm.cardType,
count: rollbackForm.count
}).then(({ data }: { data: RollbackResponse }) => {
if (data && data.code === 0) {
ElMessage.success(`回退卡成功,数量:${data.rollbackCount ?? rollbackForm.count}`)
rollbackDialogVisible.value = false
getTreeData()
} else {
ElMessage.error(data?.msg || '回退卡失败')
}
}).catch(() => ElMessage.error('请求失败')).finally(() => {
rollbackLoading.value = false
})
})
}
function openVersionDialog(row: OrgNode) {
const target = row || (dataListSelections.value.length === 1 ? dataListSelections.value[0] : null)
if (!target) {
ElMessage.warning('请选择一条组织记录')
return
}
versionOrgId.value = target.id
versionOrgName.value = target.name || '-'
versionOrgMPath.value = buildMpathFromPathIds(findPathIdsInTree(treeData.value, target.id))
|| String(target.mPath ?? target.mpath ?? '')
versionDialogVisible.value = true
}
// 无 keep-alive 时 onActivated 不会触发,需 onMounted 首次拉数;有 keep-alive 时切换回来仍走 onActivated
onMounted(() => {
getTreeData()
})
</script>
<style lang="scss" scoped>
.mod-org {
.org-content {
.table-page-wrap {
margin-top: 12px;
}
.tree-table-footer {
margin-top: 12px;
padding: 8px 0;
color: var(--el-text-color-secondary);
font-size: 13px;
}
}
}
</style>

View File

@@ -0,0 +1,106 @@
/** 门店查询表单 */
export interface ShopDataForm {
name: string
activationCode: string
status: string
}
/** 门店数据 */
export interface ShopRow {
id: string | number
name?: string
orgId?: string | number
code?: string
orgCode?: string
region?: string
address?: string
phone?: string
status?: string
storeCards?: number | unknown[]
cardStatsByType?: CardStatsByType[]
storeProfile?: StoreProfile
deviceCount?: number
boundDeviceCount?: number
terminalCount?: number
activationCodeGroup?: string
yearActivationCode?: string
quarterActivationCode?: string
monthActivationCode?: string
weekActivationCode?: string
otherActivationCode?: string
rootChannelActivationPrefix?: string
channelName?: string
primaryAgentName?: string
secondaryAgentName?: string
[key: string]: unknown
}
/** 门店档案 */
export interface StoreProfile {
storeName?: string
legalPerson?: string
licenseNo?: string
licenseImageUrl?: string
region?: string
address?: string
phone?: string
remark?: string
}
/** 卡类型统计 */
export interface CardStatsByType {
cardType: string
cardTypeName?: string
totalCount?: number
usedCount?: number
}
/** 代理商回退表单 */
export interface AgentRollbackForm {
cardType: string
count: number
}
/** 组织树节点 */
export interface OrgTreeNode {
id: string | number
name: string
type: string
children?: OrgTreeNode[]
[key: string]: unknown
}
/** 卡统计数据 */
export interface CardStats {
allocatableCount: number
activatedCount: number
pendingActivationCount: number
}
/** 按类型卡统计 */
export interface CardStatsByTypeItem {
cardType: string
cardTypeName: string
allocatableCount: number
activatedCount: number
pendingActivationCount: number
}
/** 激活码结果 */
export interface ActivationCodeResult {
list: ActivationCodeItem[]
contractId?: string | number | null
}
/** 激活码项 */
export interface ActivationCodeItem {
id: string | number
code?: string
cardType?: string
maxDeviceCount?: number
}
/** AddOrUpdate 组件实例 */
export interface AddOrUpdateInstance {
init: (id?: string | number | null, parentId?: string | number | null, options?: { typeOnly?: string }) => void
}

666
src/views/modules/org/shop.vue Executable file
View File

@@ -0,0 +1,666 @@
<template>
<div class="mod-shop">
<div
class="shop-layout"
:class="{ 'tree-collapsed': isTreeCollapsed, 'shop-layout--resizing': treePanelResizing }">
<div
class="shop-tree-container"
v-show="!isTreeCollapsed"
:style="{ width: `${treePanelWidth}px`, minWidth: `${treePanelWidth}px` }">
<shop-channel-tree
:tree-data="orgTreeData"
:loading="channelLoading"
:current-node-key="selectedChannelTreeId ?? undefined"
@select="onChannelSelect" />
</div>
<div
v-show="!isTreeCollapsed"
class="shop-tree-resizer"
title="拖拽调整左侧宽度"
@mousedown.prevent="onTreePanelResizeStart" />
<div class="tree-collapse-bar" @click="toggleTreeCollapse" :title="isTreeCollapsed ? '展开' : '收缩'">
<el-icon>
<arrow-left v-if="!isTreeCollapsed" />
<arrow-right v-else />
</el-icon>
</div>
<shop-store-list
:selected-channel-id="selectedChannelId ?? undefined"
:selected-channel-type="selectedChannelType ?? undefined"
:data-list="dataList"
:data-list-loading="dataListLoading"
:data-form="dataForm"
:page-index="pageIndex"
:page-size="pageSize"
:total-page="totalPage"
@search="getDataList"
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
@add="addHandle"
@edit="editHandle"
@delete="deleteHandle"
@view-cards="showCardsHandle"
@version-manage="openVersionDialog"
@regenerate-code="openRegenerateDialog"
@max-device="openMaxDeviceDialog">
<!-- 分配卡后的操作:本页内联声明,与「分配卡」紧挨 -->
<template #toolbarActions>
<el-button type="success" @click="openAllocateCardDialog">分配卡</el-button>
<el-button
v-if="canAgentRollbackInToolbar"
type="warning"
@click="openAgentRollbackDialog">
代理商回退
</el-button>
</template>
</shop-store-list>
</div>
<div v-show="addOrUpdateVisible">
<add-or-update ref="addOrUpdateRef" @refreshDataList="onRefreshList" />
</div>
<shop-cards-dialog
v-model="cardsDialogVisible"
:store-id="cardsStoreId ?? undefined"
:store-name="cardsStoreName" />
<shop-regenerate-code-dialog
v-model="regenerateDialogVisible"
:store-org-id="regenerateStoreOrgId ?? undefined"
:store-name="regenerateStoreName"
:load-activation-codes="(id: string | number) => loadActivationCodesByStore(id).then(r => r.list)"
@success="onRefreshList" />
<shop-max-device-dialog
v-model="maxDeviceDialogVisible"
:store-org-id="maxDeviceStoreOrgId ?? undefined"
:store-name="maxDeviceStoreName"
:load-activation-codes="loadActivationCodesByStore"
@success="onRefreshList" />
<!-- 弹窗:版本管理(代理商无入口) -->
<org-version-dialog
v-if="!isAgent"
v-model="versionDialogVisible"
:org-id="versionOrgId ?? undefined"
:org-name="versionOrgName"
:m-path="versionOrgMPath"
target-label="门店"
@success="onRefreshList" />
<!-- 弹窗:分配卡(销售方在权限子树内可选平台/渠道/代理商) -->
<org-allocate-card-dialog
v-model="allocateCardVisible"
@success="onRefreshList" />
<el-dialog
v-model="agentRollbackDialogVisible"
title="代理商回退"
width="420px"
destroy-on-close
:close-on-click-modal="false"
@closed="onAgentRollbackDialogClosed">
<div v-if="agentRollbackTargetLabel" class="agent-rollback-hint" role="status">
<span class="agent-rollback-hint__icon-wrap" aria-hidden="true">
<el-icon class="agent-rollback-hint__icon"><InfoFilled /></el-icon>
</span>
<div class="agent-rollback-hint__body">
<div class="agent-rollback-hint__title">当前回退的代理商</div>
<div class="agent-rollback-hint__name">{{ agentRollbackTargetLabel }}</div>
</div>
</div>
<el-form ref="agentRollbackFormRef" :model="agentRollbackForm" :rules="agentRollbackRules" label-width="90px">
<el-form-item label="卡类型" prop="cardType">
<el-select v-model="agentRollbackForm.cardType" placeholder="请选择卡类型" style="width: 100%">
<el-option label="年卡" value="YEAR" />
<el-option label="季卡" value="QUARTER" />
<el-option label="月卡" value="MONTH" />
<el-option label="周卡" value="WEEK" />
<el-option label="天卡" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="回退数量" prop="count">
<el-input-number v-model="agentRollbackForm.count" :min="1" :max="9999" controls-position="right" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="agentRollbackDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="agentRollbackLoading" @click="submitAgentRollback">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useUserStore } from '@/stores/user'
import AddOrUpdate from './components/org-add-or-update.vue'
import ShopChannelTree from './components/shop-channel-tree.vue'
import ShopStoreList from './components/shop-store-list.vue'
import ShopCardsDialog from './components/shop-cards-dialog.vue'
import ShopRegenerateCodeDialog from './components/shop-regenerate-code-dialog.vue'
import ShopMaxDeviceDialog from './components/shop-max-device-dialog.vue'
import OrgVersionDialog from './components/org-version-dialog.vue'
import { findPathIdsInTree, buildMpathFromPathIds } from './components/org-version-dialog.data'
import OrgAllocateCardDialog from './components/org-allocate-card-dialog.vue'
import { treeDataTranslate } from '@/utils'
import { extractChannelAgentTree } from './utils/extract-channel-agent-tree'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { getOrgTree as getOrgTreeApi, getContractByStore, searchStore, deleteOrg } from '@/api'
import { rollbackCard } from '@/api/cardFlow'
import { ArrowLeft, ArrowRight, InfoFilled } from '@element-plus/icons-vue'
import type { ShopDataForm, ShopRow, AgentRollbackForm, OrgTreeNode, ActivationCodeResult, ActivationCodeItem, AddOrUpdateInstance } from './shop.data'
defineOptions({ name: 'Shop' })
const userStore = useUserStore()
const isAgent = computed(() => userStore.isAgent)
const dataForm = reactive<ShopDataForm>({
name: '',
activationCode: '',
status: ''
})
// 左侧树收缩状态
const isTreeCollapsed = ref(true)
/** 左侧树面板宽度(可拖拽,并持久化) */
const TREE_PANEL_WIDTH_KEY = 'shop-tree-panel-width'
const TREE_PANEL_MIN = 180
const TREE_PANEL_DEFAULT = 240
function clampTreePanelWidth(w: number) {
const max = Math.max(TREE_PANEL_MIN + 120, Math.floor(window.innerWidth * 0.55))
return Math.round(Math.max(TREE_PANEL_MIN, Math.min(max, w)))
}
function readStoredTreePanelWidth() {
try {
const raw = localStorage.getItem(TREE_PANEL_WIDTH_KEY)
if (raw == null || raw === '') return TREE_PANEL_DEFAULT
const n = Number.parseInt(raw, 10)
if (Number.isNaN(n)) return TREE_PANEL_DEFAULT
return clampTreePanelWidth(n)
} catch {
return TREE_PANEL_DEFAULT
}
}
const treePanelWidth = ref(readStoredTreePanelWidth())
const treePanelResizing = ref(false)
let treePanelDragStartX = 0
let treePanelDragStartWidth = 0
function onTreePanelResizeMove(e: MouseEvent) {
const dx = e.clientX - treePanelDragStartX
treePanelWidth.value = clampTreePanelWidth(treePanelDragStartWidth + dx)
}
function onTreePanelResizeEnd() {
treePanelResizing.value = false
document.removeEventListener('mousemove', onTreePanelResizeMove)
document.removeEventListener('mouseup', onTreePanelResizeEnd)
document.body.style.removeProperty('user-select')
document.body.style.removeProperty('cursor')
try {
localStorage.setItem(TREE_PANEL_WIDTH_KEY, String(treePanelWidth.value))
} catch {
/* ignore */
}
}
function onTreePanelResizeStart(e: MouseEvent) {
treePanelDragStartX = e.clientX
treePanelDragStartWidth = treePanelWidth.value
treePanelResizing.value = true
document.addEventListener('mousemove', onTreePanelResizeMove)
document.addEventListener('mouseup', onTreePanelResizeEnd)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
}
// 左侧树当前选中节点 id(用于前端按树结构筛选门店/渠道)
const selectedChannelTreeId = ref<string | number | null>(null)
// 接口请求用 orgId(与左侧树节点 id 一致,按接口返回)
const selectedChannelId = ref<string | number | null>(null)
// 左侧树当前选中节点类型(用于判断是否可以添加门店)
const selectedChannelType = ref<string | null>(null)
/** 左侧树当前选中节点名称(用于代理商回退等提示) */
const selectedChannelName = ref<string | null>(null)
const orgTreeData = ref<OrgTreeNode[]>([])
/** 左侧已选且为代理服务商时,在工具栏显示「代理商回退」(紧跟「分配卡」后) */
const canAgentRollbackInToolbar = computed(() => {
if (selectedChannelId.value == null || selectedChannelId.value === '') return false
return String(selectedChannelType.value || '').toUpperCase() === 'SERVICE_PROVIDER'
})
/** 弹窗内展示:当前要回退的代理商(名称优先,缺省时用组织 ID) */
const agentRollbackTargetLabel = computed(() => {
if (String(selectedChannelType.value || '').toUpperCase() !== 'SERVICE_PROVIDER') return ''
const name = (selectedChannelName.value && String(selectedChannelName.value).trim()) || ''
if (name) return name
const id = selectedChannelId.value
if (id != null && id !== '') return `组织 ID:${id}`
return ''
})
const channelLoading = ref(false)
const dataList = ref<ShopRow[]>([])
const pageIndex = ref(1)
const pageSize = ref(10)
const totalPage = ref(0)
const dataListLoading = ref(false)
const addOrUpdateVisible = ref(false)
const addOrUpdateRef = ref<AddOrUpdateInstance | null>(null)
const cardsDialogVisible = ref(false)
const cardsStoreId = ref<string | number | null>(null)
const cardsStoreName = ref('')
const regenerateDialogVisible = ref(false)
const regenerateStoreName = ref('')
const regenerateStoreOrgId = ref<string | number | null>(null)
const maxDeviceDialogVisible = ref(false)
const maxDeviceStoreName = ref('')
const maxDeviceStoreOrgId = ref<string | number | null>(null)
const versionDialogVisible = ref(false)
const versionOrgId = ref<string | number | null>(null)
const versionOrgName = ref('')
const versionOrgMPath = ref('')
const allocateCardVisible = ref(false)
const agentRollbackDialogVisible = ref(false)
const agentRollbackLoading = ref(false)
const agentRollbackFormRef = ref<FormInstance | null>(null)
const agentRollbackForm = reactive<AgentRollbackForm>({
cardType: '',
count: 1
})
const agentRollbackRules = reactive<FormRules<AgentRollbackForm>>({
cardType: [{ required: true, message: '请选择卡类型', trigger: 'change' }],
count: [{ required: true, message: '请输入回退数量', trigger: 'change' }]
})
function getOrgTree() {
channelLoading.value = true
getOrgTreeApi()
.then((res) => {
const data = res.data as any
let list: any = []
if (Array.isArray(data)) list = data
else if (data && typeof data === 'object') {
const d = data.data
if (Array.isArray(d)) list = d
else if (d && typeof d === 'object') list = d.tree || d.list || []
else list = data.tree || data.list || []
}
if (!Array.isArray(list)) list = []
const translated = Array.isArray(list) && list.length > 0 && list[0].children !== undefined
? list
: treeDataTranslate(list, 'id', 'parentId')
// 直接使用接口 list 解析后的树,不追加任何包装节点
orgTreeData.value = Array.isArray(translated) ? translated : []
// 首次进入:与左侧 el-tree 的 list(extractChannelAgentTree)一致,默认选第一个根
if (selectedChannelId.value == null) {
const channelRoots = extractChannelAgentTree(orgTreeData.value)
const node = channelRoots.length > 0 ? channelRoots[0] : null
if (node) nextTick(() => onChannelSelect(node as OrgTreeNode))
}
})
.catch(() => {})
.finally(() => {
channelLoading.value = false
})
}
function toggleTreeCollapse() {
isTreeCollapsed.value = !isTreeCollapsed.value
}
function onChannelSelect(node: OrgTreeNode) {
if (!node) return
selectedChannelTreeId.value = node.id
selectedChannelId.value = node.id
selectedChannelType.value = node.type
selectedChannelName.value = (node.name as string) ?? null
pageIndex.value = 1
getDataList()
}
function showCardsHandle(row: ShopRow) {
cardsStoreId.value = row.id
cardsStoreName.value = row.name || (row.storeProfile?.storeName) || '-'
cardsDialogVisible.value = true
}
/** 根据门店组织 ID 拉取该门店下所有权益激活码 */
function loadActivationCodesByStore(storeOrgId: string | number): Promise<ActivationCodeResult> {
return getContractByStore({ orgId: storeOrgId }).then((res) => {
const data = res.data as any
if (!data || data.code !== 0) return { list: [], contractId: null }
const rawList = data.list
if (!Array.isArray(rawList)) return { list: [], contractId: null }
const list: ActivationCodeItem[] = rawList.map((item: any) => ({
id: (item.id ?? item.cacId) as string | number,
code: item.code,
cardType: item.cardType,
maxDeviceCount: item.maxDeviceCount ?? 0
})).filter((item: ActivationCodeItem) => item.id != null)
const contractId = list.length > 0 ? (rawList[0].contractId ?? null) : null
return { list, contractId }
}).catch(() => ({ list: [], contractId: null }))
}
function openRegenerateDialog(row: ShopRow) {
regenerateStoreName.value = row.name || (row.storeProfile?.storeName) || '-'
regenerateStoreOrgId.value = row.orgId ?? row.id
regenerateDialogVisible.value = true
}
function openMaxDeviceDialog(row: ShopRow) {
maxDeviceStoreName.value = row.name || (row.storeProfile?.storeName) || '-'
maxDeviceStoreOrgId.value = row.orgId ?? row.id
maxDeviceDialogVisible.value = true
}
function openVersionDialog(row: ShopRow) {
const orgId = row.orgId ?? row.id
versionOrgId.value = orgId
versionOrgName.value = row.name || (row.storeProfile?.storeName) || '-'
versionOrgMPath.value = buildMpathFromPathIds(findPathIdsInTree(orgTreeData.value, orgId))
|| String(row.mPath ?? row.mpath ?? '')
versionDialogVisible.value = true
}
function openAllocateCardDialog() {
allocateCardVisible.value = true
}
function openAgentRollbackDialog() {
const type = String(selectedChannelType.value || '').toUpperCase()
if (type !== 'SERVICE_PROVIDER') {
ElMessage.warning('请先在左侧选择代理服务商,再执行代理商回退')
return
}
if (selectedChannelId.value == null || selectedChannelId.value === '') {
ElMessage.warning('缺少回退来源组织,请从左侧选择代理')
return
}
agentRollbackDialogVisible.value = true
}
function onAgentRollbackDialogClosed() {
agentRollbackForm.cardType = ''
agentRollbackForm.count = 1
agentRollbackFormRef.value?.clearValidate?.()
}
interface RollbackResponse {
code?: number
rollbackCount?: number
msg?: string
}
function submitAgentRollback() {
agentRollbackFormRef.value?.validate((valid: boolean) => {
if (!valid) return
if (String(selectedChannelType.value || '').toUpperCase() !== 'SERVICE_PROVIDER' || selectedChannelId.value == null) {
ElMessage.warning('回退来源无效,请从左侧选择代理服务商后重试')
return
}
agentRollbackLoading.value = true
rollbackCard({
fromOrgId: Number(selectedChannelId.value),
cardType: agentRollbackForm.cardType,
count: agentRollbackForm.count
})
.then(({ data }: { data: RollbackResponse }) => {
if (data && data.code === 0) {
const who = agentRollbackTargetLabel.value
const qty = data.rollbackCount ?? agentRollbackForm.count
ElMessage.success(
who ? `代理商「${who}」回退成功,数量:${qty}` : `代理商回退成功,数量:${qty}`
)
agentRollbackDialogVisible.value = false
onRefreshList()
} else {
ElMessage.error(data?.msg || '代理商回退失败')
}
})
.catch(() => ElMessage.error('请求失败'))
.finally(() => {
agentRollbackLoading.value = false
})
})
}
interface StoreSearchResponse {
code?: number
/** 人瑞标准:分页在 `data` 内 */
data?: {
list?: ShopRow[]
totalCount?: number
}
/** 少数接口曾用的 `page` 字段 */
page?: {
list?: ShopRow[]
totalCount?: number
}
}
function pickStoreSearchPage(data: StoreSearchResponse | undefined): { list: ShopRow[]; total: number } {
if (!data) return { list: [], total: 0 }
const block = data.data ?? data.page ?? {}
const list = Array.isArray(block.list) ? block.list : []
const total = block.totalCount ?? 0
return { list, total }
}
function getDataList() {
if (!selectedChannelId.value) {
dataList.value = []
totalPage.value = 0
return
}
dataListLoading.value = true
const activationTrim = String(dataForm.activationCode || '').trim()
const params = {
page: pageIndex.value,
limit: pageSize.value,
name: dataForm.name || undefined,
activationCode: activationTrim || undefined,
status: dataForm.status !== '' && dataForm.status !== undefined ? dataForm.status : undefined,
orgId: Number(selectedChannelId.value)
}
searchStore(params)
.then(({ data }: { data: StoreSearchResponse }) => {
const { list, total } = pickStoreSearchPage(data)
dataList.value = list.map(row => ({
...row,
code: row.code ?? row.orgCode ?? '-',
region: row.region ?? row.storeProfile?.region ?? '-',
address: row.address ?? row.storeProfile?.address ?? '-',
phone: row.phone ?? row.storeProfile?.phone ?? '-'
}))
totalPage.value = total
dataListLoading.value = false
}).catch(() => {
dataList.value = []
totalPage.value = 0
dataListLoading.value = false
})
}
function sizeChangeHandle(val: number) {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle(val: number) {
pageIndex.value = val
getDataList()
}
function addHandle() {
addOrUpdateVisible.value = true
nextTick(() => {
const child = addOrUpdateRef.value
if (child && typeof child.init === 'function') {
child.init(null, selectedChannelId.value, { typeOnly: 'STORE' })
}
})
}
function editHandle(id: string | number) {
addOrUpdateVisible.value = true
nextTick(() => {
const child = addOrUpdateRef.value
if (child && typeof child.init === 'function') {
child.init(id)
}
})
}
function deleteHandle(row: ShopRow) {
const name = row.name || row.storeProfile?.storeName || '该门店'
const orgId = row.orgId ?? row.id
ElMessageBox.confirm(`确定删除门店「${name}」?删除后不可恢复。`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
return deleteOrg([Number(orgId)])
.then(({ data }) => {
if (data && data.code === 0) {
ElMessage.success('删除成功')
onRefreshList()
} else {
ElMessage.error(data?.msg || '删除失败')
}
})
.catch(() => ElMessage.error('请求失败'))
}).catch(() => {})
}
function onRefreshList() {
getOrgTree()
if (selectedChannelId.value) {
getDataList()
}
}
onMounted(() => {
getOrgTree()
if (selectedChannelId.value) getDataList()
})
onUnmounted(() => {
document.removeEventListener('mousemove', onTreePanelResizeMove)
document.removeEventListener('mouseup', onTreePanelResizeEnd)
document.body.style.removeProperty('user-select')
document.body.style.removeProperty('cursor')
})
</script>
<style lang="scss" scoped>
.mod-shop {
.shop-layout {
display: flex;
align-items: stretch;
gap: 0;
margin-top: 12px;
}
.shop-tree-container {
flex-shrink: 0;
min-height: 0;
max-height: calc(100vh - 200px);
display: flex;
flex-direction: column;
}
.shop-tree-resizer {
width: 6px;
min-width: 6px;
flex-shrink: 0;
cursor: col-resize;
align-self: stretch;
margin-left: -1px;
margin-right: -1px;
z-index: 2;
background: transparent;
transition: background-color 0.15s;
&:hover {
background: var(--el-color-primary-light-7);
}
}
.shop-layout--resizing .shop-tree-resizer {
background: var(--el-color-primary-light-5);
}
.tree-collapse-bar {
width: 18px;
min-width: 18px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color);
border-left: none;
border-radius: 0 4px 4px 0;
&:hover {
color: var(--el-color-primary);
background: var(--el-fill-color);
}
}
.tree-collapsed .tree-collapse-bar {
border-radius: 4px;
border-left: 1px solid var(--el-border-color);
}
.agent-rollback-hint {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
margin-bottom: 12px;
background: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-7);
border-radius: 6px;
}
.agent-rollback-hint__icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--el-color-warning);
border-radius: 50%;
color: #fff;
flex-shrink: 0;
}
.agent-rollback-hint__icon {
font-size: 16px;
}
.agent-rollback-hint__body {
display: flex;
flex-direction: column;
gap: 2px;
}
.agent-rollback-hint__title {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.agent-rollback-hint__name {
font-size: 14px;
font-weight: 600;
color: var(--el-color-warning);
}
}
</style>

View File

@@ -0,0 +1,69 @@
/** 组织树节点 */
export interface OrgTreeNode {
id: string | number
name: string
type: string
children?: OrgTreeNode[]
[key: string]: unknown
}
/** 渠道代理商树节点 */
export interface ChannelAgentNode {
id: string | number
name: string
type: string
disabled?: boolean
children?: ChannelAgentNode[]
}
/**
* 与 shop-channel-tree 中 el-tree 的 :data 一致:从 org 树提取「渠道 → 代理商」结构
* @param nodes - 组织树节点数组
* @returns 渠道-代理商树
*/
export function extractChannelAgentTree(nodes: OrgTreeNode[] | null | undefined): ChannelAgentNode[] {
if (!nodes || !Array.isArray(nodes)) return []
function mapAgentNode(node: OrgTreeNode): ChannelAgentNode {
const children = (node.children || [])
.filter(c => c.type === 'SERVICE_PROVIDER')
.map(mapAgentNode)
return {
id: node.id,
name: node.name,
type: node.type,
children: children.length > 0 ? children : undefined
}
}
const result: ChannelAgentNode[] = []
for (const node of nodes) {
if (node.type === 'PLATFORM') {
const children = extractChannelAgentTree(node.children || [])
if (children.length === 0) continue
result.push({
id: node.id,
name: node.name,
type: node.type,
disabled: false,
children: children.length > 0 ? children : undefined
})
} else if (node.type === 'CHANNEL') {
const children = (node.children || [])
.filter(c => c.type === 'SERVICE_PROVIDER')
.map(mapAgentNode)
result.push({
id: node.id,
name: node.name,
type: node.type,
children: children.length > 0 ? children : undefined
})
} else if (node.type === 'SERVICE_PROVIDER') {
// 仅有代理商、无渠道时,根或平台子节点必须是 SERVICE_PROVIDER,否则原逻辑只递归 children 会得到空树
result.push(mapAgentNode(node))
} else {
result.push(...extractChannelAgentTree(node.children || []))
}
}
return result
}

View File

@@ -0,0 +1,219 @@
<template>
<div class="mod-api-jst-call-log">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item label="设备 MAC">
<el-input v-model="dataForm.mac" placeholder="MAC 模糊" clearable style="width: 160px"></el-input>
</el-form-item>
<el-form-item label="创建时间区间">
<el-date-picker
v-model="createdAtRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
:default-time="createdAtRangeDefaultTime"
unlink-panels
clearable
style="width: 380px"
/>
</el-form-item>
<el-form-item label="接口路径">
<el-input v-model="dataForm.apiPath" placeholder="路径关键字,后端 LIKE 匹配" clearable style="width: 220px"></el-input>
</el-form-item>
<el-form-item label="请求体">
<el-input v-model="dataForm.requestBody" placeholder="请求体关键字,后端 LIKE 匹配" clearable style="width: 240px"></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
style="width: 100%"
>
<el-table-column
prop="mac"
header-align="center"
align="center"
width="150"
:show-overflow-tooltip="true"
label="设备 MAC"
/>
<el-table-column
prop="deviceSn"
header-align="center"
align="center"
width="140"
:show-overflow-tooltip="true"
label="序列号"
/>
<el-table-column
prop="deviceUid"
header-align="center"
align="center"
width="120"
:show-overflow-tooltip="true"
label="设备 UID"
/>
<el-table-column
prop="createTime"
header-align="center"
align="center"
width="170"
label="创建时间"
/>
<el-table-column
prop="apiPath"
header-align="center"
align="left"
min-width="200"
:show-overflow-tooltip="true"
label="接口路径"
/>
<el-table-column
prop="requestBody"
header-align="center"
align="left"
min-width="220"
:show-overflow-tooltip="true"
label="请求体 requestBody"
/>
<el-table-column
prop="responseBody"
header-align="center"
align="left"
min-width="220"
:show-overflow-tooltip="true"
label="请求结果"
/>
<el-table-column
prop="errorMessage"
header-align="center"
align="left"
min-width="220"
:show-overflow-tooltip="true"
label="错误信息"
/>
<el-table-column
prop="storeOrgName"
header-align="center"
align="center"
min-width="140"
:show-overflow-tooltip="true"
label="组织名称"
/>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper"
>
</el-pagination>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { getApiJstCallLogList } from '@/api/sys'
import type { ApiJstCallLogInfo } from '@/api/sys'
import type { PageParams, PageResult } from '@/types/api'
interface DataForm {
mac: string
apiPath: string
requestBody: string
}
type JstLogListResponsePayload = {
data?: { page?: PageResult<ApiJstCallLogInfo> } & Partial<PageResult<ApiJstCallLogInfo>>
page?: PageResult<ApiJstCallLogInfo>
}
const dataForm = reactive<DataForm>({
mac: '',
apiPath: '',
requestBody: ''
})
/** 选日期后默认起始 00:00:00、结束 23:59:59,避免只选同一天时区间过窄 */
const createdAtRangeDefaultTime: [Date, Date] = [
new Date(2000, 0, 1, 0, 0, 0),
new Date(2000, 0, 1, 23, 59, 59)
]
const createdAtRange = ref<[string, string] | null>(null)
const dataList = ref<ApiJstCallLogInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
/**
* GET /sys/apiJstCallLog/list 查询参数:
* - mac(模糊由后端 LIKE)
* - beginTime / endTime:创建时间闭区间(与 audit-log 一致)
* - apiPath:路径模糊(由后端 LIKE)
* - requestBody:请求体模糊(由后端 LIKE)
*/
function buildListParams (): PageParams {
const p: PageParams = { page: pageIndex.value, limit: pageSize.value }
const t = (s: string | null | undefined): string => (s != null && String(s).trim() !== '' ? String(s).trim() : '')
const mac = t(dataForm.mac)
if (mac) p.mac = mac
const apiPath = t(dataForm.apiPath)
if (apiPath) p.apiPath = apiPath
const requestBody = t(dataForm.requestBody)
if (requestBody) p.requestBody = requestBody
const r = createdAtRange.value
if (Array.isArray(r) && r.length === 2 && r[0] && r[1]) {
p.beginTime = r[0]
p.endTime = r[1]
}
return p
}
function getDataList (): void {
dataListLoading.value = true
getApiJstCallLogList(buildListParams()).then((data) => {
if (data && data.code === 0) {
const raw = data as JstLogListResponsePayload
const page = raw.data?.page ?? raw.page ?? raw.data
if (page && (Array.isArray(page.list) || page.totalCount != null)) {
dataList.value = page.list || []
totalPage.value = page.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
} else {
dataList.value = []
totalPage.value = 0
}
dataListLoading.value = false
})
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
onMounted(() => {
getDataList()
})
</script>

View File

@@ -0,0 +1,211 @@
<template>
<div class="mod-audit-log">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item label="操作者">
<el-input v-model="dataForm.actorName" placeholder="按用户名模糊" clearable style="width: 180px"></el-input>
</el-form-item>
<el-form-item label="操作行为">
<el-input v-model="dataForm.head" placeholder="按操作行为模糊" clearable style="width: 200px"></el-input>
</el-form-item>
<el-form-item label="接口">
<el-input v-model="dataForm.api" placeholder="类#方法 等" clearable style="width: 220px"></el-input>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="createdAtRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
style="width: 100%">
<el-table-column
prop="id"
header-align="center"
align="center"
width="80"
label="ID">
</el-table-column>
<el-table-column
prop="name"
header-align="center"
align="center"
width="100"
label="操作者">
</el-table-column>
<el-table-column
prop="head"
header-align="center"
align="center"
min-width="160"
:show-overflow-tooltip="true"
label="操作行为">
</el-table-column>
<el-table-column
prop="createdAt"
header-align="center"
align="center"
width="170"
label="创建时间">
</el-table-column>
<el-table-column
prop="api"
header-align="center"
align="center"
min-width="200"
:show-overflow-tooltip="true"
label="接口">
</el-table-column>
<el-table-column
prop="param"
header-align="center"
align="left"
min-width="200"
:show-overflow-tooltip="true"
label="参数">
</el-table-column>
<el-table-column
prop="paramDesc"
header-align="center"
align="left"
min-width="200"
:show-overflow-tooltip="true"
label="参数描述">
</el-table-column>
<el-table-column
prop="result"
header-align="center"
align="center"
width="100"
:show-overflow-tooltip="true"
label="结果">
</el-table-column>
<el-table-column
prop="ip"
header-align="center"
align="center"
width="150"
:show-overflow-tooltip="true"
label="IP">
</el-table-column>
<el-table-column
prop="deviceInfo"
header-align="center"
align="left"
min-width="180"
:show-overflow-tooltip="true"
label="客户端">
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { getAuditLogList } from '@/api/sys'
import type { AuditLogInfo } from '@/api/sys'
import type { PageParams, PageResult } from '@/types/api'
interface DataForm {
actorName: string
head: string
api: string
}
/** 列表接口可能返回根级 page,或包在 data / data.page 下 */
type AuditListResponsePayload = {
data?: { page?: PageResult<AuditLogInfo> } & Partial<PageResult<AuditLogInfo>>
page?: PageResult<AuditLogInfo>
}
const dataForm = reactive<DataForm>({
actorName: '',
head: '',
api: ''
})
const createdAtRange = ref<[string, string] | null>(null)
const dataList = ref<AuditLogInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
function buildListParams (): PageParams {
const p: PageParams = { page: pageIndex.value, limit: pageSize.value }
const t = (s: string | null | undefined): string => (s != null && String(s).trim() !== '' ? String(s).trim() : '')
const actor = t(dataForm.actorName)
if (actor) p.actorName = actor
const head = t(dataForm.head)
if (head) p.head = head
const api = t(dataForm.api)
if (api) p.api = api
const r = createdAtRange.value
if (Array.isArray(r) && r.length === 2 && r[0] && r[1]) {
p.beginTime = r[0]
p.endTime = r[1]
}
return p
}
function getDataList (): void {
dataListLoading.value = true
getAuditLogList(buildListParams()).then((data) => {
if (data && data.code === 0) {
// 兼容:{ code, page };或 { code, data: { page } };或 data 本体即分页字段
const raw = data as AuditListResponsePayload
const page = raw.data?.page ?? raw.page ?? raw.data
if (page && (Array.isArray(page.list) || page.totalCount != null)) {
dataList.value = page.list || []
totalPage.value = page.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
} else {
dataList.value = []
totalPage.value = 0
}
dataListLoading.value = false
})
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
onMounted(() => {
getDataList()
})
</script>

View File

@@ -0,0 +1,108 @@
<template>
<el-dialog
:title="!dataForm.id ? '新增' : '修改'"
:close-on-click-modal="false"
v-model="visible">
<el-form :model="dataForm" :rules="dataRule" ref="dataFormRef" @keyup.enter="dataFormSubmit()" label-width="80px">
<el-form-item label="参数名" prop="paramKey">
<el-input v-model="dataForm.paramKey" placeholder="参数名"></el-input>
</el-form-item>
<el-form-item label="参数值" prop="paramValue">
<el-input v-model="dataForm.paramValue" placeholder="参数值"></el-input>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="dataForm.remark" placeholder="备注"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmit()">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref, nextTick, useTemplateRef } from 'vue'
import { getConfigInfo, saveConfig } from '@/api/sys'
import { ElMessage, type FormInstance, type FormItemRule } from 'element-plus'
const emit = defineEmits<{
refreshDataList: []
}>()
const visible = ref<boolean>(false)
interface DataForm {
id: number | null
paramKey: string
paramValue: string
remark: string
}
const dataForm = reactive<DataForm>({
id: null,
paramKey: '',
paramValue: '',
remark: ''
})
const dataRule: Record<string, FormItemRule[]> = {
paramKey: [
{ required: true, message: '参数名不能为空', trigger: 'blur' }
],
paramValue: [
{ required: true, message: '参数值不能为空', trigger: 'blur' }
]
}
const dataFormRef = useTemplateRef<FormInstance>('dataFormRef')
function init (id?: number): void {
dataForm.id = id || null
visible.value = true
nextTick(() => {
dataFormRef.value?.resetFields()
if (dataForm.id) {
getConfigInfo(dataForm.id).then((data) => {
if (data && data.code === 0 && data.data) {
const config = data.data
dataForm.paramKey = config.paramKey
dataForm.paramValue = config.paramValue
dataForm.remark = config.remark || ''
}
})
}
})
}
// 表单提交
function dataFormSubmit (): void {
dataFormRef.value?.validate((valid) => {
if (valid) {
saveConfig({
id: dataForm.id || undefined,
paramKey: dataForm.paramKey,
paramValue: dataForm.paramValue,
remark: dataForm.remark
}).then((data) => {
if (data && data.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
visible.value = false
emit('refreshDataList')
}
})
} else {
ElMessage.error(data.msg)
}
})
}
})
}
defineExpose({ init })
</script>

View File

@@ -0,0 +1,315 @@
<template>
<el-dialog
:title="!dataForm.id ? '新增' : '修改'"
:close-on-click-modal="false"
v-model="visible">
<el-form :model="dataForm" :rules="dataRule" ref="dataFormRef" @keyup.enter="dataFormSubmit()" label-width="80px">
<el-form-item label="类型" prop="type">
<el-radio-group v-model="dataForm.type">
<el-radio v-for="(type, index) in dataForm.typeList" :value="index" :key="index">{{ type }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="dataForm.typeList[dataForm.type] + '名称'" prop="name">
<el-input v-model="dataForm.name" :placeholder="dataForm.typeList[dataForm.type] + '名称'"></el-input>
</el-form-item>
<el-form-item label="上级菜单" prop="parentName">
<el-popover
ref="menuListPopoverRef"
placement="bottom-start"
trigger="click"
:width="300">
<template #reference>
<el-input v-model="dataForm.parentName" readonly placeholder="点击选择上级菜单" class="menu-list__input"></el-input>
</template>
<el-tree
:data="menuList"
:props="menuListTreeProps"
node-key="id"
ref="menuListTreeRef"
@current-change="menuListTreeCurrentChangeHandle"
:default-expand-all="true"
:highlight-current="true"
:expand-on-click-node="false">
</el-tree>
</el-popover>
</el-form-item>
<el-form-item v-if="dataForm.type === 1" label="菜单路由" prop="url">
<el-input v-model="dataForm.url" placeholder="菜单路由"></el-input>
</el-form-item>
<el-form-item v-if="dataForm.type !== 0" label="授权标识" prop="perms">
<el-input v-model="dataForm.perms" placeholder="多个用逗号分隔, 如: user:list,user:create"></el-input>
</el-form-item>
<el-form-item v-if="dataForm.type !== 2" label="排序号" prop="orderNum">
<el-input-number v-model="dataForm.orderNum" controls-position="right" :min="0" label="排序号"></el-input-number>
</el-form-item>
<el-form-item v-if="dataForm.type !== 2" label="是否展示" prop="show">
<el-radio-group v-model="dataForm.show">
<el-radio :value="10">展示</el-radio>
<el-radio :value="20">隐藏</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="dataForm.type !== 2" label="菜单图标" prop="icon">
<el-row>
<el-col :span="22">
<el-popover
ref="iconListPopoverRef"
placement="bottom-start"
trigger="click"
popper-class="mod-menu__icon-popover"
:width="458">
<template #reference>
<el-input :model-value="''" readonly placeholder="点击选择菜单图标" class="icon-list__input">
<template v-if="dataForm.icon && iconMap[dataForm.icon as keyof typeof iconMap]" #prefix>
<el-icon><component :is="iconMap[dataForm.icon as keyof typeof iconMap]" /></el-icon>
</template>
</el-input>
</template>
<div class="mod-menu__icon-inner">
<div class="mod-menu__icon-list">
<el-button
v-for="(iconName, index) in iconList"
:key="index"
@click="iconActiveHandle(iconName)"
:class="{ 'is-active': iconName === dataForm.icon }">
<el-icon :size="18">
<component :is="iconMap[iconName as keyof typeof iconMap]" />
</el-icon>
</el-button>
</div>
</div>
</el-popover>
</el-col>
<el-col :span="2" class="icon-list__tips">
<el-tooltip placement="top" effect="light">
<template #content>
使用 Element Plus Icons,保存的值为图标组件名
</template>
<span class="icon-list__tips-icon">?</span>
</el-tooltip>
</el-col>
</el-row>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmit()">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref, nextTick, useTemplateRef } from 'vue'
import { getMenuSelect, getMenuInfo, saveMenu } from '@/api/sys'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { treeDataTranslate } from '@/utils'
import { ElMessage, type FormInstance, type FormItemRule, type TreeInstance } from 'element-plus'
import type { MenuInfo } from '@/api/sys'
// Element Plus Icons 图标名列表(用于菜单图标选择)
const iconNames = Object.keys(ElementPlusIconsVue).filter(
key => key !== 'default' && (typeof ElementPlusIconsVue[key as keyof typeof ElementPlusIconsVue] === 'object' || typeof ElementPlusIconsVue[key as keyof typeof ElementPlusIconsVue] === 'function') && ElementPlusIconsVue[key as keyof typeof ElementPlusIconsVue] != null
)
const iconList = iconNames
const iconMap = ElementPlusIconsVue
const emit = defineEmits<{
refreshDataList: []
}>()
const visible = ref<boolean>(false)
interface DataForm {
id: number | null
type: number
typeList: string[]
name: string
parentId: number
parentName: string
url: string
perms: string
orderNum: number
icon: string
show: number
}
const dataForm = reactive<DataForm>({
id: null,
type: 1,
typeList: ['目录', '菜单', '按钮'],
name: '',
parentId: 0,
parentName: '',
url: '',
perms: '',
orderNum: 0,
icon: '',
show: 10
})
const validateUrl: FormItemRule['validator'] = (_rule, value, callback) => {
if (dataForm.type === 1 && !/\S/.test(value as string)) {
callback(new Error('菜单URL不能为空'))
} else {
callback()
}
}
const dataRule: Record<string, FormItemRule[]> = {
name: [
{ required: true, message: '菜单名称不能为空', trigger: 'blur' }
],
parentName: [
{ required: true, message: '上级菜单不能为空', trigger: 'change' }
],
url: [
{ validator: validateUrl, trigger: 'blur' }
]
}
const menuList = ref<any[]>([])
const menuListTreeProps = {
label: 'name',
children: 'children'
}
const dataFormRef = useTemplateRef<FormInstance>('dataFormRef')
const menuListTreeRef = useTemplateRef<TreeInstance>('menuListTreeRef')
function init (id?: number): void {
dataForm.id = id || null
getMenuSelect().then((data) => {
if (!data || data.code !== 0) return
const body = data as unknown as { data?: { menuList?: MenuInfo[] }; menuList?: MenuInfo[] }
const rawList = body.data?.menuList ?? body.menuList ?? []
menuList.value = treeDataTranslate(rawList, 'id')
}).then(() => {
visible.value = true
nextTick(() => {
dataFormRef.value?.resetFields()
})
}).then(() => {
if (!dataForm.id) {
// 新增
menuListTreeSetCurrentNode()
} else {
// 修改
getMenuInfo(dataForm.id).then((data) => {
if (!data || data.code !== 0) return
const body = data as unknown as { data?: MenuInfo; menu?: MenuInfo }
const menu = body.data ?? body.menu
if (menu && typeof menu === 'object') {
dataForm.id = menu.id
dataForm.type = menu.type ?? 1
dataForm.name = menu.name
dataForm.parentId = menu.parentId ?? 0
dataForm.url = menu.url || ''
dataForm.perms = menu.perms || ''
dataForm.orderNum = menu.orderNum ?? 0
dataForm.icon = menu.icon || ''
const rawShow = menu.show != null ? menu.show : (menu as any).isShow
dataForm.show = rawShow != null ? Number(rawShow) : 10
menuListTreeSetCurrentNode()
}
})
}
})
}
// 菜单树选中
function menuListTreeCurrentChangeHandle (data: MenuInfo): void {
dataForm.parentId = data.id
dataForm.parentName = data.name
}
// 菜单树设置当前选中节点
function menuListTreeSetCurrentNode (): void {
menuListTreeRef.value?.setCurrentKey(dataForm.parentId)
const currentNode = menuListTreeRef.value?.getCurrentNode()
dataForm.parentName = (currentNode as MenuInfo | undefined)?.name || ''
}
// 图标选中
function iconActiveHandle (iconName: string): void {
dataForm.icon = iconName
}
// 表单提交
function dataFormSubmit (): void {
dataFormRef.value?.validate((valid) => {
if (valid) {
saveMenu({
id: dataForm.id || undefined,
type: dataForm.type,
name: dataForm.name,
parentId: dataForm.parentId,
url: dataForm.url,
perms: dataForm.perms,
orderNum: dataForm.orderNum,
icon: dataForm.icon,
show: dataForm.show
}).then((data) => {
if (data && data.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
visible.value = false
emit('refreshDataList')
}
})
} else {
ElMessage.error(data.msg)
}
})
}
})
}
defineExpose({ init })
</script>
<style lang="scss">
.mod-menu {
.menu-list__input,
.icon-list__input {
> .el-input__inner {
cursor: pointer;
}
}
&__icon-popover {
width: 458px;
overflow: hidden;
}
&__icon-inner {
width: 478px;
max-height: 258px;
overflow-x: hidden;
overflow-y: auto;
}
&__icon-list {
width: 458px;
padding: 0;
margin: -8px 0 0 -8px;
> .el-button {
padding: 8px;
margin: 8px 0 0 8px;
display: inline-flex;
align-items: center;
gap: 6px;
> .el-icon {
flex-shrink: 0;
}
}
}
.icon-list__tips {
font-size: 18px;
text-align: center;
color: #e6a23c;
cursor: pointer;
.icon-list__tips-icon { font-weight: bold; }
}
}
</style>

View File

@@ -0,0 +1,158 @@
<template>
<el-dialog
:title="!dataForm.id ? '新增' : '修改'"
:close-on-click-modal="false"
v-model="visible">
<el-form :model="dataForm" :rules="dataRule" ref="dataFormRef" @keyup.enter="dataFormSubmit()" label-width="80px">
<el-form-item label="角色名称" prop="roleName">
<el-input v-model="dataForm.roleName" placeholder="角色名称"></el-input>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="dataForm.remark" placeholder="备注"></el-input>
</el-form-item>
<el-form-item size="small" label="授权">
<el-tree
:data="menuList"
:props="menuListTreeProps"
node-key="id"
ref="menuListTreeRef"
:default-expand-all="true"
show-checkbox>
</el-tree>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmit()">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref, nextTick, useTemplateRef } from 'vue'
import { getMenuList, getRoleInfo, saveRole } from '@/api/sys'
import { ElMessage, type FormInstance, type FormItemRule, type TreeInstance } from 'element-plus'
import { treeDataTranslate } from '@/utils'
import type { RoleInfo } from '@/api/sys'
const emit = defineEmits<{
refreshDataList: []
}>()
const visible = ref<boolean>(false)
const menuList = ref<any[]>([])
const menuListTreeProps = {
label: 'name',
children: 'children'
}
interface DataForm {
id: number | null
roleName: string
remark: string
}
const dataForm = reactive<DataForm>({
id: null,
roleName: '',
remark: ''
})
const dataRule: Record<string, FormItemRule[]> = {
roleName: [
{ required: true, message: '角色名称不能为空', trigger: 'blur' }
]
}
const tempKey = -666666 // 临时key, 用于解决tree半选中状态项不能传给后台接口问题. # 待优化
const dataFormRef = useTemplateRef<FormInstance>('dataFormRef')
const menuListTreeRef = useTemplateRef<TreeInstance>('menuListTreeRef')
function init (id?: number): void {
dataForm.id = id || null
getMenuList().then((resp) => {
if (!resp || resp.code !== 0) return
const r = resp as unknown as Record<string, any>
const dRaw = r.data
const d =
dRaw != null &&
typeof dRaw === 'object' &&
!Array.isArray(dRaw)
? (dRaw as Record<string, any>)
: ({} as Record<string, any>)
let list: any[] = []
if (Array.isArray(d.menuList)) list = d.menuList
else if (Array.isArray(r.menuList)) list = r.menuList
else if (d.page?.list) list = d.page.list
else if (Array.isArray(d.list)) list = d.list
else if (Array.isArray(r.list)) list = r.list
else {
const bucket = d.page ?? d
list = Array.isArray(bucket) ? bucket : bucket?.list ?? []
}
menuList.value = treeDataTranslate(list, 'id', 'parentId')
}).then(() => {
visible.value = true
nextTick(() => {
dataFormRef.value?.resetFields()
menuListTreeRef.value?.setCheckedKeys([])
})
}).then(() => {
if (dataForm.id) {
getRoleInfo(dataForm.id).then((body) => {
if (!body || body.code !== 0) return
const env = body as unknown as {
data?: RoleInfo & { menuIdList?: number[] }
role?: RoleInfo & { menuIdList?: number[] }
}
const rolePayload = env.data ?? env.role
if (!rolePayload || typeof rolePayload !== 'object') return
const role = rolePayload as RoleInfo & { menuIdList: number[] }
dataForm.roleName = role.roleName || ''
dataForm.remark = role.remark || ''
const menuIdList = Array.isArray(role.menuIdList) ? role.menuIdList : []
const idx = menuIdList.indexOf(tempKey)
if (idx !== -1) {
menuIdList.splice(idx, menuIdList.length - idx)
}
menuIdList.forEach((mid: number) => {
menuListTreeRef.value?.setChecked(mid, true, false)
})
})
}
})
}
function dataFormSubmit (): void {
dataFormRef.value?.validate((valid) => {
if (valid) {
const checkedKeys = (menuListTreeRef.value?.getCheckedKeys() || []) as number[]
const halfCheckedKeys = (menuListTreeRef.value?.getHalfCheckedKeys() || []) as number[]
saveRole({
id: dataForm.id || undefined,
roleName: dataForm.roleName,
remark: dataForm.remark,
menuIdList: [...checkedKeys, ...halfCheckedKeys].filter((id: number) => id !== tempKey)
}).then((data) => {
if (data && data.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
visible.value = false
emit('refreshDataList')
}
})
} else {
ElMessage.error(data.msg)
}
})
}
})
}
defineExpose({ init })
</script>

View File

@@ -0,0 +1,332 @@
<template>
<el-dialog
:title="!dataForm.id ? '添加用户' : '修改'"
:close-on-click-modal="false"
v-model="visible">
<el-form :model="dataForm" :rules="dataRule" ref="dataFormRef" @keyup.enter="dataFormSubmit()" label-width="100px">
<template v-if="!dataForm.id">
<el-form-item label="归属方式" prop="bindScopeType">
<el-radio-group v-model="dataForm.bindScopeType" @change="onBindScopeChange">
<el-radio value="channel_agent">渠道 / 代理商</el-radio>
<el-radio value="store">门店</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="dataForm.bindScopeType === 'channel_agent'"
label="所属组织"
prop="selectedOrgId">
<org-tree-select
v-model="dataForm.selectedOrgId"
:hide-node-types="orgTreeHideTypes"
height="320px"
@change="onOrgTreeChange" />
</el-form-item>
<el-form-item v-else label="所属门店" prop="storeOrgId">
<store-select
v-model="dataForm.storeOrgId"
width="100%"
@change="onStoreSelectChange" />
</el-form-item>
</template>
<el-form-item label="用户名" prop="userName">
<el-input v-model="dataForm.userName" placeholder="登录帐号"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password" :class="{ 'is-required': !dataForm.id }">
<el-input
v-model="dataForm.password"
type="password"
:placeholder="dataForm.id ? '不改请留空' : '密码'"
autocomplete="new-password"></el-input>
<div v-if="!dataForm.id" class="form-tip" style="margin-top: 4px;">最短8位,且须同时包含小写、大写、数字、特殊字符4种</div>
<div v-else class="form-tip" style="margin-top: 4px;">不修改密码请留空</div>
</el-form-item>
<el-form-item label="确认密码" prop="comfirmPassword" :class="{ 'is-required': !dataForm.id }">
<el-input
v-model="dataForm.comfirmPassword"
type="password"
:placeholder="dataForm.id ? '不改请留空' : '确认密码'"
autocomplete="new-password"></el-input>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="dataForm.email" placeholder="选填"></el-input>
</el-form-item>
<el-form-item label="手机号" prop="mobile">
<el-input v-model="dataForm.mobile" placeholder="选填"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmit()">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref, nextTick } from 'vue'
import { getUserInfo, saveUser, type UserInfo } from '@/api/sys'
import { isEmail, isMobile } from '@/utils/validate'
import { ElMessage, type FormInstance, type FormItemRule } from 'element-plus'
import OrgTreeSelect from '@/components/org-tree-select/index.vue'
import StoreSelect from '@/components/store-select/index.vue'
const emit = defineEmits<{
refreshDataList: []
}>()
interface OrgTreePayload {
orgId: string | number | null
channelOrgId?: string | number | null
agentOrgId?: string | number | null
}
/** 组织树仅展示渠道/代理商,不包含门店与平台根 */
const orgTreeHideTypes = ['PLATFORM', 'STORE']
const visible = ref<boolean>(false)
const dataFormRef = ref<FormInstance | null>(null)
const orgBindings = reactive<OrgTreePayload>({
orgId: null,
channelOrgId: null,
agentOrgId: null
})
interface DataForm {
id: number
bindScopeType: 'channel_agent' | 'store'
selectedOrgId: string | number | undefined
storeOrgId: string | number | undefined
userName: string
password: string
comfirmPassword: string
salt: string
email: string
mobile: string
status: number
}
const dataForm = reactive<DataForm>({
id: 0,
bindScopeType: 'channel_agent',
selectedOrgId: undefined,
storeOrgId: undefined,
userName: '',
password: '',
comfirmPassword: '',
salt: '',
email: '',
mobile: '',
status: 1
})
function onOrgTreeChange (payload: OrgTreePayload): void {
orgBindings.orgId = payload.orgId
orgBindings.channelOrgId = payload.channelOrgId
orgBindings.agentOrgId = payload.agentOrgId
nextTick(() => {
dataFormRef.value?.validateField('selectedOrgId')
})
}
function onBindScopeChange (): void {
dataForm.selectedOrgId = undefined
dataForm.storeOrgId = undefined
orgBindings.orgId = null
orgBindings.channelOrgId = null
orgBindings.agentOrgId = null
nextTick(() => {
dataFormRef.value?.clearValidate(['selectedOrgId', 'storeOrgId'])
})
}
function onStoreSelectChange (): void {
nextTick(() => {
dataFormRef.value?.validateField('storeOrgId')
})
}
function validatePasswordStrength (password: string): { valid: boolean; message?: string } {
if (!password || password.length < 8) return { valid: false, message: '密码长度不能少于8位' }
const hasLower = /[a-z]/.test(password)
const hasUpper = /[A-Z]/.test(password)
const hasDigit = /[0-9]/.test(password)
const hasSpecial = /[^A-Za-z0-9]/.test(password)
if (!hasLower) return { valid: false, message: '密码须包含小写字母' }
if (!hasUpper) return { valid: false, message: '密码须包含大写字母' }
if (!hasDigit) return { valid: false, message: '密码须包含数字' }
if (!hasSpecial) return { valid: false, message: '密码须包含特殊字符' }
return { valid: true }
}
const validatePassword: FormItemRule['validator'] = (_rule, value, callback) => {
const v = typeof value === 'string' ? value.trim() : ''
if (!dataForm.id && !v) {
callback(new Error('密码不能为空'))
} else if (!dataForm.id && v) {
const r = validatePasswordStrength(v)
if (!r.valid) return callback(new Error(r.message))
callback()
} else if (dataForm.id && v) {
const r = validatePasswordStrength(v)
if (!r.valid) return callback(new Error(r.message))
callback()
} else {
callback()
}
}
const validateComfirmPassword: FormItemRule['validator'] = (_rule, value, callback) => {
const confirm = typeof value === 'string' ? value.trim() : ''
const pwd = typeof dataForm.password === 'string' ? dataForm.password.trim() : ''
if (!dataForm.id && !confirm) {
callback(new Error('确认密码不能为空'))
} else if (!pwd && confirm) {
callback(new Error('请先填写密码'))
} else if (pwd !== confirm) {
callback(new Error('确认密码与密码输入不一致'))
} else {
callback()
}
}
const validateEmail: FormItemRule['validator'] = (_rule, value, callback) => {
if (!value || !String(value).trim()) {
return callback()
}
if (!isEmail(value as string)) {
callback(new Error('邮箱格式错误'))
} else {
callback()
}
}
const validateMobile: FormItemRule['validator'] = (_rule, value, callback) => {
if (!value || !String(value).trim()) {
return callback()
}
if (!isMobile(value as string)) {
callback(new Error('手机号格式错误'))
} else {
callback()
}
}
const validateChannelAgentOrg: FormItemRule['validator'] = (_rule, value, callback) => {
if (dataForm.id || dataForm.bindScopeType !== 'channel_agent') return callback()
if (value == null || value === '') {
return callback(new Error('请选择渠道或代理商'))
}
callback()
}
const validateStoreOrg: FormItemRule['validator'] = (_rule, value, callback) => {
if (dataForm.id || dataForm.bindScopeType !== 'store') return callback()
if (value == null || value === '') {
return callback(new Error('请选择门店'))
}
callback()
}
const dataRule: Record<string, FormItemRule[]> = {
selectedOrgId: [{ validator: validateChannelAgentOrg, trigger: 'change' }],
storeOrgId: [{ validator: validateStoreOrg, trigger: 'change' }],
userName: [{ required: true, message: '用户名不能为空', trigger: 'blur' }],
password: [{ validator: validatePassword, trigger: 'blur' }],
comfirmPassword: [{ validator: validateComfirmPassword, trigger: 'blur' }],
email: [{ validator: validateEmail, trigger: 'blur' }],
mobile: [{ validator: validateMobile, trigger: 'blur' }]
}
function init (id?: number | string | null): void {
dataForm.id = id ? Number(id) : 0
visible.value = true
nextTick(() => {
if (dataFormRef.value && dataFormRef.value.clearValidate) {
dataFormRef.value.clearValidate()
}
dataForm.bindScopeType = 'channel_agent'
dataForm.selectedOrgId = undefined
dataForm.storeOrgId = undefined
orgBindings.orgId = null
orgBindings.channelOrgId = null
orgBindings.agentOrgId = null
dataForm.userName = ''
dataForm.password = ''
dataForm.comfirmPassword = ''
dataForm.salt = ''
dataForm.email = ''
dataForm.mobile = ''
dataForm.status = 1
})
if (id !== null && id !== undefined && id !== '') {
getUserInfo(Number(id)).then((body) => {
if (!body || body.code !== 0) return
const env = body as unknown as { data?: UserInfo; user?: UserInfo }
const user = env.data ?? env.user
if (!user || typeof user !== 'object') return
dataForm.id = user.id
dataForm.userName = user.username || ''
dataForm.salt = user.salt || ''
dataForm.email = user.email || ''
dataForm.mobile = user.mobile || ''
dataForm.status = user.status != null ? Number(user.status) : 1
dataForm.password = ''
dataForm.comfirmPassword = ''
})
}
}
function dataFormSubmit (): void {
if (!dataFormRef.value) return
dataFormRef.value.validate((valid) => {
if (!valid) return
const pwdTrim = typeof dataForm.password === 'string' ? dataForm.password.trim() : ''
const payload: Record<string, any> = {
id: dataForm.id || undefined,
username: dataForm.userName,
salt: dataForm.salt,
email: dataForm.email,
mobile: dataForm.mobile,
status: dataForm.status
}
if (dataForm.id) {
if (pwdTrim) payload.password = pwdTrim
} else {
payload.password = pwdTrim
}
if (!dataForm.id) {
if (dataForm.bindScopeType === 'channel_agent') {
payload.orgId = orgBindings.orgId ?? dataForm.selectedOrgId
if (orgBindings.channelOrgId != null) {
payload.channelOrgId = orgBindings.channelOrgId
}
if (orgBindings.agentOrgId) {
payload.agentOrgId = orgBindings.agentOrgId
}
} else {
payload.orgId = dataForm.storeOrgId
}
}
saveUser(payload).then((res) => {
if (res && res.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
visible.value = false
emit('refreshDataList')
}
})
} else {
ElMessage.error(res?.msg || '请求失败')
}
})
})
}
defineExpose({ init })
</script>

168
src/views/modules/sys/config.vue Executable file
View File

@@ -0,0 +1,168 @@
<template>
<div class="mod-config">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.paramKey" placeholder="参数名" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
<el-button type="primary" @click="addOrUpdateHandle()">新增</el-button>
<el-button type="danger" @click="deleteHandle()" :disabled="dataListSelections.length <= 0">批量删除</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
@selection-change="selectionChangeHandle"
style="width: 100%;">
<el-table-column
type="selection"
header-align="center"
align="center"
width="50">
</el-table-column>
<el-table-column
prop="id"
header-align="center"
align="center"
width="80"
label="ID">
</el-table-column>
<el-table-column
prop="paramKey"
header-align="center"
align="center"
label="参数名">
</el-table-column>
<el-table-column
prop="paramValue"
header-align="center"
align="center"
label="参数值">
</el-table-column>
<el-table-column
prop="remark"
header-align="center"
align="center"
label="备注">
</el-table-column>
<el-table-column
fixed="right"
header-align="center"
align="center"
width="150"
label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update v-if="addOrUpdateVisible" ref="addOrUpdateRef" @refreshDataList="getDataList"></add-or-update>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onActivated, nextTick, useTemplateRef } from 'vue'
import AddOrUpdate from './components/config-add-or-update.vue'
import { getConfigList, deleteConfig } from '@/api/sys'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { ConfigInfo } from '@/api/sys'
interface DataForm {
paramKey: string
}
const dataForm = reactive<DataForm>({
paramKey: ''
})
const dataList = ref<ConfigInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
const dataListSelections = ref<ConfigInfo[]>([])
const addOrUpdateVisible = ref<boolean>(false)
const addOrUpdateRef = useTemplateRef<InstanceType<typeof AddOrUpdate>>('addOrUpdateRef')
function getDataList (): void {
dataListLoading.value = true
getConfigList({
page: pageIndex.value,
limit: pageSize.value,
paramKey: dataForm.paramKey
}).then((data) => {
if (data && data.code === 0 && data.data) {
const page = data.data.page || data.data
dataList.value = page.list || []
totalPage.value = page.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
dataListLoading.value = false
})
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
function selectionChangeHandle (val: ConfigInfo[]): void {
dataListSelections.value = val
}
async function addOrUpdateHandle (id?: number): Promise<void> {
addOrUpdateVisible.value = true
await nextTick()
addOrUpdateRef.value?.init(id)
}
function deleteHandle (id?: number): void {
const ids = id ? [id] : dataListSelections.value.map(item => item.id)
ElMessageBox.confirm(`确定对[id=${ids.join(',')}]进行[${id ? '删除' : '批量删除'}]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteConfig(ids as number[]).then((data) => {
if (data && data.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
}
})
} else {
ElMessage.error(data.msg)
}
})
}).catch(() => {})
}
onActivated(() => {
getDataList()
})
</script>

138
src/views/modules/sys/log.vue Executable file
View File

@@ -0,0 +1,138 @@
<template>
<div class="mod-log">
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
<el-form-item>
<el-input v-model="dataForm.key" placeholder="用户名/用户操作" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="getDataList()">查询</el-button>
</el-form-item>
</el-form>
<div class="table-page-wrap">
<el-table
:data="dataList"
border
v-loading="dataListLoading"
style="width: 100%">
<el-table-column
prop="id"
header-align="center"
align="center"
width="80"
label="ID">
</el-table-column>
<el-table-column
prop="username"
header-align="center"
align="center"
label="用户名">
</el-table-column>
<el-table-column
prop="operation"
header-align="center"
align="center"
label="用户操作">
</el-table-column>
<el-table-column
prop="method"
header-align="center"
align="center"
width="150"
:show-overflow-tooltip="true"
label="请求方法">
</el-table-column>
<el-table-column
prop="params"
header-align="center"
align="center"
width="150"
:show-overflow-tooltip="true"
label="请求参数">
</el-table-column>
<el-table-column
prop="time"
header-align="center"
align="center"
label="执行时长(毫秒)">
</el-table-column>
<el-table-column
prop="ip"
header-align="center"
align="center"
width="150"
label="IP地址">
</el-table-column>
<el-table-column
prop="createDate"
header-align="center"
align="center"
width="180"
label="创建时间">
</el-table-column>
</el-table>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
:current-page="pageIndex"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
:total="totalPage"
layout="total, sizes, prev, pager, next, jumper">
</el-pagination>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { getLogList } from '@/api/sys'
import type { LogInfo } from '@/api/sys'
interface DataForm {
key: string
}
const dataForm = reactive<DataForm>({
key: ''
})
const dataList = ref<LogInfo[]>([])
const pageIndex = ref<number>(1)
const pageSize = ref<number>(10)
const totalPage = ref<number>(0)
const dataListLoading = ref<boolean>(false)
function getDataList (): void {
dataListLoading.value = true
getLogList({
page: pageIndex.value,
limit: pageSize.value,
key: dataForm.key
}).then((data) => {
if (data && data.code === 0 && data.data) {
const page = data.data.page || data.data
dataList.value = page.list || []
totalPage.value = page.totalCount || 0
} else {
dataList.value = []
totalPage.value = 0
}
dataListLoading.value = false
})
}
function sizeChangeHandle (val: number): void {
pageSize.value = val
pageIndex.value = 1
getDataList()
}
function currentChangeHandle (val: number): void {
pageIndex.value = val
getDataList()
}
onMounted(() => {
getDataList()
})
</script>

165
src/views/modules/sys/menu.vue Executable file
View File

@@ -0,0 +1,165 @@
<template>
<div class="mod-menu">
<el-form :inline="true" :model="dataForm">
<el-form-item>
<el-button type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item>
</el-form>
<el-table
:data="dataList"
row-key="id"
border
style="width: 100%; ">
<el-table-column
prop="name"
header-align="center"
min-width="150"
label="名称" >
</el-table-column>
<el-table-column
prop="parentName"
header-align="center"
align="center"
width="120"
label="上级菜单">
</el-table-column>
<el-table-column
header-align="center"
align="center"
label="图标">
<template #default="scope">
<el-icon v-if="scope.row.icon && iconMap[scope.row.icon as keyof typeof iconMap]">
<component :is="iconMap[scope.row.icon as keyof typeof iconMap]" />
</el-icon>
<i v-else-if="scope.row.icon" :class="'el-icon-' + scope.row.icon"></i>
</template>
</el-table-column>
<el-table-column
prop="type"
header-align="center"
align="center"
label="类型">
<template #default="scope">
<el-tag v-if="scope.row.type === 0" size="small">目录</el-tag>
<el-tag v-else-if="scope.row.type === 1" size="small" type="success">菜单</el-tag>
<el-tag v-else-if="scope.row.type === 2" size="small" type="info">按钮</el-tag>
</template>
</el-table-column>
<el-table-column
prop="orderNum"
header-align="center"
align="center"
label="排序号">
</el-table-column>
<el-table-column
prop="show"
header-align="center"
align="center"
width="90"
label="是否展示">
<template #default="scope">
<el-tag v-if="scope.row.show == '10'" size="small" type="success">展示</el-tag>
<el-tag v-else size="small" type="info">隐藏</el-tag>
</template>
</el-table-column>
<el-table-column
prop="url"
header-align="center"
align="center"
width="150"
:show-overflow-tooltip="true"
label="菜单URL">
</el-table-column>
<el-table-column
prop="perms"
header-align="center"
align="center"
width="150"
:show-overflow-tooltip="true"
label="授权标识">
</el-table-column>
<el-table-column
fixed="right"
header-align="center"
align="center"
width="150"
label="操作">
<template #default="scope">
<el-button link type="primary" size="small" @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button link type="danger" size="small" @click="deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update v-if="addOrUpdateVisible" ref="addOrUpdateRef" @refreshDataList="getDataList"></add-or-update>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, nextTick, useTemplateRef } from 'vue'
import AddOrUpdate from './components/menu-add-or-update.vue'
import { getMenuList, deleteMenu } from '@/api/sys'
import { treeDataTranslate } from '@/utils'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const iconMap = ElementPlusIconsVue
const dataForm = reactive<Record<string, any>>({})
const dataList = ref<any[]>([])
const dataListLoading = ref<boolean>(false)
const addOrUpdateVisible = ref<boolean>(false)
const addOrUpdateRef = useTemplateRef<InstanceType<typeof AddOrUpdate>>('addOrUpdateRef')
function getDataList (): void {
dataListLoading.value = true
getMenuList().then((data) => {
if (data && data.code === 0 && data.data) {
const d = data.data as Record<string, any>
let list: any[] = []
if (Array.isArray(d.menuList)) list = d.menuList
else if (d.page?.list) list = d.page.list
else if (Array.isArray(d.list)) list = d.list
else {
const bucket = d.page ?? d
list = Array.isArray(bucket) ? bucket : bucket?.list ?? []
}
dataList.value = treeDataTranslate(list, 'id')
}
dataListLoading.value = false
})
}
async function addOrUpdateHandle (id?: number): Promise<void> {
addOrUpdateVisible.value = true
await nextTick()
addOrUpdateRef.value?.init(id)
}
function deleteHandle (id: number): void {
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteMenu(id).then((data) => {
if (data && data.code === 0) {
ElMessage.success({
message: '操作成功',
duration: 1500,
onClose: () => {
getDataList()
}
})
} else {
ElMessage.error(data.msg)
}
})
}).catch(() => {})
}
onMounted(() => {
getDataList()
})
</script>

Some files were not shown because too many files have changed in this diff Show More