初始化2

This commit is contained in:
2026-04-08 17:10:58 +08:00
commit 4986d90eb9
532 changed files with 112617 additions and 0 deletions

View File

@@ -0,0 +1,336 @@
<template>
<div class="space-y-4">
<a-page-header title="租户管理" sub-title="租户创建查询与维护" :ghost="false" class="page-header">
<template #extra>
<a-space>
<a-input
v-model:value="keywords"
allow-clear
placeholder="搜索租户名称/租户ID"
class="w-64"
@press-enter="doSearch"
/>
<a-button :loading="loading" @click="reload">刷新</a-button>
<a-button type="primary" @click="openCreate">新增租户</a-button>
</a-space>
</template>
</a-page-header>
<a-card :bordered="false" class="card">
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
<a-table
:data-source="list"
:loading="loading"
:pagination="false"
size="middle"
:row-key="(r: any) => r.tenantId ?? r.appId ?? r.tenantName"
>
<a-table-column title="租户ID" data-index="tenantId" width="90" />
<a-table-column title="租户名称" key="tenantName" width="220">
<template #default="{ record }">
<div class="flex items-center gap-2 min-w-0">
<a-avatar :src="record.logo" :size="22" shape="square">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="truncate">{{ record.tenantName || '-' }}</span>
</div>
</template>
</a-table-column>
<a-table-column title="状态" key="status" width="120">
<template #default="{ record }">
<a-tag v-if="record.status === 0 || record.status === undefined" color="green">正常</a-tag>
<a-tag v-else color="default">禁用</a-tag>
</template>
</a-table-column>
<a-table-column title="创建时间" data-index="createTime" width="180" />
<a-table-column title="操作" key="actions" width="260" fixed="right">
<template #default="{ record }">
<a-space>
<a-button size="small" @click="openEdit(record)">编辑</a-button>
<a-button size="small" @click="openReset(record)" :disabled="!record.tenantId">重置密码</a-button>
<a-popconfirm
title="确定删除该租户"
ok-text="删除"
cancel-text="取消"
@confirm="remove(record)"
>
<a-button size="small" danger :loading="busyTenantId === record.tenantId" :disabled="!record.tenantId">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table-column>
</a-table>
<div class="mt-4 flex items-center justify-end">
<a-pagination
:current="page"
:page-size="limit"
:total="total"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
@change="onPageChange"
@show-size-change="onPageSizeChange"
/>
</div>
</a-card>
<a-modal
v-model:open="editOpen"
:title="editTitle"
ok-text="保存"
cancel-text="取消"
:confirm-loading="saving"
@ok="submitEdit"
>
<a-form ref="editFormRef" layout="vertical" :model="editForm" :rules="editRules">
<a-form-item v-if="editForm.tenantId" label="租户ID">
<a-input :value="String(editForm.tenantId ?? '')" disabled />
</a-form-item>
<a-form-item label="租户名称" name="tenantName">
<a-input v-model:value="editForm.tenantName" placeholder="例如某某科技有限公司" />
</a-form-item>
<a-form-item label="企业名称" name="companyName">
<a-input v-model:value="editForm.companyName" placeholder="例如某某科技有限公司" />
</a-form-item>
<a-form-item label="Logo" name="logo">
<a-input v-model:value="editForm.logo" placeholder="https://..." />
</a-form-item>
<a-form-item label="应用秘钥" name="appSecret">
<a-input-password v-model:value="editForm.appSecret" placeholder="appSecret可选" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-select
v-model:value="editForm.status"
placeholder="请选择"
:options="[
{ label: '正常', value: 0 },
{ label: '禁用', value: 1 }
]"
/>
</a-form-item>
<a-form-item label="备注" name="description">
<a-textarea v-model:value="editForm.description" :rows="3" placeholder="备注(可选)" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="resetOpen"
title="重置租户密码"
ok-text="确认重置"
cancel-text="取消"
:confirm-loading="resetting"
@ok="submitReset"
>
<a-form layout="vertical">
<a-form-item label="租户">
<a-input :value="selectedTenant?.tenantName || ''" disabled />
</a-form-item>
<a-form-item label="新密码">
<a-input-password v-model:value="resetPassword" placeholder="请输入新密码" />
</a-form-item>
</a-form>
<a-alert class="mt-2" type="warning" show-icon message="重置后请尽快通知租户管理员修改密码。" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { message, type FormInstance } from 'ant-design-vue'
import { UserOutlined } from '@ant-design/icons-vue'
import { addTenant, pageTenant, removeTenant, updateTenant, updateTenantPassword } from '@/api/system/tenant'
import type { Tenant } from '@/api/system/tenant/model'
import { TEMPLATE_ID } from '@/config/setting'
definePageMeta({ layout: 'console' })
const loading = ref(false)
const error = ref<string>('')
const list = ref<Tenant[]>([])
const total = ref(0)
const page = ref(1)
const limit = ref(10)
const keywords = ref('')
const tenantCode = ref('')
const adminHeaders = { TenantId: TEMPLATE_ID }
async function loadTenants() {
loading.value = true
error.value = ''
try {
const res = await pageTenant({
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined,
tenantCode: tenantCode.value || undefined
}, { headers: adminHeaders })
list.value = res?.list ?? []
total.value = res?.count ?? 0
} catch (e) {
error.value = e instanceof Error ? e.message : '租户列表加载失败'
} finally {
loading.value = false
}
}
async function reload() {
await loadTenants()
}
function doSearch() {
page.value = 1
loadTenants()
}
function onPageChange(nextPage: number) {
page.value = nextPage
loadTenants()
}
function onPageSizeChange(_current: number, nextSize: number) {
limit.value = nextSize
page.value = 1
loadTenants()
}
onMounted(() => {
loadTenants()
})
const editOpen = ref(false)
const saving = ref(false)
const editFormRef = ref<FormInstance>()
const editForm = reactive<Tenant>({
tenantId: undefined,
tenantName: '',
companyName: '',
appId: '',
appSecret: '',
logo: '',
description: '',
status: 0
})
const editTitle = computed(() => (editForm.tenantId ? '编辑租户' : '新增租户'))
const editRules = reactive({
tenantName: [{ required: true, type: 'string', message: '请输入租户名称' }]
})
function openCreate() {
editForm.tenantId = undefined
editForm.tenantName = ''
editForm.companyName = ''
editForm.appId = ''
editForm.appSecret = ''
editForm.logo = ''
editForm.description = ''
editForm.status = 0
editOpen.value = true
}
function openEdit(row: Tenant) {
editForm.tenantId = row.tenantId
editForm.tenantName = row.tenantName ?? ''
editForm.companyName = row.companyName ?? ''
editForm.appId = row.appId ?? ''
editForm.appSecret = row.appSecret ?? ''
editForm.logo = row.logo ?? ''
editForm.description = row.description ?? ''
editForm.status = row.status ?? 0
editOpen.value = true
}
async function submitEdit() {
try {
await editFormRef.value?.validate()
} catch {
return
}
saving.value = true
try {
const payload: Tenant = {
...editForm,
tenantName: editForm.tenantName?.trim(),
companyName: editForm.companyName?.trim() || undefined,
appId: editForm.appId?.trim(),
appSecret: editForm.appSecret?.trim() || undefined,
logo: editForm.logo?.trim() || undefined,
description: editForm.description?.trim() || undefined
}
if (payload.tenantId) {
await updateTenant(payload, { headers: adminHeaders })
message.success('租户已更新')
} else {
await addTenant(payload, { headers: adminHeaders })
message.success('租户已创建')
}
editOpen.value = false
await loadTenants()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
const busyTenantId = ref<number | null>(null)
async function remove(row: Tenant) {
if (!row.tenantId) return
busyTenantId.value = row.tenantId
try {
await removeTenant(row.tenantId, { headers: adminHeaders })
message.success('已删除')
if (list.value.length <= 1 && page.value > 1) page.value -= 1
await loadTenants()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
} finally {
busyTenantId.value = null
}
}
const resetOpen = ref(false)
const resetting = ref(false)
const resetPassword = ref('')
const selectedTenant = ref<Tenant | null>(null)
function openReset(row: Tenant) {
selectedTenant.value = row
resetPassword.value = ''
resetOpen.value = true
}
async function submitReset() {
if (!selectedTenant.value?.tenantId) return
const pwd = resetPassword.value.trim()
if (!pwd) {
message.error('请输入新密码')
return
}
resetting.value = true
try {
await updateTenantPassword(selectedTenant.value.tenantId, pwd, { headers: adminHeaders })
message.success('密码已重置')
resetOpen.value = false
} catch (e) {
message.error(e instanceof Error ? e.message : '重置失败')
} finally {
resetting.value = false
}
}
</script>
<style scoped>
.page-header {
border-radius: 12px;
}
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,336 @@
<template>
<div class="space-y-4">
<a-page-header title="管理中心" sub-title="产品开通使用与续费" :ghost="false" class="page-header">
<template #extra>
<a-segmented
:value="active"
:options="[
{ label: '已开通', value: 'index' },
{ label: '未开通', value: 'unopened' }
]"
@update:value="onSwitch"
/>
</template>
</a-page-header>
<!-- 产品分类 -->
<a-card :bordered="false" class="card">
<a-spin :spinning="loading">
<a-tabs v-model:activeKey="activeCategory" @change="loadProducts">
<a-tab-pane key="all" tab="全部产品" />
<a-tab-pane key="website" tab="企业官网" />
<a-tab-pane key="shop" tab="电商系统" />
<a-tab-pane key="mp" tab="小程序/公众号" />
<a-tab-pane key="plugin" tab="插件工具" />
</a-tabs>
<div v-if="!loading && filteredProducts.length === 0" class="empty-state">
<a-empty description="暂无可用产品">
<template #image>
<div class="empty-icon">🛒</div>
</template>
<a-button type="primary" @click="navigateTo('/market')">
前往应用商店
</a-button>
</a-empty>
</div>
<div v-else class="product-grid">
<div
v-for="product in filteredProducts"
:key="product.productId"
class="product-card"
>
<div class="product-icon-wrap">
<img v-if="product.icon" :src="product.icon" class="product-icon-img" />
<div v-else class="product-icon-placeholder" :style="{ background: iconBgColor(product.productName) }">
{{ (product.productName || 'P').charAt(0) }}
</div>
</div>
<div class="product-info">
<div class="product-name">{{ product.productName }}</div>
<div class="product-desc">{{ product.description || '暂无描述' }}</div>
<div class="product-tags">
<a-tag v-if="product.priceType === 'free'" color="green" size="small">免费</a-tag>
<a-tag v-else color="orange" size="small">
¥{{ (product.price || 0) / 100 }}
</a-tag>
<a-tag v-if="product.appType" size="small">
{{ typeName(product.appType) }}
</a-tag>
</div>
</div>
<div class="product-actions">
<a-button type="primary" size="small" @click="handleOpen(product)">
立即开通
</a-button>
<a-button size="small" @click="handlePreview(product)">
了解详情
</a-button>
</div>
</div>
</div>
</a-spin>
</a-card>
<!-- 开通引导 -->
<a-card :bordered="false" class="card guide-card">
<div class="guide-content">
<div class="guide-info">
<h3 class="guide-title">💡 需要定制化方案</h3>
<p class="guide-desc">如果以上产品无法满足您的需求可以联系我们的技术团队获取专属定制方案</p>
</div>
<div class="guide-actions">
<a-button type="primary" @click="navigateTo('/console/tickets')">
提交工单
</a-button>
<a-button @click="navigateTo('/market')">
浏览应用商店
</a-button>
</div>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { pageAppProductAll } from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
definePageMeta({ layout: 'console' })
const route = useRoute()
const active = computed(() => (route.path.includes('/console/tenant/unopened') ? 'unopened' : ''))
function onSwitch(value: string | number) {
navigateTo(`/console/tenant/${String(value)}`)
}
// ─── 数据 ──────────────────────────────────────────────────────
const loading = ref(false)
const products = ref<AppProduct[]>([])
const activeCategory = ref('all')
const TYPE_NAME: Record<string, string> = {
web: 'Web 应用',
miniprogram: '小程序',
mobile: '移动 App',
api: 'API 服务',
plugin: '插件',
}
const TYPE_CATEGORY_MAP: Record<string, string> = {
web: 'website',
miniprogram: 'mp',
plugin: 'plugin',
}
function typeName(type?: string) {
return TYPE_NAME[type || ''] || type || '应用'
}
function getCategoryForProduct(product: AppProduct): string {
if (product.appType) {
return TYPE_CATEGORY_MAP[product.appType] || 'all'
}
// 根据 keywords 或 description 推断分类
const name = (product.productName || '').toLowerCase()
const desc = (product.description || '').toLowerCase()
if (name.includes('官网') || name.includes('企业') || desc.includes('官网') || desc.includes('企业站')) return 'website'
if (name.includes('电商') || name.includes('商城') || name.includes('shop') || desc.includes('电商')) return 'shop'
if (name.includes('小程序') || name.includes('公众号') || name.includes('mp') || desc.includes('小程序')) return 'mp'
if (name.includes('插件') || name.includes('plugin') || desc.includes('插件')) return 'plugin'
return 'all'
}
const filteredProducts = computed(() => {
if (activeCategory.value === 'all') return products.value
return products.value.filter(p => getCategoryForProduct(p) === activeCategory.value)
})
const PALETTE = ['#4e6ef2', '#f4a261', '#e76f51', '#2a9d8f', '#e9c46a', '#264653']
function iconBgColor(name?: string) {
if (!name) return PALETTE[0]
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffffffff
return PALETTE[Math.abs(h) % PALETTE.length]
}
async function loadProducts() {
loading.value = true
try {
const res = await pageAppProductAll({
current: 1,
size: 50,
status: 1, // 正常状态
})
products.value = res?.list || []
} catch (e) {
console.error('加载产品列表失败', e)
products.value = []
} finally {
loading.value = false
}
}
function handleOpen(product: AppProduct) {
navigateTo(`/market?app=${product.productId}`)
}
function handlePreview(product: AppProduct) {
navigateTo(`/market?app=${product.productId}`)
}
onMounted(() => {
loadProducts()
})
</script>
<style scoped>
.page-header {
border-radius: 12px;
}
.card {
border-radius: 12px;
}
/* 产品网格 */
.product-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.product-card {
display: flex;
align-items: center;
gap: 16px;
padding: 18px 20px;
border-radius: 12px;
border: 1px solid #f0f0f0;
background: #fff;
transition: all 0.2s;
}
.product-card:hover {
border-color: #d6e4ff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
.product-icon-wrap {
flex-shrink: 0;
}
.product-icon-img {
width: 52px;
height: 52px;
border-radius: 12px;
object-fit: cover;
}
.product-icon-placeholder {
width: 52px;
height: 52px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
color: #fff;
}
.product-info {
flex: 1;
min-width: 0;
}
.product-name {
font-size: 15px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
}
.product-desc {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.product-tags {
display: flex;
gap: 6px;
}
.product-actions {
flex-shrink: 0;
display: flex;
gap: 8px;
}
/* 空状态 */
.empty-state {
padding: 60px 20px;
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
/* 引导卡片 */
.guide-card {
background: linear-gradient(135deg, #f5f3ff, #ede9fe);
border-color: #ede9fe;
}
.guide-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.guide-info { flex: 1; min-width: 200px; }
.guide-title {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin: 0 0 6px;
}
.guide-desc {
font-size: 13px;
color: rgba(0, 0, 0, 0.5);
margin: 0;
line-height: 1.6;
}
.guide-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
@media (max-width: 640px) {
.product-card {
flex-direction: column;
text-align: center;
}
.product-actions {
width: 100%;
justify-content: center;
}
}
</style>