feat(core): 初始化项目基础架构和CMS功能模块

- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore)
- 实现服务端API代理功能,支持文件、模块和服务器API转发
- 创建文章详情页、栏目文章列表页和单页内容展示页面
- 集成Ant Design Vue组件库并实现SSR样式提取功能
- 定义API响应数据结构类型和应用布局组件
- 开发开发者应用中心和文章管理页面
- 实现CMS导航菜单获取和多租户切换功能
This commit is contained in:
2026-01-27 00:14:08 +08:00
commit 775841eed3
315 changed files with 47072 additions and 0 deletions

View File

@@ -0,0 +1,347 @@
<template>
<div class="space-y-4">
<a-page-header title="账号信息" sub-title="基本资料与企业信息" />
<a-spin :spinning="loading" tip="加载中...">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-card :bordered="false" class="card" title="基本资料">
<div class="flex items-center gap-4">
<a-avatar :size="56" :src="avatarUrl">
<template v-if="!avatarUrl" #icon>
<UserOutlined />
</template>
</a-avatar>
<div class="min-w-0">
<div class="text-base font-semibold text-gray-900">
{{ user?.nickname || user?.username || '未命名用户' }}
</div>
<div class="text-gray-500">
{{ user?.phone || (user as any)?.mobile || user?.email || '' }}
</div>
</div>
</div>
<a-divider />
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="用户ID">{{ user?.userId ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="账号">{{ user?.username ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="昵称">{{ user?.nickname ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="手机号">{{ user?.phone || (user as any)?.mobile || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ user?.email ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="租户ID">{{ user?.tenantId ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="租户名称">{{ user?.tenantName ?? '-' }}</a-descriptions-item>
</a-descriptions>
<div class="mt-6 flex justify-end gap-2">
<a-button @click="reload" :loading="loading">刷新</a-button>
<a-button type="primary" :disabled="!user" @click="openEditUser">编辑</a-button>
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" class="card" title="企业信息">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="企业ID">{{ company?.companyId ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="企业简称">{{ company?.shortName ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="企业全称">{{ company?.companyName ?? company?.tenantName ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="绑定域名">{{ company?.domain ?? company?.freeDomain ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ company?.phone ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ company?.email ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="地址">
{{ companyAddress || '-' }}
</a-descriptions-item>
<a-descriptions-item label="实名认证">
<a-tag v-if="company?.authentication" color="green">已认证</a-tag>
<a-tag v-else color="default">未认证</a-tag>
</a-descriptions-item>
</a-descriptions>
<div class="mt-6 flex justify-end gap-2">
<a-button @click="reload" :loading="loading">刷新</a-button>
<a-button type="primary" :disabled="!company" @click="openEditCompany">编辑</a-button>
</div>
</a-card>
</a-col>
</a-row>
</a-spin>
<a-modal
v-model:open="editUserOpen"
title="编辑基本资料"
:confirm-loading="savingUser"
ok-text="保存"
cancel-text="取消"
@ok="submitUser"
>
<a-form ref="userFormRef" layout="vertical" :model="userForm" :rules="userRules">
<a-form-item label="昵称" name="nickname">
<a-input v-model:value="userForm.nickname" placeholder="请输入昵称" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="userForm.email" placeholder="例如name@example.com" />
</a-form-item>
<a-form-item label="头像 URL" name="avatarUrl">
<a-input v-model:value="userForm.avatarUrl" placeholder="https://..." />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="editCompanyOpen"
title="编辑企业信息"
:confirm-loading="savingCompany"
ok-text="保存"
cancel-text="取消"
@ok="submitCompany"
>
<a-form ref="companyFormRef" layout="vertical" :model="companyForm" :rules="companyRules">
<a-form-item label="企业简称" name="shortName">
<a-input v-model:value="companyForm.shortName" placeholder="例如:某某科技" />
</a-form-item>
<a-form-item label="企业全称" name="companyName">
<a-input v-model:value="companyForm.companyName" placeholder="例如:某某科技有限公司" />
</a-form-item>
<a-form-item label="绑定域名" name="domain">
<a-input v-model:value="companyForm.domain" placeholder="例如example.com" />
</a-form-item>
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="companyForm.phone" placeholder="请输入联系电话" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="companyForm.email" placeholder="例如service@example.com" />
</a-form-item>
<a-form-item label="地址" name="address">
<a-textarea v-model:value="companyForm.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
<a-form-item label="发票抬头" name="invoiceHeader">
<a-input v-model:value="companyForm.invoiceHeader" placeholder="用于开票" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import { UserOutlined } from '@ant-design/icons-vue'
import { getTenantInfo, getUserInfo, updateLoginUser } from '@/api/layout'
import { updateCompany } from '@/api/system/company'
import type { Company } from '@/api/system/company/model'
import type { User } from '@/api/system/user/model'
definePageMeta({ layout: 'console' })
const loading = ref(false)
const savingUser = ref(false)
const savingCompany = ref(false)
const user = ref<User | null>(null)
const company = ref<Company | null>(null)
const avatarUrl = computed(() => {
const candidate =
user.value?.avatarUrl ||
user.value?.avatar ||
user.value?.merchantAvatar ||
user.value?.logo ||
''
if (typeof candidate !== 'string') return ''
const normalized = candidate.trim()
if (!normalized || normalized === 'null' || normalized === 'undefined') return ''
return normalized
})
const companyAddress = computed(() => {
const parts = [company.value?.province, company.value?.city, company.value?.region, company.value?.address]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.filter(Boolean)
return parts.join(' ')
})
async function load() {
loading.value = true
try {
const [uRes, cRes] = await Promise.allSettled([getUserInfo(), getTenantInfo()])
if (uRes.status === 'fulfilled') {
user.value = uRes.value
} else {
console.error(uRes.reason)
message.error(uRes.reason instanceof Error ? uRes.reason.message : '获取用户信息失败')
}
if (cRes.status === 'fulfilled') {
company.value = cRes.value
} else {
console.error(cRes.reason)
message.error(cRes.reason instanceof Error ? cRes.reason.message : '获取企业信息失败')
}
} finally {
loading.value = false
}
}
async function reload() {
await load()
}
const editUserOpen = ref(false)
const userFormRef = ref<FormInstance>()
const userForm = reactive<{ nickname?: string; email?: string; avatarUrl?: string }>({
nickname: '',
email: '',
avatarUrl: ''
})
const userRules = reactive({
nickname: [{ required: true, message: '请输入昵称', type: 'string' }],
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }]
})
function openEditUser() {
if (!user.value) return
userForm.nickname = user.value.nickname ?? ''
userForm.email = user.value.email ?? ''
userForm.avatarUrl = user.value.avatarUrl ?? avatarUrl.value ?? ''
editUserOpen.value = true
}
async function submitUser() {
if (!user.value) return
try {
await userFormRef.value?.validate()
} catch {
return
}
const nickname = (userForm.nickname ?? '').trim()
if (!nickname) {
message.error('请输入昵称')
return
}
const email = (userForm.email ?? '').trim()
const avatar = (userForm.avatarUrl ?? '').trim()
savingUser.value = true
try {
await updateLoginUser({
userId: user.value.userId,
nickname,
email: email || undefined,
avatarUrl: avatar || undefined
} as User)
message.success('保存成功')
editUserOpen.value = false
await load()
} catch (e: unknown) {
console.error(e)
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingUser.value = false
}
}
const editCompanyOpen = ref(false)
const companyFormRef = ref<FormInstance>()
const companyForm = reactive<{
companyId?: number
shortName?: string
companyName?: string
domain?: string
phone?: string
email?: string
address?: string
invoiceHeader?: string
}>({
companyId: undefined,
shortName: '',
companyName: '',
domain: '',
phone: '',
email: '',
address: '',
invoiceHeader: ''
})
const companyRules = reactive({
companyName: [{ required: true, message: '请输入企业全称', type: 'string' }],
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
phone: [
{
validator: (_rule: unknown, value: unknown) => {
const normalized = typeof value === 'string' ? value.trim() : ''
if (!normalized) return Promise.resolve()
const mobileReg = /^1[3-9]\d{9}$/
if (mobileReg.test(normalized)) return Promise.resolve()
return Promise.reject(new Error('手机号格式不正确'))
},
trigger: 'blur'
}
]
})
function openEditCompany() {
if (!company.value) return
companyForm.companyId = company.value.companyId
companyForm.shortName = company.value.shortName ?? ''
companyForm.companyName = company.value.companyName ?? company.value.tenantName ?? ''
companyForm.domain = company.value.domain ?? ''
companyForm.phone = company.value.phone ?? ''
companyForm.email = company.value.email ?? ''
companyForm.address = company.value.address ?? ''
companyForm.invoiceHeader = company.value.invoiceHeader ?? ''
editCompanyOpen.value = true
}
async function submitCompany() {
if (!company.value) return
try {
await companyFormRef.value?.validate()
} catch {
return
}
if (!companyForm.companyId) {
message.error('企业ID缺失无法保存')
return
}
const companyName = (companyForm.companyName ?? '').trim()
if (!companyName) {
message.error('请输入企业全称')
return
}
savingCompany.value = true
try {
const domain = (companyForm.domain ?? '').trim()
const phone = (companyForm.phone ?? '').trim()
const email = (companyForm.email ?? '').trim()
const address = (companyForm.address ?? '').trim()
const invoiceHeader = (companyForm.invoiceHeader ?? '').trim()
await updateCompany({
companyId: companyForm.companyId,
shortName: (companyForm.shortName ?? '').trim() || undefined,
companyName,
domain: domain || undefined,
phone: phone || undefined,
email: email || undefined,
address: address || undefined,
invoiceHeader: invoiceHeader || undefined
} as Company)
message.success('保存成功')
editCompanyOpen.value = false
await load()
} catch (e: unknown) {
console.error(e)
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingCompany.value = false
}
}
onMounted(load)
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,456 @@
<template>
<div class="space-y-4">
<a-page-header title="实名认证" sub-title="企业/个人认证与资料提交">
<template #extra>
<a-space>
<a-tag v-if="current" :color="statusTagColor">{{ statusText }}</a-tag>
</a-space>
</template>
</a-page-header>
<a-card :bordered="false" class="card">
<a-alert show-icon :type="statusAlertType" :message="statusMessage" :description="statusDescription" />
<a-divider />
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules" :disabled="formDisabled">
<a-form-item label="认证类型" name="type">
<a-radio-group v-model:value="form.type">
<a-radio :value="0">个人</a-radio>
<a-radio :value="1">企业</a-radio>
</a-radio-group>
</a-form-item>
<template v-if="form.type === 0">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-form-item label="真实姓名" name="realName">
<a-input v-model:value="form.realName" placeholder="请输入真实姓名" />
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="证件号码" name="idCard">
<a-input v-model:value="form.idCard" placeholder="请输入身份证/证件号码" />
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="手机号" name="phone">
<a-input v-model:value="form.phone" placeholder="用于联系(选填)" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-form-item label="身份证正面" name="sfz1">
<a-upload
v-model:file-list="sfz1List"
:disabled="formDisabled"
:max-count="1"
list-type="picture-card"
:before-upload="beforeUpload"
:custom-request="uploadSfz1"
@remove="() => (form.sfz1 = '')"
>
<div>上传</div>
</a-upload>
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="身份证反面" name="sfz2">
<a-upload
v-model:file-list="sfz2List"
:disabled="formDisabled"
:max-count="1"
list-type="picture-card"
:before-upload="beforeUpload"
:custom-request="uploadSfz2"
@remove="() => (form.sfz2 = '')"
>
<div>上传</div>
</a-upload>
</a-form-item>
</a-col>
</a-row>
</template>
<template v-else>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-form-item label="主体名称" name="name">
<a-input v-model:value="form.name" placeholder="例如:某某科技有限公司" />
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="营业执照号码" name="zzCode">
<a-input v-model:value="form.zzCode" placeholder="请输入统一社会信用代码/执照号" />
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="联系人" name="realName">
<a-input v-model:value="form.realName" placeholder="请输入联系人姓名(选填)" />
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="form.phone" placeholder="用于联系(选填)" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="营业执照" name="zzImg">
<a-upload
v-model:file-list="zzImgList"
:disabled="formDisabled"
:max-count="1"
list-type="picture-card"
:before-upload="beforeUpload"
:custom-request="uploadZzImg"
@remove="() => (form.zzImg = '')"
>
<div>上传</div>
</a-upload>
</a-form-item>
</template>
</a-form>
<div class="mt-4 flex justify-end gap-2">
<!-- <a-popconfirm-->
<!-- v-if="current?.id"-->
<!-- :title="withdrawConfirmTitle"-->
<!-- ok-text="撤回"-->
<!-- cancel-text="取消"-->
<!-- @confirm="withdraw"-->
<!-- >-->
<!-- <a-button danger :loading="submitting">撤回</a-button>-->
<!-- </a-popconfirm>-->
<a-button @click="resetForm" :disabled="submitting || formDisabled">重置</a-button>
<a-button
type="primary"
:loading="submitting"
:disabled="formDisabled"
@click="submit"
>
{{ current?.id ? '更新' : '提交' }}
</a-button>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import { getUserInfo } from '@/api/layout'
import { addUserVerify, listUserVerify, removeUserVerify, updateUserVerify } from '@/api/system/userVerify'
import { uploadFile } from '@/api/system/file'
import type { UploadFile } from 'ant-design-vue'
import type { UserVerify } from '@/api/system/userVerify/model'
definePageMeta({ layout: 'console' })
type UploadRequestOption = {
file?: File
onSuccess?: (body: unknown, file: File) => void
onError?: (err: unknown) => void
}
const loading = ref(false)
const submitting = ref(false)
const current = ref<UserVerify | null>(null)
const userId = ref<number | null>(null)
const status = computed(() => current.value?.status)
const isPending = computed(() => status.value === 0)
const isApproved = computed(() => status.value === 1)
const isRejected = computed(() => status.value === 2 || status.value === 30)
const formDisabled = computed(() => !!current.value && (isPending.value || isApproved.value))
const statusText = computed(() => {
if (isPending.value) return '待审核'
if (isApproved.value) return '审核通过'
if (isRejected.value) return '已驳回'
if (status.value === undefined || status.value === null) return '未知状态'
return `未知状态(${status.value}`
})
const statusTagColor = computed(() => {
if (isPending.value) return 'gold'
if (isApproved.value) return 'green'
if (isRejected.value) return 'red'
return 'default'
})
const statusAlertType = computed(() => {
if (!current.value) return 'info'
if (isPending.value) return 'warning'
if (isApproved.value) return 'success'
if (isRejected.value) return 'error'
return 'info'
})
const statusMessage = computed(() => {
if (!current.value) return '未提交认证资料'
const prefix = isApproved.value ? '已通过实名认证' : isRejected.value ? '实名认证已驳回' : '已提交认证资料'
return `${prefix}ID: ${current.value.id ?? '-'}`
})
const statusDescription = computed(() => {
if (!current.value) return '提交后将生成一条实名认证记录,你可随时更新或撤回。'
const time = current.value.createTime ?? current.value.updateTime ?? '-'
const reason = (current.value.comments || '').trim()
if (isApproved.value) return `审核通过时间:${time}(审核通过后不可编辑;如需变更请联系管理员)`
if (isPending.value) return `提交时间:${time}(审核中不可编辑;如需修改请先撤回后重新提交)`
if (isRejected.value) return `驳回时间:${time}${reason ? `(原因:${reason}` : ''}(请修改资料后重新提交)`
return `提交时间:${time}`
})
const withdrawConfirmTitle = computed(() => {
if (isApproved.value) return '当前已审核通过,确定撤回(删除)实名认证记录?'
if (isPending.value) return '当前正在审核中,撤回后可修改并重新提交,确定撤回?'
if (isRejected.value) return '当前已驳回,撤回后可重新提交,确定撤回?'
return '确定撤回(删除)当前实名认证记录?'
})
const formRef = ref<FormInstance>()
const form = reactive<UserVerify>({
type: 0,
name: '',
zzCode: '',
zzImg: '',
realName: '',
phone: '',
idCard: '',
sfz1: '',
sfz2: '',
status: 0,
comments: ''
})
const rules = computed(() => {
if (form.type === 1) {
return {
type: [{ required: true, type: 'number', message: '请选择认证类型' }],
name: [{ required: true, type: 'string', message: '请输入主体名称' }],
zzCode: [{ required: true, type: 'string', message: '请输入营业执照号码' }],
zzImg: [{ required: true, type: 'string', message: '请上传营业执照' }]
}
}
return {
type: [{ required: true, type: 'number', message: '请选择认证类型' }],
realName: [{ required: true, type: 'string', message: '请输入真实姓名' }],
idCard: [{ required: true, type: 'string', message: '请输入证件号码' }],
sfz1: [{ required: true, type: 'string', message: '请上传身份证正面' }],
sfz2: [{ required: true, type: 'string', message: '请上传身份证反面' }]
}
})
function applyCurrentToForm(next: UserVerify | null) {
current.value = next
form.id = next?.id
form.type = next?.type ?? 0
form.name = next?.name ?? ''
form.zzCode = next?.zzCode ?? ''
form.zzImg = next?.zzImg ?? ''
form.realName = next?.realName ?? ''
form.phone = next?.phone ?? ''
form.idCard = next?.idCard ?? ''
form.sfz1 = next?.sfz1 ?? ''
form.sfz2 = next?.sfz2 ?? ''
form.status = next?.status ?? 0
form.comments = next?.comments ?? ''
syncFileLists()
}
const sfz1List = ref<UploadFile[]>([])
const sfz2List = ref<UploadFile[]>([])
const zzImgList = ref<UploadFile[]>([])
function toFileList(url: string): UploadFile[] {
const normalized = typeof url === 'string' ? url.trim() : ''
if (!normalized) return []
return [
{
uid: normalized,
name: normalized.split('/').slice(-1)[0] || 'image',
status: 'done',
url: normalized
} as UploadFile
]
}
function syncFileLists() {
sfz1List.value = toFileList(form.sfz1 ?? '')
sfz2List.value = toFileList(form.sfz2 ?? '')
zzImgList.value = toFileList(form.zzImg ?? '')
}
function beforeUpload(file: File) {
const isImage = file.type.startsWith('image/')
if (!isImage) {
message.error('仅支持上传图片文件')
return false
}
const maxSizeMb = 5
if (file.size > maxSizeMb * 1024 * 1024) {
message.error(`图片大小不能超过 ${maxSizeMb}MB`)
return false
}
return true
}
async function doUpload(
option: UploadRequestOption,
setUrl: (url: string) => void,
setList: (list: UploadFile[]) => void
) {
const rawFile = option.file
if (!rawFile) return
try {
const record = await uploadFile(rawFile)
const url = (record?.url || record?.downloadUrl || '').trim()
if (!url) throw new Error('上传成功但未返回文件地址')
setUrl(url)
setList(
toFileList(url).map((f) => ({
...f,
uid: String(rawFile.name) + '-' + String(Date.now())
})) as UploadFile[]
)
option.onSuccess?.(record, rawFile)
} catch (e) {
option.onError?.(e)
message.error(e instanceof Error ? e.message : '上传失败')
}
}
function uploadSfz1(option: UploadRequestOption) {
return doUpload(
option,
(url) => (form.sfz1 = url),
(list) => (sfz1List.value = list)
)
}
function uploadSfz2(option: UploadRequestOption) {
return doUpload(
option,
(url) => (form.sfz2 = url),
(list) => (sfz2List.value = list)
)
}
function uploadZzImg(option: UploadRequestOption) {
return doUpload(
option,
(url) => (form.zzImg = url),
(list) => (zzImgList.value = list)
)
}
async function load() {
loading.value = true
try {
const user = await getUserInfo()
userId.value = user.userId ?? null
} catch {
userId.value = null
}
try {
if (!userId.value) {
applyCurrentToForm(null)
return
}
const list = await listUserVerify({ userId: userId.value })
const mine = Array.isArray(list)
? [...list].sort((a, b) => (Number(b.id ?? 0) - Number(a.id ?? 0)))[0]
: undefined
applyCurrentToForm(mine ?? null)
} catch (e) {
applyCurrentToForm(null)
message.error(e instanceof Error ? e.message : '加载实名认证信息失败')
} finally {
loading.value = false
}
}
async function reload() {
await load()
}
function resetForm() {
applyCurrentToForm(current.value)
formRef.value?.clearValidate()
}
async function submit() {
if (formDisabled.value) {
message.warning(isApproved.value ? '审核通过后不可编辑' : '审核中不可编辑')
return
}
try {
await formRef.value?.validate()
} catch {
return
}
submitting.value = true
try {
const payload: UserVerify = {
id: form.id,
userId: userId.value ?? form.userId,
type: form.type,
name: form.name,
zzCode: form.zzCode,
zzImg: form.zzImg,
realName: form.realName,
phone: form.phone,
idCard: form.idCard,
sfz1: form.sfz1,
sfz2: form.sfz2,
status: 0,
comments: form.comments
}
if (current.value?.id) {
await updateUserVerify(payload)
message.success('认证资料已更新')
} else {
await addUserVerify(payload)
message.success('认证资料已提交')
}
await load()
} catch (e) {
message.error(e instanceof Error ? e.message : '提交失败')
} finally {
submitting.value = false
}
}
async function withdraw() {
if (!current.value?.id) return
submitting.value = true
try {
await removeUserVerify(current.value.id)
message.success('已撤回')
await load()
} catch (e) {
message.error(e instanceof Error ? e.message : '撤回失败')
} finally {
submitting.value = false
}
}
onMounted(async () => {
await load()
})
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,442 @@
<template>
<div class="space-y-4">
<a-page-header title="成员管理" sub-title="成员邀请角色与权限">
<template #extra>
<a-space>
<a-input
v-model:value="keywords"
allow-clear
placeholder="搜索账号/昵称/手机号"
class="w-64"
@press-enter="doSearch"
/>
<a-button :loading="loading" @click="reload">刷新</a-button>
<a-button type="primary" @click="openInvite">邀请成员</a-button>
</a-space>
</template>
</a-page-header>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-card :bordered="false" class="card" title="成员配额">
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="成员上限">
{{ company?.members ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="当前人数">
{{ company?.users ?? '-' }}
</a-descriptions-item>
</a-descriptions>
<div class="mt-4 text-sm text-gray-500">
成员数据来自系统用户租户维度可进行邀请禁用重置密码与角色设置
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" class="card" title="快速操作">
<a-space wrap>
<a-button @click="openInvite">邀请成员</a-button>
<a-button @click="reload">刷新列表</a-button>
</a-space>
</a-card>
</a-col>
</a-row>
<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.userId ?? r.username"
>
<a-table-column title="ID" data-index="userId" width="90" />
<a-table-column title="账号" data-index="username" width="180" />
<a-table-column title="昵称" data-index="nickname" width="160" />
<a-table-column title="手机号" data-index="phone" width="140" />
<a-table-column title="角色" key="roleName" width="160">
<template #default="{ record }">
<span>{{ resolveRoleName(record) }}</span>
</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="openRole(record)" :disabled="!record.userId">设置角色</a-button>
<a-button size="small" @click="openReset(record)" :disabled="!record.userId">重置密码</a-button>
<a-button
size="small"
:loading="busyUserId === record.userId"
@click="toggleStatus(record)"
:disabled="!record.userId"
>
{{ record.status === 1 ? '解冻' : '冻结' }}
</a-button>
<a-popconfirm
title="确定删除该成员"
ok-text="删除"
cancel-text="取消"
@confirm="remove(record)"
>
<a-button size="small" danger :disabled="!record.userId">删除</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="inviteOpen"
title="邀请成员"
ok-text="创建账号"
cancel-text="取消"
:confirm-loading="inviting"
@ok="submitInvite"
>
<a-form ref="inviteFormRef" layout="vertical" :model="inviteForm" :rules="inviteRules">
<a-form-item label="账号" name="username">
<a-input v-model:value="inviteForm.username" placeholder="例如tom / tom@example.com" />
</a-form-item>
<a-form-item label="昵称" name="nickname">
<a-input v-model:value="inviteForm.nickname" placeholder="例如Tom" />
</a-form-item>
<a-form-item label="手机号" name="phone">
<a-input v-model:value="inviteForm.phone" placeholder="例如13800000000" />
</a-form-item>
<a-form-item label="角色" name="roleId">
<a-select
v-model:value="inviteForm.roleId"
placeholder="请选择角色"
allow-clear
:options="roleOptions"
/>
</a-form-item>
<a-form-item label="初始密码" name="password">
<a-input-password v-model:value="inviteForm.password" placeholder="请输入初始密码" />
</a-form-item>
<a-form-item label="确认密码" name="password2">
<a-input-password v-model:value="inviteForm.password2" placeholder="再次输入密码" />
</a-form-item>
</a-form>
<a-alert
class="mt-2"
type="info"
show-icon
message="创建后可在本页进行冻结/解冻重置密码与角色设置"
/>
</a-modal>
<a-modal
v-model:open="roleOpen"
title="设置角色"
ok-text="保存"
cancel-text="取消"
:confirm-loading="savingRole"
@ok="submitRole"
>
<a-form layout="vertical">
<a-form-item label="成员">
<a-input :value="selectedUser?.nickname || selectedUser?.username || ''" disabled />
</a-form-item>
<a-form-item label="角色">
<a-select v-model:value="selectedRoleId" placeholder="请选择角色" :options="roleOptions" />
</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="selectedUser?.nickname || selectedUser?.username || ''" 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 { computed, onMounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import { getTenantInfo } from '@/api/layout'
import { listRoles } from '@/api/system/role'
import { addUser, pageUsers, removeUser, updateUserPassword, updateUserStatus } from '@/api/system/user'
import { addUserRole, listUserRole, updateUserRole } from '@/api/system/userRole'
import type { Company } from '@/api/system/company/model'
import type { Role } from '@/api/system/role/model'
import type { User } from '@/api/system/user/model'
definePageMeta({ layout: 'console' })
const loading = ref(false)
const error = ref<string>('')
const company = ref<Company | null>(null)
const roles = ref<Role[]>([])
const list = ref<User[]>([])
const total = ref(0)
const page = ref(1)
const limit = ref(10)
const keywords = ref('')
const roleOptions = computed(() =>
roles.value.map((r) => ({ label: r.roleName ?? String(r.roleId ?? ''), value: r.roleId }))
)
function resolveRoleName(user: User) {
const direct = typeof user.roleName === 'string' ? user.roleName.trim() : ''
if (direct) return direct
const hit = roles.value.find((r) => r.roleId === user.roleId)
return hit?.roleName ?? '-'
}
async function loadCompany() {
try {
company.value = await getTenantInfo()
} catch {
// ignore
}
}
async function loadRolesOnce() {
if (roles.value.length) return
try {
roles.value = await listRoles()
} catch {
roles.value = []
}
}
async function loadMembers() {
loading.value = true
error.value = ''
try {
const res = await pageUsers({
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined
})
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 Promise.all([loadCompany(), loadRolesOnce(), loadMembers()])
}
function doSearch() {
page.value = 1
loadMembers()
}
function onPageChange(nextPage: number) {
page.value = nextPage
loadMembers()
}
function onPageSizeChange(_current: number, nextSize: number) {
limit.value = nextSize
page.value = 1
loadMembers()
}
onMounted(async () => {
await reload()
})
const busyUserId = ref<number | null>(null)
async function toggleStatus(user: User) {
if (!user.userId) return
const next = user.status === 1 ? 0 : 1
busyUserId.value = user.userId
try {
await updateUserStatus(user.userId, next)
message.success(next === 0 ? '已解冻' : '已冻结')
await loadMembers()
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
busyUserId.value = null
}
}
async function remove(user: User) {
if (!user.userId) return
busyUserId.value = user.userId
try {
await removeUser(user.userId)
message.success('已删除')
await loadMembers()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
} finally {
busyUserId.value = null
}
}
const inviteOpen = ref(false)
const inviting = ref(false)
const inviteFormRef = ref<FormInstance>()
const inviteForm = reactive<{ username: string; nickname: string; phone: string; roleId?: number; password: string; password2: string }>({
username: '',
nickname: '',
phone: '',
roleId: undefined,
password: '',
password2: ''
})
const inviteRules = reactive({
username: [{ required: true, type: 'string', message: '请输入账号' }],
nickname: [{ required: true, type: 'string', message: '请输入昵称' }],
password: [{ required: true, type: 'string', message: '请输入初始密码' }],
password2: [{ required: true, type: 'string', message: '请再次输入密码' }]
})
function openInvite() {
inviteForm.username = ''
inviteForm.nickname = ''
inviteForm.phone = ''
inviteForm.roleId = undefined
inviteForm.password = ''
inviteForm.password2 = ''
inviteOpen.value = true
}
async function submitInvite() {
try {
await inviteFormRef.value?.validate()
} catch {
return
}
if (inviteForm.password !== inviteForm.password2) {
message.error('两次输入的密码不一致')
return
}
inviting.value = true
try {
await addUser({
username: inviteForm.username.trim(),
nickname: inviteForm.nickname.trim(),
phone: inviteForm.phone.trim() || undefined,
password: inviteForm.password,
password2: inviteForm.password2,
roleId: inviteForm.roleId
})
message.success('成员已创建')
inviteOpen.value = false
await loadMembers()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
inviting.value = false
}
}
const roleOpen = ref(false)
const savingRole = ref(false)
const selectedUser = ref<User | null>(null)
const selectedRoleId = ref<number | undefined>(undefined)
function openRole(user: User) {
selectedUser.value = user
selectedRoleId.value = user.roleId
roleOpen.value = true
}
async function submitRole() {
if (!selectedUser.value?.userId) return
if (!selectedRoleId.value) {
message.error('请选择角色')
return
}
savingRole.value = true
try {
const mappings = await listUserRole({ userId: selectedUser.value.userId })
const first = Array.isArray(mappings) ? mappings[0] : undefined
if (first?.id) {
await updateUserRole({ ...first, roleId: selectedRoleId.value })
} else {
await addUserRole({ userId: selectedUser.value.userId, roleId: selectedRoleId.value })
}
message.success('角色已更新')
roleOpen.value = false
await loadMembers()
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
} finally {
savingRole.value = false
}
}
const resetOpen = ref(false)
const resetting = ref(false)
const resetPassword = ref('')
function openReset(user: User) {
selectedUser.value = user
resetPassword.value = ''
resetOpen.value = true
}
async function submitReset() {
if (!selectedUser.value?.userId) return
const pwd = resetPassword.value.trim()
if (!pwd) {
message.error('请输入新密码')
return
}
resetting.value = true
try {
await updateUserPassword(selectedUser.value.userId, pwd)
message.success('密码已重置')
resetOpen.value = false
} catch (e) {
message.error(e instanceof Error ? e.message : '重置失败')
} finally {
resetting.value = false
}
}
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,134 @@
<template>
<div class="space-y-4">
<a-page-header title="账号安全" sub-title="密码登录设备与安全设置">
<template #extra>
<a-space>
<a-button danger @click="logout">退出登录</a-button>
</a-space>
</template>
</a-page-header>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-card :bordered="false" class="card" title="修改密码">
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules">
<a-form-item label="原密码" name="oldPassword">
<a-input-password v-model:value="form.oldPassword" placeholder="请输入原密码" />
</a-form-item>
<a-form-item label="新密码" name="password">
<a-input-password v-model:value="form.password" placeholder="请输入新密码(至少 6 位)" />
</a-form-item>
<a-form-item label="确认新密码" name="password2">
<a-input-password v-model:value="form.password2" placeholder="再次输入新密码" />
</a-form-item>
</a-form>
<div class="mt-2 flex justify-end gap-2">
<a-button @click="resetForm" :disabled="pending">重置</a-button>
<a-button type="primary" :loading="pending" @click="submit">保存</a-button>
</div>
<a-alert
class="mt-4"
show-icon
type="info"
message="修改密码后建议重新登录,以确保所有会话状态一致。"
/>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" class="card" title="安全建议">
<a-list size="small" bordered :data-source="tips">
<template #renderItem="{ item }">
<a-list-item>{{ item }}</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import { updatePassword } from '@/api/layout'
import { removeToken } from '@/utils/token-util'
import { clearAuthz } from '@/utils/permission'
definePageMeta({ layout: 'console' })
const pending = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<{ oldPassword: string; password: string; password2: string }>({
oldPassword: '',
password: '',
password2: ''
})
const rules = reactive({
oldPassword: [{ required: true, type: 'string', message: '请输入原密码' }],
password: [
{ required: true, type: 'string', message: '请输入新密码' },
{ min: 6, type: 'string', message: '新密码至少 6 位', trigger: 'blur' }
],
password2: [{ required: true, type: 'string', message: '请再次输入新密码' }]
})
const tips = [
'定期修改密码,避免与其他平台重复使用。',
'优先使用更长的随机密码。',
'不要将账号/密码分享给他人。',
'如怀疑账号被盗用,请立即修改密码并退出登录。'
]
function resetForm() {
form.oldPassword = ''
form.password = ''
form.password2 = ''
formRef.value?.clearValidate()
}
async function submit() {
try {
await formRef.value?.validate()
} catch {
return
}
if (form.password !== form.password2) {
message.error('两次输入的新密码不一致')
return
}
pending.value = true
try {
await updatePassword({ oldPassword: form.oldPassword, password: form.password })
message.success('密码修改成功')
resetForm()
} catch (e) {
message.error(e instanceof Error ? e.message : '密码修改失败')
} finally {
pending.value = false
}
}
function logout() {
removeToken()
try {
localStorage.removeItem('TenantId')
localStorage.removeItem('UserId')
} catch {
// ignore
}
clearAuthz()
navigateTo('/login')
}
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<div class="space-y-4">
<a-page-header title="优惠券" sub-title="可用优惠与使用记录" />
<a-card :bordered="false" class="card">
<a-empty description="待接入:优惠券列表" />
</a-card>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: 'console' })
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

362
app/pages/console/index.vue Normal file
View File

@@ -0,0 +1,362 @@
<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.websiteId ?? r.appId ?? r.websiteName ?? r.tenantName"
>
<a-table-column title="租户ID" data-index="tenantId" width="90" />
<a-table-column title="租户名称" key="tenantName">
<template #default="{ record }">
<div class="flex items-center gap-2 min-w-0">
<a-avatar :src="record.websiteLogo || record.websiteIcon || record.logo" :size="22" shape="square">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="truncate">{{ record.websiteName || record.tenantName || '-' }}</span>
</div>
</template>
</a-table-column>
<a-table-column title="状态" key="status">
<template #default="{ record }">
<a-tag :color="statusColor(record.status)">
{{ statusText(record.status, record.statusText) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="创建时间" data-index="createTime" />
<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="comments">
<a-textarea v-model:value="editForm.comments" :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 { pageCmsWebsiteAll } from '@/api/cms/cmsWebsite'
import type { CmsWebsite } from '@/api/cms/cmsWebsite/model'
import { addTenant, removeTenant, updateTenant, updateTenantPassword } from '@/api/system/tenant'
import type { Tenant } from '@/api/system/tenant/model'
definePageMeta({ layout: 'console' })
const loading = ref(false)
const error = ref<string>('')
const list = ref<CmsWebsite[]>([])
const total = ref(0)
const page = ref(1)
const limit = ref(10)
const keywords = ref('')
async function loadTenants() {
loading.value = true
error.value = ''
try {
const rawUserId = process.client ? localStorage.getItem('UserId') : null
const userId = rawUserId ? Number(rawUserId) : NaN
const res = await pageCmsWebsiteAll({
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined,
userId: Number.isFinite(userId) ? userId : undefined
})
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: '',
comments: '',
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.comments = ''
editForm.status = 0
editOpen.value = true
}
function openEdit(row: CmsWebsite | Tenant) {
// pageCmsWebsiteAll 返回的是应用(网站)列表,这里映射到租户表单字段
const anyRow = row as unknown as Partial<CmsWebsite & Tenant>
editForm.tenantId = anyRow.tenantId
editForm.tenantName = anyRow.tenantName ?? anyRow.websiteName ?? ''
editForm.companyName = anyRow.companyName ?? ''
editForm.appId = anyRow.appId ?? anyRow.websiteCode ?? ''
editForm.appSecret = anyRow.appSecret ?? anyRow.websiteSecret ?? ''
editForm.logo = anyRow.logo ?? anyRow.websiteLogo ?? anyRow.websiteIcon ?? ''
editForm.comments = anyRow.comments ?? ''
// 租户状态只支持 0/1应用状态(0~5) 这里做一个兼容映射
editForm.status = typeof anyRow.status === 'number' ? (anyRow.status === 1 ? 0 : 1) : 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,
comments: editForm.comments?.trim() || undefined
}
if (payload.tenantId) {
await updateTenant(payload)
message.success('租户已更新')
} else {
await addTenant(payload)
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: CmsWebsite | Tenant) {
if (!row.tenantId) return
busyTenantId.value = row.tenantId
try {
await removeTenant(row.tenantId)
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)
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)
message.success('密码已重置')
resetOpen.value = false
} catch (e) {
message.error(e instanceof Error ? e.message : '重置失败')
} finally {
resetting.value = false
}
}
function statusText(status?: number, fallback?: string) {
if (fallback) return fallback
const map: Record<number, string> = {
0: '未开通',
1: '运行中',
2: '维护中',
3: '已关闭',
4: '欠费停机',
5: '违规关停'
}
if (typeof status === 'number' && status in map) return map[status]
return '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = {
0: 'default',
1: 'green',
2: 'orange',
3: 'red',
4: 'volcano',
5: 'red'
}
if (typeof status === 'number' && status in map) return map[status]
return 'default'
}
</script>
<style scoped>
.page-header {
border-radius: 12px;
}
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,425 @@
<template>
<div class="space-y-4">
<a-page-header title="发票记录" sub-title="开票申请与发票下载">
<template #extra>
<a-space>
<a-button :loading="loadingPrefill" @click="prefill">自动填充</a-button>
<a-button @click="reloadRecords">刷新记录</a-button>
</a-space>
</template>
</a-page-header>
<a-card :bordered="false" class="card">
<a-alert
class="mb-4"
show-icon
type="info"
message="开票申请提交后会记录在本地(浏览器)用于演示;如需接入后端开票流程,可在 submitApply 中替换为真实接口。"
/>
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules">
<div class="grid gap-4 md:grid-cols-2">
<a-form-item label="发票类型" name="invoiceType">
<a-select
v-model:value="form.invoiceType"
placeholder="请选择发票类型"
:options="invoiceTypeOptions"
/>
</a-form-item>
<a-form-item label="发票获取方式" name="deliveryMethod">
<a-select v-model:value="form.deliveryMethod" :options="deliveryMethodOptions" disabled />
</a-form-item>
<a-form-item label="发票抬头" name="invoiceTitle">
<a-input v-model:value="form.invoiceTitle" placeholder="例如:某某科技有限公司" />
</a-form-item>
<a-form-item label="纳税人识别号" name="taxpayerId">
<a-input v-model:value="form.taxpayerId" placeholder="请输入纳税人识别号" />
</a-form-item>
<a-form-item label="邮箱地址" name="email">
<a-input v-model:value="form.email" placeholder="例如name@example.com" />
</a-form-item>
<div class="hidden md:block" />
<a-form-item label="开户银行" name="bankName">
<a-input v-model:value="form.bankName" placeholder="专票必填" />
</a-form-item>
<a-form-item label="开户账号" name="bankAccount">
<a-input v-model:value="form.bankAccount" placeholder="专票必填" />
</a-form-item>
<a-form-item label="注册地址" name="registeredAddress">
<a-input v-model:value="form.registeredAddress" placeholder="专票必填" />
</a-form-item>
<a-form-item label="注册电话" name="registeredPhone">
<a-input v-model:value="form.registeredPhone" placeholder="专票必填(座机/手机号)" />
</a-form-item>
</div>
<a-space class="mt-2">
<a-button type="primary" :loading="submitting" @click="submitApply">提交开票申请</a-button>
<a-button :disabled="submitting" @click="resetForm">重置</a-button>
</a-space>
</a-form>
</a-card>
<a-card :bordered="false" class="card">
<a-space class="mb-3" align="center">
<div class="text-base font-medium">申请记录</div>
<a-tag color="blue">{{ records.length }}</a-tag>
</a-space>
<a-empty v-if="!records.length" description="暂无开票申请记录" />
<a-table
v-else
:data-source="records"
:pagination="false"
size="middle"
:row-key="(r: InvoiceApplyRecord) => r.id"
>
<a-table-column title="提交时间" key="createdAt" width="180">
<template #default="{ record }">
<span>{{ formatTime(record.createdAt) }}</span>
</template>
</a-table-column>
<a-table-column title="发票类型" key="invoiceType" width="170">
<template #default="{ record }">
<span>{{ invoiceTypeText(record.invoiceType) }}</span>
</template>
</a-table-column>
<a-table-column title="发票抬头" key="invoiceTitle" ellipsis>
<template #default="{ record }">
<span>{{ record.invoiceTitle || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="邮箱" key="email" width="220" ellipsis>
<template #default="{ record }">
<span>{{ record.email || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="状态" key="status" width="120">
<template #default="{ record }">
<a-tag v-if="record.status === 'submitted'" color="default">已提交</a-tag>
<a-tag v-else-if="record.status === 'issued'" color="green">已开具</a-tag>
<a-tag v-else-if="record.status === 'rejected'" color="red">已驳回</a-tag>
<a-tag v-else color="default">-</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" key="actions" width="220" fixed="right">
<template #default="{ record }">
<a-space>
<a-button size="small" @click="openDetail(record)">查看</a-button>
<a-button size="small" :disabled="!record.fileUrl" @click="download(record)">下载</a-button>
<a-button danger size="small" @click="removeRecord(record)">删除</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
</a-card>
<a-modal v-model:open="detailOpen" title="开票申请详情" :width="720" ok-text="关闭" cancel-text="取消" :footer="null">
<a-descriptions bordered size="small" :column="2">
<a-descriptions-item label="发票类型">{{ invoiceTypeText(detail?.invoiceType) }}</a-descriptions-item>
<a-descriptions-item label="发票获取方式">数字电子发票</a-descriptions-item>
<a-descriptions-item label="发票抬头" :span="2">{{ detail?.invoiceTitle || '-' }}</a-descriptions-item>
<a-descriptions-item label="纳税人识别号" :span="2">{{ detail?.taxpayerId || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱" :span="2">{{ detail?.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="开户银行" :span="2">{{ detail?.bankName || '-' }}</a-descriptions-item>
<a-descriptions-item label="开户账号" :span="2">{{ detail?.bankAccount || '-' }}</a-descriptions-item>
<a-descriptions-item label="注册地址" :span="2">{{ detail?.registeredAddress || '-' }}</a-descriptions-item>
<a-descriptions-item label="注册电话" :span="2">{{ detail?.registeredPhone || '-' }}</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ formatTime(detail?.createdAt) }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="detail?.status === 'submitted'" color="default">已提交</a-tag>
<a-tag v-else-if="detail?.status === 'issued'" color="green">已开具</a-tag>
<a-tag v-else-if="detail?.status === 'rejected'" color="red">已驳回</a-tag>
<a-tag v-else color="default">-</a-tag>
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { message, Modal, type FormInstance } from 'ant-design-vue'
import { getTenantInfo, getUserInfo } from '@/api/layout'
definePageMeta({ layout: 'console' })
type InvoiceType = 'normal' | 'special'
type InvoiceDeliveryMethod = 'digital'
type InvoiceApplyStatus = 'submitted' | 'issued' | 'rejected'
type InvoiceApplyRecord = {
id: string
createdAt: string
status: InvoiceApplyStatus
invoiceType: InvoiceType
invoiceTitle: string
taxpayerId: string
email: string
deliveryMethod: InvoiceDeliveryMethod
bankName: string
bankAccount: string
registeredAddress: string
registeredPhone: string
invoiceNo?: string
fileUrl?: string
}
const STORAGE_KEY = 'console.invoiceApplications.v1'
const invoiceTypeOptions = [
{ label: '增值税普通发票', value: 'normal' },
{ label: '增值税专用发票', value: 'special' }
]
const deliveryMethodOptions = [{ label: '数字电子发票', value: 'digital' }]
const loadingPrefill = ref(false)
const submitting = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<{
invoiceType: InvoiceType | undefined
invoiceTitle: string
taxpayerId: string
email: string
deliveryMethod: InvoiceDeliveryMethod
bankName: string
bankAccount: string
registeredAddress: string
registeredPhone: string
}>({
invoiceType: undefined,
invoiceTitle: '',
taxpayerId: '',
email: '',
deliveryMethod: 'digital',
bankName: '',
bankAccount: '',
registeredAddress: '',
registeredPhone: ''
})
const records = ref<InvoiceApplyRecord[]>([])
const detailOpen = ref(false)
const detail = ref<InvoiceApplyRecord | null>(null)
function invoiceTypeText(value?: InvoiceType | null) {
if (value === 'special') return '增值税专用发票'
if (value === 'normal') return '增值税普通发票'
return '-'
}
function formatTime(value?: string | null) {
if (!value) return '-'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function safeParseRecords(raw: string | null): InvoiceApplyRecord[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed as InvoiceApplyRecord[]
} catch {
return []
}
}
function persistRecords(next: InvoiceApplyRecord[]) {
try {
if (!import.meta.client) return
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// ignore
}
}
function reloadRecords() {
if (!import.meta.client) return
records.value = safeParseRecords(localStorage.getItem(STORAGE_KEY))
}
function generateId() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function isSpecialInvoice() {
return form.invoiceType === 'special'
}
function requiredWhenSpecial(label: string) {
return (_rule: unknown, value: unknown) => {
if (!isSpecialInvoice()) return Promise.resolve()
const normalized = typeof value === 'string' ? value.trim() : ''
if (normalized) return Promise.resolve()
return Promise.reject(new Error(`${label}不能为空(专票必填)`))
}
}
function phoneValidator(_rule: unknown, value: unknown) {
const normalized = typeof value === 'string' ? value.trim() : ''
if (!normalized) {
if (isSpecialInvoice()) return Promise.reject(new Error('注册电话不能为空(专票必填)'))
return Promise.resolve()
}
const mobileReg = /^1[3-9]\d{9}$/
const landlineReg = /^0\d{2,3}-?\d{7,8}$/
if (mobileReg.test(normalized) || landlineReg.test(normalized)) return Promise.resolve()
return Promise.reject(new Error('电话格式不正确座机0xx-xxxxxxx 或手机号)'))
}
const rules = computed(() => ({
invoiceType: [{ required: true, message: '请选择发票类型' }],
invoiceTitle: [{ required: true, message: '请输入发票抬头', type: 'string' }],
taxpayerId: [{ required: true, message: '请输入纳税人识别号', type: 'string' }],
email: [
{ required: true, message: '请输入邮箱地址', type: 'string' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
],
deliveryMethod: [{ required: true, message: '请选择发票获取方式' }],
bankName: [{ validator: requiredWhenSpecial('开户银行'), trigger: 'blur' }],
bankAccount: [{ validator: requiredWhenSpecial('开户账号'), trigger: 'blur' }],
registeredAddress: [{ validator: requiredWhenSpecial('注册地址'), trigger: 'blur' }],
registeredPhone: [{ validator: phoneValidator, trigger: 'blur' }]
}))
async function prefill(options: { silent?: boolean } = {}) {
loadingPrefill.value = true
try {
const [uRes, cRes] = await Promise.allSettled([getUserInfo(), getTenantInfo()])
if (uRes.status === 'fulfilled') {
if (!form.email.trim()) form.email = (uRes.value.email ?? '').trim()
}
if (cRes.status === 'fulfilled') {
const title = (cRes.value.invoiceHeader ?? cRes.value.companyName ?? cRes.value.tenantName ?? '').trim()
if (title && !form.invoiceTitle.trim()) form.invoiceTitle = title
}
if (!options.silent) message.success('已自动填充可用信息')
} catch (e: unknown) {
console.error(e)
if (!options.silent) message.error(e instanceof Error ? e.message : '自动填充失败')
} finally {
loadingPrefill.value = false
}
}
function resetForm() {
form.invoiceType = undefined
form.invoiceTitle = ''
form.taxpayerId = ''
form.bankName = ''
form.bankAccount = ''
form.registeredAddress = ''
form.registeredPhone = ''
form.deliveryMethod = 'digital'
}
async function submitApply() {
try {
await formRef.value?.validate()
} catch {
return
}
const payload: Omit<InvoiceApplyRecord, 'id' | 'createdAt' | 'status'> = {
invoiceType: form.invoiceType as InvoiceType,
invoiceTitle: form.invoiceTitle.trim(),
taxpayerId: form.taxpayerId.trim(),
email: form.email.trim(),
deliveryMethod: form.deliveryMethod,
bankName: form.bankName.trim(),
bankAccount: form.bankAccount.trim(),
registeredAddress: form.registeredAddress.trim(),
registeredPhone: form.registeredPhone.trim()
}
if (!payload.invoiceTitle) return message.error('请输入发票抬头')
if (!payload.taxpayerId) return message.error('请输入纳税人识别号')
if (!payload.email) return message.error('请输入邮箱地址')
submitting.value = true
try {
const next: InvoiceApplyRecord = {
id: generateId(),
createdAt: new Date().toISOString(),
status: 'submitted',
...payload
}
const updated = [next, ...records.value]
records.value = updated
persistRecords(updated)
message.success('已提交开票申请')
resetForm()
await prefill({ silent: true })
} catch (e: unknown) {
console.error(e)
message.error(e instanceof Error ? e.message : '提交失败')
} finally {
submitting.value = false
}
}
function openDetail(record: InvoiceApplyRecord) {
detail.value = record
detailOpen.value = true
}
function download(record: InvoiceApplyRecord) {
if (!record.fileUrl) return
if (!import.meta.client) return
window.open(record.fileUrl, '_blank', 'noopener,noreferrer')
}
function removeRecord(record: InvoiceApplyRecord) {
Modal.confirm({
title: '确认删除该开票申请?',
content: '删除后无法恢复(仅删除本地记录)。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
const updated = records.value.filter((r) => r.id !== record.id)
records.value = updated
persistRecords(updated)
if (detail.value?.id === record.id) {
detailOpen.value = false
detail.value = null
}
message.success('已删除')
}
})
}
onMounted(() => {
reloadRecords()
prefill({ silent: true })
})
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<a-spin size="large" tip="正在退出..." class="logout-spin" />
</template>
<script setup lang="ts">
import { removeToken } from '@/utils/token-util'
import { clearAuthz } from '@/utils/permission'
definePageMeta({ layout: 'console' })
onMounted(async () => {
removeToken()
try {
localStorage.removeItem('TenantId')
localStorage.removeItem('UserId')
} catch {
// ignore
}
clearAuthz()
await navigateTo('/')
})
</script>
<style scoped>
.logout-spin {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
}
</style>

View File

@@ -0,0 +1,394 @@
<template>
<div class="space-y-4">
<a-page-header title="订单管理" sub-title="购买续费与支付记录">
<template #extra>
<a-space>
<a-input
v-model:value="keywords"
allow-clear
placeholder="搜索订单号/产品"
class="w-64"
@press-enter="doSearch"
/>
<a-button :loading="loading" @click="reload">刷新</a-button>
</a-space>
</template>
</a-page-header>
<a-card :bordered="false" class="card">
<a-space class="mb-4">
<a-segmented
:value="payStatusSegment"
:options="payStatusOptions"
@update:value="onPayStatusChange"
/>
<a-select
v-model:value="orderStatus"
allow-clear
placeholder="订单状态"
:options="orderStatusOptions"
@change="reload"
style="min-width: 160px"
/>
</a-space>
<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.orderId ?? r.orderNo"
>
<a-table-column title="订单号" key="orderNo" width="220">
<template #default="{ record }">
<a-typography-text :copyable="{ text: record.orderNo || '' }">
{{ record.orderNo || '-' }}
</a-typography-text>
</template>
</a-table-column>
<a-table-column title="产品" key="product" width="200">
<template #default="{ record }">
<div class="min-w-0">
<div class="truncate">{{ resolveProductName(record) }}</div>
<div class="text-xs text-gray-500 truncate" v-if="resolveProductSub(record)">
{{ resolveProductSub(record) }}
</div>
</div>
</template>
</a-table-column>
<a-table-column title="金额" key="amount" width="140">
<template #default="{ record }">
<span>{{ formatMoney(record.payPrice || record.totalPrice) }}</span>
</template>
</a-table-column>
<a-table-column title="支付" key="payStatus" width="110">
<template #default="{ record }">
<a-tag v-if="Number(record.payStatus) === 1" color="green">已支付</a-tag>
<a-tag v-else-if="Number(record.payStatus) === 0" color="default">未支付</a-tag>
<a-tag v-else color="default">-</a-tag>
</template>
</a-table-column>
<a-table-column title="状态" key="orderStatus" width="160">
<template #default="{ record }">
<a-tag :color="resolveOrderStatusColor(record.orderStatus)">{{ resolveOrderStatusText(record.orderStatus) }}</a-tag>
</template>
</a-table-column>
<a-table-column title="创建时间" data-index="createTime" width="180">
<template #default="{ record }">
<span>{{ record.createTime || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="到期时间" data-index="expirationTime" width="180">
<template #default="{ record }">
<span>{{ record.expirationTime || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="操作" key="actions" width="120" fixed="right">
<template #default="{ record }">
<a-button size="small" @click="openDetail(record)">查看</a-button>
</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="detailOpen" title="订单详情" :width="720" ok-text="关闭" cancel-text="取消" :footer="null">
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="订单号">
<a-typography-text :copyable="{ text: selected?.orderNo || '' }">
{{ selected?.orderNo || '-' }}
</a-typography-text>
</a-descriptions-item>
<a-descriptions-item label="订单ID">{{ selected?.orderId ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="金额">{{ formatMoney(selected?.payPrice || selected?.totalPrice) }}</a-descriptions-item>
<a-descriptions-item label="支付方式">{{ resolvePayTypeText(selected?.payType) }}</a-descriptions-item>
<a-descriptions-item label="支付状态">
{{ Number(selected?.payStatus) === 1 ? '已支付' : Number(selected?.payStatus) === 0 ? '未支付' : '-' }}
</a-descriptions-item>
<a-descriptions-item label="订单状态">{{ resolveOrderStatusText(selected?.orderStatus) }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ selected?.createTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="支付时间">{{ selected?.payTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="到期时间">{{ selected?.expirationTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="产品">
{{ resolveProductName(selected) }}
</a-descriptions-item>
<a-descriptions-item label="备注">
<span class="break-all">{{ pickFirstRemark(selected) || '-' }}</span>
</a-descriptions-item>
</a-descriptions>
<a-divider />
<div class="text-sm text-gray-600 mb-2">解析到的扩展字段buyerRemarks/merchantRemarks/comments</div>
<a-typography-paragraph :copyable="{ text: prettyExtra(selected) }">
<pre class="m-0 whitespace-pre-wrap break-words">{{ prettyExtra(selected) }}</pre>
</a-typography-paragraph>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { getUserInfo } from '@/api/layout'
import { pageShopOrder } from '@/api/shop/shopOrder'
import type { ShopOrder } from '@/api/shop/shopOrder/model'
definePageMeta({ layout: 'console' })
const loading = ref(false)
const error = ref('')
const list = ref<ShopOrder[]>([])
const page = ref(1)
const limit = ref(10)
const total = ref(0)
const keywords = ref('')
const payStatus = ref<number | null>(null)
const orderStatus = ref<number | null>(null)
const currentUserId = ref<number | null>(null)
const payStatusOptions = [
{ label: '全部', value: 'all' },
{ label: '已支付', value: 1 },
{ label: '未支付', value: 0 }
]
const payStatusSegment = computed(() => (payStatus.value === null ? 'all' : payStatus.value))
const orderStatusOptions = [
{ label: '未使用', value: 0 },
{ label: '已完成', value: 1 },
{ label: '已取消', value: 2 },
{ label: '取消中', value: 3 },
{ label: '退款申请中', value: 4 },
{ label: '退款被拒绝', value: 5 },
{ label: '退款成功', value: 6 },
{ label: '客户申请退款', value: 7 }
]
const detailOpen = ref(false)
const selected = ref<ShopOrder | null>(null)
function safeJsonParse(value: string): unknown {
try {
return JSON.parse(value)
} catch {
return undefined
}
}
function pickFirstRemark(order?: ShopOrder | null) {
if (!order) return ''
const record = order as unknown as Record<string, unknown>
const keys = ['buyerRemarks', 'merchantRemarks', 'comments']
for (const key of keys) {
const v = record[key]
if (typeof v === 'string' && v.trim()) return v.trim()
}
return ''
}
function parseExtra(order?: ShopOrder | null): Record<string, unknown> | null {
const raw = pickFirstRemark(order)
if (!raw) return null
const parsed = safeJsonParse(raw)
if (!parsed || typeof parsed !== 'object') return null
return parsed as Record<string, unknown>
}
function prettyExtra(order?: ShopOrder | null) {
const extra = parseExtra(order)
if (!extra) return '-'
try {
return JSON.stringify(extra, null, 2)
} catch {
return '-'
}
}
const productCatalog: Record<string, { name: string }> = {
website: { name: '企业官网' },
shop: { name: '电商系统' },
mp: { name: '小程序/公众号' }
}
function resolveProductCode(order?: ShopOrder | null) {
const extra = parseExtra(order)
const code = typeof extra?.product === 'string' ? extra.product.trim() : ''
return code
}
function resolveProductSub(order?: ShopOrder | null) {
const extra = parseExtra(order)
const months = extra?.months
const tenantName = extra?.tenantName
const domain = extra?.domain
const parts: string[] = []
if (typeof months === 'number' || typeof months === 'string') {
const m = String(months).trim()
if (m) parts.push(`${m}个月`)
}
if (typeof tenantName === 'string' && tenantName.trim()) parts.push(tenantName.trim())
if (typeof domain === 'string' && domain.trim()) parts.push(domain.trim())
return parts.join(' · ')
}
function resolveProductName(order?: ShopOrder | null) {
const code = resolveProductCode(order)
if (code && productCatalog[code]) return productCatalog[code].name
if (code) return code
return '-'
}
function formatMoney(value?: string) {
const v = typeof value === 'string' ? value.trim() : ''
if (!v) return '-'
const n = Number(v)
if (!Number.isFinite(n)) return `¥${v}`
return `¥${n.toFixed(2)}`
}
function resolvePayTypeText(payType?: number) {
const v = Number(payType)
if (!Number.isFinite(v)) return '-'
const map: Record<number, string> = {
0: '余额',
1: '微信',
102: '微信 Native',
2: '会员卡',
3: '支付宝',
4: '现金',
5: 'POS',
12: '免费'
}
return map[v] || `方式${v}`
}
function resolveOrderStatusText(orderStatus?: number) {
const v = Number(orderStatus)
if (!Number.isFinite(v)) return '-'
const map: Record<number, string> = {
0: '未使用',
1: '已完成',
2: '已取消',
3: '取消中',
4: '退款申请中',
5: '退款被拒绝',
6: '退款成功',
7: '客户申请退款'
}
return map[v] || `状态${v}`
}
function resolveOrderStatusColor(orderStatus?: number) {
const v = Number(orderStatus)
if (v === 1) return 'green'
if (v === 2) return 'default'
if (v === 6) return 'default'
if (v === 4 || v === 3 || v === 7) return 'orange'
if (v === 5) return 'red'
return 'blue'
}
async function ensureUser() {
if (currentUserId.value) return
const user = await getUserInfo()
currentUserId.value = user.userId ?? null
}
async function load() {
loading.value = true
error.value = ''
try {
await ensureUser()
const userId = currentUserId.value
if (!userId) {
throw new Error('缺少用户信息,无法查询当前用户订单')
}
const data = await pageShopOrder({
page: page.value,
limit: limit.value,
userId,
keywords: keywords.value?.trim() || undefined,
payStatus: payStatus.value === null ? undefined : payStatus.value,
orderStatus: orderStatus.value === null ? undefined : orderStatus.value
})
list.value = data?.list || []
total.value = data?.count || 0
} catch (e: unknown) {
console.error(e)
list.value = []
total.value = 0
error.value = e instanceof Error ? e.message : '加载订单失败'
message.error(error.value)
} finally {
loading.value = false
}
}
async function reload() {
await load()
}
function doSearch() {
page.value = 1
load()
}
function onPayStatusChange(value: string | number) {
payStatus.value = value === 'all' ? null : Number(value)
page.value = 1
load()
}
function onPageChange(p: number) {
page.value = p
load()
}
function onPageSizeChange(_current: number, size: number) {
limit.value = size
page.value = 1
load()
}
function openDetail(order: ShopOrder) {
selected.value = order
detailOpen.value = true
}
onMounted(() => {
load()
})
</script>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<div class="space-y-4">
<a-page-header title="已购产品" sub-title="订阅与授权信息" />
<a-card :bordered="false" class="card">
<a-empty description="待接入:已购产品列表" />
</a-card>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: 'console' })
</script>
<style scoped>
.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-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="comments">
<a-textarea v-model:value="editForm.comments" :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: '',
comments: '',
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.comments = ''
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.comments = row.comments ?? ''
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,
comments: editForm.comments?.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,40 @@
<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-empty description="待接入:未开通产品列表" />
</a-card>
</div>
</template>
<script setup lang="ts">
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)}`)
}
</script>
<style scoped>
.page-header {
border-radius: 12px;
}
.card {
border-radius: 12px;
}
</style>