init: 橘视厅酒店版会员系统前端
This commit is contained in:
52
src/views/modules/card-allocation/detail.data.ts
Executable file
52
src/views/modules/card-allocation/detail.data.ts
Executable 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
|
||||
}
|
||||
581
src/views/modules/card-allocation/detail.vue
Executable file
581
src/views/modules/card-allocation/detail.vue
Executable 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>
|
||||
581
src/views/modules/card-flow/allocation-order-final-destination-stats.vue
Executable file
581
src/views/modules/card-flow/allocation-order-final-destination-stats.vue
Executable 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>
|
||||
452
src/views/modules/card-flow/card-flow.vue
Executable file
452
src/views/modules/card-flow/card-flow.vue
Executable 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>
|
||||
460
src/views/modules/card/card-import-batches.vue
Executable file
460
src/views/modules/card/card-import-batches.vue
Executable 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>
|
||||
25
src/views/modules/card/card-import.vue
Executable file
25
src/views/modules/card/card-import.vue
Executable 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
302
src/views/modules/card/card.vue
Executable 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>
|
||||
124
src/views/modules/card/components/card-detail.vue
Executable file
124
src/views/modules/card/components/card-detail.vue
Executable 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>
|
||||
48
src/views/modules/card/components/card-import-dialog.vue
Executable file
48
src/views/modules/card/components/card-import-dialog.vue
Executable 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>
|
||||
829
src/views/modules/card/components/card-import-form.vue
Executable file
829
src/views/modules/card/components/card-import-form.vue
Executable 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
74
src/views/modules/card/data.ts
Executable 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
|
||||
}
|
||||
13
src/views/modules/device/components/device-import-dialog.data.ts
Executable file
13
src/views/modules/device/components/device-import-dialog.data.ts
Executable 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
|
||||
}
|
||||
64
src/views/modules/device/components/device-import-dialog.vue
Executable file
64
src/views/modules/device/components/device-import-dialog.vue
Executable 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>
|
||||
64
src/views/modules/device/components/device-import-form.data.ts
Executable file
64
src/views/modules/device/components/device-import-form.data.ts
Executable 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
|
||||
}
|
||||
1503
src/views/modules/device/components/device-import-form.vue
Executable file
1503
src/views/modules/device/components/device-import-form.vue
Executable file
File diff suppressed because it is too large
Load Diff
13
src/views/modules/device/components/device-preset-dialog.data.ts
Executable file
13
src/views/modules/device/components/device-preset-dialog.data.ts
Executable 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
|
||||
}
|
||||
69
src/views/modules/device/components/device-preset-dialog.vue
Executable file
69
src/views/modules/device/components/device-preset-dialog.vue
Executable 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>
|
||||
|
||||
243
src/views/modules/device/components/device-replace-duplicate-dialog.vue
Executable file
243
src/views/modules/device/components/device-replace-duplicate-dialog.vue
Executable 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>
|
||||
18
src/views/modules/device/components/device-select-fullscreen-dialog.data.ts
Executable file
18
src/views/modules/device/components/device-select-fullscreen-dialog.data.ts
Executable 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
|
||||
328
src/views/modules/device/components/device-select-fullscreen-dialog.vue
Executable file
328
src/views/modules/device/components/device-select-fullscreen-dialog.vue
Executable 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>
|
||||
30
src/views/modules/device/components/device-transfer-card-dialog.data.ts
Executable file
30
src/views/modules/device/components/device-transfer-card-dialog.data.ts
Executable 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
|
||||
493
src/views/modules/device/components/device-transfer-card-dialog.vue
Executable file
493
src/views/modules/device/components/device-transfer-card-dialog.vue
Executable 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>
|
||||
7
src/views/modules/device/components/device-update-mac-dialog.data.ts
Executable file
7
src/views/modules/device/components/device-update-mac-dialog.data.ts
Executable file
@@ -0,0 +1,7 @@
|
||||
import type { DeviceListItem } from '@/utils/device-list'
|
||||
|
||||
export interface DeviceUpdateMacForm {
|
||||
mac: string
|
||||
}
|
||||
|
||||
export type DeviceUpdateMacDevice = DeviceListItem | null
|
||||
155
src/views/modules/device/components/device-update-mac-dialog.vue
Executable file
155
src/views/modules/device/components/device-update-mac-dialog.vue
Executable 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>
|
||||
13
src/views/modules/device/device-activation-failed.data.ts
Executable file
13
src/views/modules/device/device-activation-failed.data.ts
Executable 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
|
||||
237
src/views/modules/device/device-activation-failed.vue
Executable file
237
src/views/modules/device/device-activation-failed.vue
Executable 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>
|
||||
33
src/views/modules/device/device-duplicate-mac-sn.data.ts
Executable file
33
src/views/modules/device/device-duplicate-mac-sn.data.ts
Executable 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'
|
||||
353
src/views/modules/device/device-duplicate-mac-sn.vue
Executable file
353
src/views/modules/device/device-duplicate-mac-sn.vue
Executable 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>
|
||||
40
src/views/modules/device/device-expiring-year.data.ts
Executable file
40
src/views/modules/device/device-expiring-year.data.ts
Executable 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
|
||||
263
src/views/modules/device/device-expiring-year.vue
Executable file
263
src/views/modules/device/device-expiring-year.vue
Executable 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>
|
||||
23
src/views/modules/device/device-import.data.ts
Executable file
23
src/views/modules/device/device-import.data.ts
Executable 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
|
||||
}
|
||||
136
src/views/modules/device/device-import.vue
Executable file
136
src/views/modules/device/device-import.vue
Executable 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>
|
||||
14
src/views/modules/device/device-info.data.ts
Executable file
14
src/views/modules/device/device-info.data.ts
Executable 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
|
||||
}
|
||||
224
src/views/modules/device/device-info.vue
Executable file
224
src/views/modules/device/device-info.vue
Executable 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>
|
||||
118
src/views/modules/device/device.data.ts
Executable file
118
src/views/modules/device/device.data.ts
Executable 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
|
||||
1355
src/views/modules/device/device.vue
Executable file
1355
src/views/modules/device/device.vue
Executable file
File diff suppressed because it is too large
Load Diff
300
src/views/modules/jetsen-media/list.vue
Normal file
300
src/views/modules/jetsen-media/list.vue
Normal 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>
|
||||
140
src/views/modules/job/components/schedule-add-or-update.vue
Executable file
140
src/views/modules/job/components/schedule-add-or-update.vue
Executable 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>
|
||||
196
src/views/modules/job/components/schedule-log.vue
Executable file
196
src/views/modules/job/components/schedule-log.vue
Executable 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>
|
||||
336
src/views/modules/job/schedule.vue
Executable file
336
src/views/modules/job/schedule.vue
Executable 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>
|
||||
215
src/views/modules/manage/activation-code.vue
Normal file
215
src/views/modules/manage/activation-code.vue
Normal 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>
|
||||
242
src/views/modules/manage/card-order.vue
Normal file
242
src/views/modules/manage/card-order.vue
Normal 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>
|
||||
312
src/views/modules/manage/card-pool.vue
Normal file
312
src/views/modules/manage/card-pool.vue
Normal 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="每行一个卡密/激活码,例如: XXXX-XXXX-XXXX 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>
|
||||
314
src/views/modules/manage/channel.vue
Normal file
314
src/views/modules/manage/channel.vue
Normal 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>
|
||||
542
src/views/modules/manage/content-package.vue
Normal file
542
src/views/modules/manage/content-package.vue
Normal 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>
|
||||
503
src/views/modules/manage/device.vue
Normal file
503
src/views/modules/manage/device.vue
Normal 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>
|
||||
159
src/views/modules/manage/openapi-call-log.vue
Normal file
159
src/views/modules/manage/openapi-call-log.vue
Normal 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>
|
||||
385
src/views/modules/manage/partner-card.vue
Normal file
385
src/views/modules/manage/partner-card.vue
Normal 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>
|
||||
133
src/views/modules/manage/partner-device.vue
Normal file
133
src/views/modules/manage/partner-device.vue
Normal 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>
|
||||
283
src/views/modules/manage/partner.vue
Normal file
283
src/views/modules/manage/partner.vue
Normal 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>
|
||||
288
src/views/modules/manage/pay-config.vue
Normal file
288
src/views/modules/manage/pay-config.vue
Normal 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>
|
||||
391
src/views/modules/manage/price.vue
Normal file
391
src/views/modules/manage/price.vue
Normal 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>
|
||||
246
src/views/modules/manage/profit-receiver.vue
Normal file
246
src/views/modules/manage/profit-receiver.vue
Normal 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>
|
||||
407
src/views/modules/manage/profit-rule.vue
Normal file
407
src/views/modules/manage/profit-rule.vue
Normal 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>
|
||||
305
src/views/modules/manage/profit-sharing.vue
Normal file
305
src/views/modules/manage/profit-sharing.vue
Normal 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>
|
||||
306
src/views/modules/manage/refund.vue
Normal file
306
src/views/modules/manage/refund.vue
Normal 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>
|
||||
479
src/views/modules/manage/stats-overview.vue
Normal file
479
src/views/modules/manage/stats-overview.vue
Normal 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>
|
||||
260
src/views/modules/manage/store.vue
Normal file
260
src/views/modules/manage/store.vue
Normal 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>
|
||||
283
src/views/modules/media/components/media-asset-add-or-update.vue
Executable file
283
src/views/modules/media/components/media-asset-add-or-update.vue
Executable 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>
|
||||
337
src/views/modules/media/components/media-asset-detail-dialog.vue
Executable file
337
src/views/modules/media/components/media-asset-detail-dialog.vue
Executable 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>
|
||||
62
src/views/modules/media/components/media-rank-dialog.vue
Executable file
62
src/views/modules/media/components/media-rank-dialog.vue
Executable 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>
|
||||
441
src/views/modules/media/components/rank-board-panel.vue
Executable file
441
src/views/modules/media/components/rank-board-panel.vue
Executable 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>
|
||||
201
src/views/modules/media/media-asset.vue
Executable file
201
src/views/modules/media/media-asset.vue
Executable 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>
|
||||
17
src/views/modules/media/rank-board.vue
Executable file
17
src/views/modules/media/rank-board.vue
Executable 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>
|
||||
120
src/views/modules/org/components/org-add-or-update.data.ts
Executable file
120
src/views/modules/org/components/org-add-or-update.data.ts
Executable 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
|
||||
}
|
||||
1019
src/views/modules/org/components/org-add-or-update.vue
Executable file
1019
src/views/modules/org/components/org-add-or-update.vue
Executable file
File diff suppressed because it is too large
Load Diff
31
src/views/modules/org/components/org-allocate-card-dialog.data.ts
Executable file
31
src/views/modules/org/components/org-allocate-card-dialog.data.ts
Executable 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
|
||||
}
|
||||
455
src/views/modules/org/components/org-allocate-card-dialog.vue
Executable file
455
src/views/modules/org/components/org-allocate-card-dialog.vue
Executable 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>
|
||||
118
src/views/modules/org/components/org-version-dialog.data.ts
Executable file
118
src/views/modules/org/components/org-version-dialog.data.ts
Executable 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
|
||||
}
|
||||
726
src/views/modules/org/components/org-version-dialog.vue
Executable file
726
src/views/modules/org/components/org-version-dialog.vue
Executable 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>
|
||||
177
src/views/modules/org/components/shop-allocate-dialog.vue
Executable file
177
src/views/modules/org/components/shop-allocate-dialog.vue
Executable 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>
|
||||
15
src/views/modules/org/components/shop-card-stats.data.ts
Executable file
15
src/views/modules/org/components/shop-card-stats.data.ts
Executable 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
|
||||
}
|
||||
426
src/views/modules/org/components/shop-card-stats.vue
Executable file
426
src/views/modules/org/components/shop-card-stats.vue
Executable 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>
|
||||
25
src/views/modules/org/components/shop-cards-dialog.data.ts
Executable file
25
src/views/modules/org/components/shop-cards-dialog.data.ts
Executable 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
|
||||
}
|
||||
}
|
||||
200
src/views/modules/org/components/shop-cards-dialog.vue
Executable file
200
src/views/modules/org/components/shop-cards-dialog.vue
Executable 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>
|
||||
8
src/views/modules/org/components/shop-channel-tree.data.ts
Executable file
8
src/views/modules/org/components/shop-channel-tree.data.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
/** 渠道代理商树节点 */
|
||||
export interface ChannelAgentNode {
|
||||
id: string | number
|
||||
name: string
|
||||
type: string
|
||||
disabled?: boolean
|
||||
children?: ChannelAgentNode[]
|
||||
}
|
||||
142
src/views/modules/org/components/shop-channel-tree.vue
Executable file
142
src/views/modules/org/components/shop-channel-tree.vue
Executable 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>
|
||||
19
src/views/modules/org/components/shop-max-device-dialog.data.ts
Executable file
19
src/views/modules/org/components/shop-max-device-dialog.data.ts
Executable 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
|
||||
}
|
||||
233
src/views/modules/org/components/shop-max-device-dialog.vue
Executable file
233
src/views/modules/org/components/shop-max-device-dialog.vue
Executable 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>
|
||||
16
src/views/modules/org/components/shop-regenerate-code-dialog.data.ts
Executable file
16
src/views/modules/org/components/shop-regenerate-code-dialog.data.ts
Executable 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
|
||||
}
|
||||
103
src/views/modules/org/components/shop-regenerate-code-dialog.vue
Executable file
103
src/views/modules/org/components/shop-regenerate-code-dialog.vue
Executable 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>
|
||||
49
src/views/modules/org/components/shop-store-list.data.ts
Executable file
49
src/views/modules/org/components/shop-store-list.data.ts
Executable 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
|
||||
}
|
||||
408
src/views/modules/org/components/shop-store-list.vue
Executable file
408
src/views/modules/org/components/shop-store-list.vue
Executable 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>
|
||||
45
src/views/modules/org/org.data.ts
Executable file
45
src/views/modules/org/org.data.ts
Executable 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
457
src/views/modules/org/org.vue
Executable 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>
|
||||
106
src/views/modules/org/shop.data.ts
Executable file
106
src/views/modules/org/shop.data.ts
Executable 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
666
src/views/modules/org/shop.vue
Executable 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>
|
||||
69
src/views/modules/org/utils/extract-channel-agent-tree.ts
Executable file
69
src/views/modules/org/utils/extract-channel-agent-tree.ts
Executable 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
|
||||
}
|
||||
219
src/views/modules/sys/api-jst-call-log.vue
Executable file
219
src/views/modules/sys/api-jst-call-log.vue
Executable 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>
|
||||
211
src/views/modules/sys/audit-log.vue
Executable file
211
src/views/modules/sys/audit-log.vue
Executable 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>
|
||||
108
src/views/modules/sys/components/config-add-or-update.vue
Executable file
108
src/views/modules/sys/components/config-add-or-update.vue
Executable 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>
|
||||
315
src/views/modules/sys/components/menu-add-or-update.vue
Executable file
315
src/views/modules/sys/components/menu-add-or-update.vue
Executable 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>
|
||||
158
src/views/modules/sys/components/role-add-or-update.vue
Executable file
158
src/views/modules/sys/components/role-add-or-update.vue
Executable 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>
|
||||
332
src/views/modules/sys/components/user-add-or-update.vue
Executable file
332
src/views/modules/sys/components/user-add-or-update.vue
Executable 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
168
src/views/modules/sys/config.vue
Executable 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
138
src/views/modules/sys/log.vue
Executable 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
165
src/views/modules/sys/menu.vue
Executable 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>
|
||||
194
src/views/modules/sys/role.vue
Executable file
194
src/views/modules/sys/role.vue
Executable file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="mod-role">
|
||||
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
|
||||
<el-form-item>
|
||||
<el-input v-model="dataForm.roleName" 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-tooltip content="选中角色中存在子集时不允许删除" :disabled="!(dataListSelections.length > 0 && dataListSelections.some(item => idsWithChildren.has(item.id)))">
|
||||
<span class="pl-3">
|
||||
<el-button type="danger" @click="deleteHandle()" :disabled="dataListSelections.length <= 0 || dataListSelections.some(item => idsWithChildren.has(item.id))">批量删除</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</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="roleName"
|
||||
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
|
||||
prop="createdAt"
|
||||
header-align="center"
|
||||
align="center"
|
||||
width="180"
|
||||
label="创建时间">
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="updatedAt"
|
||||
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="addOrUpdateHandle(scope.row.id)">修改</el-button>
|
||||
<el-tooltip content="存在子集时不允许删除" :disabled="!idsWithChildren.has(scope.row.id)">
|
||||
<span>
|
||||
<el-button 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>
|
||||
<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, onMounted, nextTick, useTemplateRef } from 'vue'
|
||||
import AddOrUpdate from './components/role-add-or-update.vue'
|
||||
import { getRoleList, deleteRole } from '@/api/sys'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { RoleInfo } from '@/api/sys'
|
||||
|
||||
interface DataForm {
|
||||
roleName: string
|
||||
}
|
||||
|
||||
const dataForm = reactive<DataForm>({
|
||||
roleName: ''
|
||||
})
|
||||
|
||||
const dataList = ref<RoleInfo[]>([])
|
||||
const pageIndex = ref<number>(1)
|
||||
const pageSize = ref<number>(10)
|
||||
const totalPage = ref<number>(0)
|
||||
const dataListLoading = ref<boolean>(false)
|
||||
const dataListSelections = ref<RoleInfo[]>([])
|
||||
const addOrUpdateVisible = ref<boolean>(false)
|
||||
const addOrUpdateRef = useTemplateRef<InstanceType<typeof AddOrUpdate>>('addOrUpdateRef')
|
||||
const idsWithChildren = ref<Set<number | string>>(new Set())
|
||||
|
||||
function getDataList (): void {
|
||||
dataListLoading.value = true
|
||||
getRoleList({
|
||||
page: pageIndex.value,
|
||||
limit: pageSize.value,
|
||||
roleName: dataForm.roleName
|
||||
}).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
|
||||
const list = dataList.value || []
|
||||
const parentIds = list.map(r => r.parentId).filter(p => p != null && p !== '')
|
||||
idsWithChildren.value = new Set(parentIds)
|
||||
} else {
|
||||
dataList.value = []
|
||||
totalPage.value = 0
|
||||
idsWithChildren.value = new Set()
|
||||
}
|
||||
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: RoleInfo[]): 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)
|
||||
const withChildren = ids.filter(id => idsWithChildren.value.has(id))
|
||||
if (withChildren.length > 0) {
|
||||
ElMessage.warning('以下角色存在子集,不允许删除:' + withChildren.join('、'))
|
||||
return
|
||||
}
|
||||
ElMessageBox.confirm(`确定对[id=${ids.join(',')}]进行[${id ? '删除' : '批量删除'}]操作?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
deleteRole(ids as number[]).then((data) => {
|
||||
if (data && data.code === 0) {
|
||||
ElMessage.success({
|
||||
message: '操作成功',
|
||||
duration: 1500,
|
||||
onClose: () => {
|
||||
getDataList()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
ElMessage.error(data.msg)
|
||||
}
|
||||
})
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getDataList()
|
||||
})
|
||||
</script>
|
||||
185
src/views/modules/sys/user.vue
Executable file
185
src/views/modules/sys/user.vue
Executable file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="mod-user">
|
||||
<el-form :inline="true" :model="dataForm" @keyup.enter="getDataList()">
|
||||
<el-form-item>
|
||||
<el-input v-model="dataForm.userName" 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
|
||||
prop="index"
|
||||
header-align="center"
|
||||
align="center"
|
||||
width="80"
|
||||
label="序号">
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="username"
|
||||
header-align="center"
|
||||
align="center"
|
||||
label="用户名">
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="orgName"
|
||||
header-align="center"
|
||||
align="center"
|
||||
label="所属组织">
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="mobile"
|
||||
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" type="danger">禁用</el-tag>
|
||||
<el-tag v-else size="small">启用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
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.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>
|
||||
<!-- 弹窗, 新增 / 修改 -->
|
||||
<AddOrUpdate
|
||||
v-show="addOrUpdateVisible"
|
||||
ref="addOrUpdateRef"
|
||||
@refreshDataList="getDataList"></AddOrUpdate>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, nextTick, useTemplateRef } from 'vue'
|
||||
import AddOrUpdate from './components/user-add-or-update.vue'
|
||||
import { getUserList, deleteUser } from '@/api/sys'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { UserInfo } from '@/api/sys'
|
||||
|
||||
interface DataForm {
|
||||
userName: string
|
||||
}
|
||||
|
||||
const dataForm = reactive<DataForm>({
|
||||
userName: ''
|
||||
})
|
||||
|
||||
const dataList = ref<UserInfo[]>([])
|
||||
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 addOrUpdateRef = useTemplateRef<InstanceType<typeof AddOrUpdate>>('addOrUpdateRef')
|
||||
|
||||
function getDataList (): void {
|
||||
dataListLoading.value = true
|
||||
getUserList({
|
||||
page: pageIndex.value,
|
||||
limit: pageSize.value,
|
||||
username: dataForm.userName
|
||||
}).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()
|
||||
}
|
||||
|
||||
async function addOrUpdateHandle (id: number): Promise<void> {
|
||||
addOrUpdateVisible.value = true
|
||||
await nextTick()
|
||||
addOrUpdateRef.value?.init(id)
|
||||
}
|
||||
|
||||
async function addHandle (): Promise<void> {
|
||||
addOrUpdateVisible.value = true
|
||||
await nextTick()
|
||||
addOrUpdateRef.value?.init(null)
|
||||
}
|
||||
|
||||
function deleteHandle (id: number): void {
|
||||
ElMessageBox.confirm(`确定对[id=${id}]进行[删除]操作?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
deleteUser([id]).then((data) => {
|
||||
if (data && data.code === 0) {
|
||||
ElMessage.success({
|
||||
message: '操作成功',
|
||||
duration: 1500,
|
||||
onClose: () => {
|
||||
getDataList()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
ElMessage.error(data.msg)
|
||||
}
|
||||
})
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getDataList()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user