初始化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,43 @@
# Console 端工作记忆
## 2026-03-31
### Console 端功能完善 - 5项任务全部完成
1. **优惠券页面** (`coupons.vue`)
- 接入 `shopUserCoupon` API 替换空数组,加载真实优惠券数据
- 实现 `pageShopUserCoupon` 按状态筛选(可用/已使用/已过期)
- 兑换功能:通过 `listShopCoupon` 查找模板 + `addShopUserCoupon` 领取
- 添加 loading 状态stats 改为 computed
2. **应用中心** (`apps.vue`)
- "已购应用" Tab 从硬编码数据改为 `pageShopOrder` API 加载
- `transformOrderToApp` 函数将订单转换为应用卡片格式
- 续费/查看详情/退订按钮改为真实页面跳转market/orders/tickets
3. **账号安全** (`account/security.vue`)
- 新增安全概览卡片(密码强度/登录设备/异常登录)
- 安全建议改为图标+标题+描述的卡片式布局
- 接入 `pageLoginRecords` API 展示最近登录记录表格
- 支持登录类型/设备/浏览器/IP 显示和复制
4. **发票记录** (`invoices.vue`)
- 移除 localStorage 演示提示,改为正式提示文案
- 新增统计概览卡片(全部/已提交/已开具)
- 添加订单关联选择器:`pageShopOrder` 加载已支付订单
- 表格和详情弹窗增加"关联订单"列
- 新增 `orderNo` 字段到表单和记录类型
5. **未开通产品** (`tenant/unopened.vue`)
- 从空壳页面改为完整产品展示页
- 接入 `pageCmsWebsiteAll` API 加载可用产品
- 支持分类 Tab 切换(全部/企业官网/电商系统/小程序/插件)
- 产品卡片展示(图标/名称/描述/价格/标签)
- 立即开通/了解详情按钮跳转到应用商店
- 定制方案引导区域
## 项目 API 模式备忘
- `pageXxx` 返回 `PageResult<T>` (已解包),直接 `.list` `.count`
- `getUserInfo()` 返回 `Promise<User>``userId`
- SSR 守卫:`import.meta.client` 保护 localStorage
- 优惠券有三套 APIshopCoupon(模板)、shopUserCoupon(管理侧)、userCoupon(前台)

View File

@@ -0,0 +1,28 @@
# Console 端长期记忆
## 项目概况
- Nuxt 4 / Vue 3 + Composition API + Ant Design Vue + TypeScript
- `/console` 端为用户控制台,包含订单/优惠券/工单/发票/应用/账号安全等模块
## 关键 API 模式
- `pageXxx` 返回已解包的 `PageResult<T>`,直接用 `.list` `.count`
- `getXxx` 返回解包的单对象
- `getUserInfo()` 返回 `Promise<User>``userId`
- SSR 安全:`import.meta.client` 保护 localStorage 访问
- 用户ID获取优先 `getUserInfo()` APIfallback `localStorage.getItem('UserId')`
## 优惠券相关 API
- `shopCoupon` - 优惠券模板(管理员侧),`/shop/shop-coupon/...`
- `shopUserCoupon` - 用户优惠券实例(管理侧),`/shop/shop-user-coupon/...`
- `userCoupon` - 前台用户侧,`MODULES_API_URL + '/booking/user-coupon/...'`
- Console 端使用 `shopUserCoupon` 系列接口
## 2026-03-31 Console 端完善
- 优惠券页接入真实API + 兑换功能
- 应用中心已购应用从订单API加载
- 账号安全:增加登录日志 + 安全概览
- 发票记录:添加订单关联 + 统计卡片
- 未开通产品:从空壳改为产品展示页
## 用户偏好
- (待补充)

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.description || '').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,
description: ''
})
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.description = next?.description ?? ''
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,
description: form.description
}
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,377 @@
<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" :sm="8" v-for="s in securityOverview" :key="s.label">
<div class="security-overview-card" :class="s.colorClass">
<div class="overview-icon">{{ s.icon }}</div>
<div class="overview-info">
<div class="overview-value">{{ s.value }}</div>
<div class="overview-label">{{ s.label }}</div>
</div>
</div>
</a-col>
</a-row>
<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="安全建议">
<div class="security-tips">
<div v-for="(tip, index) in securityTips" :key="index" class="tip-item">
<div class="tip-icon" :class="tip.level">{{ tip.icon }}</div>
<div class="tip-content">
<div class="tip-title">{{ tip.title }}</div>
<div class="tip-desc">{{ tip.desc }}</div>
</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 登录日志 -->
<a-card :bordered="false" class="card" title="最近登录记录">
<template #extra>
<a-button size="small" @click="loadLoginRecords">刷新</a-button>
</template>
<a-table
:data-source="loginRecords"
:loading="loginLoading"
:pagination="{ pageSize: 5, showTotal: (t: number) => `共 ${t} 条` }"
size="small"
:row-key="(r: any) => r.id"
>
<a-table-column title="时间" data-index="createTime" width="180">
<template #default="{ record }">
{{ formatTime(record.createTime) }}
</template>
</a-table-column>
<a-table-column title="类型" key="loginType" width="120">
<template #default="{ record }">
<a-tag :color="loginTypeColor(record.loginType)" size="small">
{{ loginTypeText(record.loginType) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="设备" key="device" width="160">
<template #default="{ record }">
<span class="text-sm">{{ record.device || record.os || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="浏览器" data-index="browser" width="120" />
<a-table-column title="IP 地址" data-index="ip" width="140">
<template #default="{ record }">
<a-typography-text :copyable="{ text: record.ip || '', tooltips: ['复制', '已复制'] }">
{{ record.ip || '-' }}
</a-typography-text>
</template>
</a-table-column>
<a-table-column title="备注" data-index="description" ellipsis />
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import { updatePassword, getUserInfo } from '@/api/layout'
import { removeToken } from '@/utils/token-util'
import { clearAuthz } from '@/utils/permission'
import { pageLoginRecords } from '@/api/system/login-record'
import type { LoginRecord } from '@/api/system/login-record/model'
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: '请再次输入新密码' }]
})
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')
}
// ─── 安全概览 ─────────────────────────────────────────────────
const securityOverview = computed(() => [
{
icon: '🔐',
label: '密码强度',
value: '基础',
colorClass: 'overview-warn',
},
{
icon: '📱',
label: '登录设备',
value: `${loginRecords.value.length} 次登录`,
colorClass: 'overview-info',
},
{
icon: '⚠️',
label: '异常登录',
value: failedLogins.value > 0 ? `${failedLogins.value}` : '无',
colorClass: failedLogins.value > 0 ? 'overview-danger' : 'overview-success',
},
])
// ─── 安全建议 ─────────────────────────────────────────────────
const securityTips = [
{
icon: '🔑',
level: 'level-info',
title: '定期修改密码',
desc: '建议每 3 个月更换一次密码,避免与其他平台重复使用。',
},
{
icon: '🛡️',
level: 'level-info',
title: '使用强密码',
desc: '密码至少 6 位,建议混合使用大小写字母、数字和特殊字符。',
},
{
icon: '📱',
level: 'level-warn',
title: '关注登录记录',
desc: '定期检查登录日志,如发现异常设备登录请立即修改密码。',
},
{
icon: '🔐',
level: 'level-info',
title: '保护账号安全',
desc: '不要将账号/密码分享给他人,如怀疑账号被盗用请立即修改密码并退出登录。',
},
]
// ─── 登录日志 ─────────────────────────────────────────────────
const loginLoading = ref(false)
const loginRecords = ref<LoginRecord[]>([])
const failedLogins = computed(() => loginRecords.value.filter(r => r.loginType === 1).length)
async function loadLoginRecords() {
loginLoading.value = true
try {
const data = await pageLoginRecords({
page: 1,
limit: 20,
})
loginRecords.value = data?.list || []
} catch (e) {
console.error('加载登录日志失败', e)
loginRecords.value = []
} finally {
loginLoading.value = false
}
}
function loginTypeText(type?: number) {
const map: Record<number, string> = {
0: '登录成功',
1: '登录失败',
2: '退出登录',
3: 'Token 续签',
}
return type !== undefined ? (map[type] || `类型${type}`) : '-'
}
function loginTypeColor(type?: number) {
const map: Record<number, string> = {
0: 'green',
1: 'red',
2: 'default',
3: 'blue',
}
return type !== undefined ? (map[type] || 'default') : 'default'
}
function formatTime(value?: string) {
if (!value) return '-'
try {
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())}`
} catch {
return value
}
}
// ─── 初始化 ──────────────────────────────────────────────────
onMounted(() => {
loadLoginRecords()
})
</script>
<style scoped>
.card {
border-radius: 12px;
}
/* 安全概览 */
.security-overview-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
border-radius: 12px;
border: 1px solid #f0f0f0;
background: #fff;
}
.overview-icon {
font-size: 32px;
flex-shrink: 0;
}
.overview-info { flex: 1; }
.overview-value {
font-size: 18px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
}
.overview-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
.overview-info { background: #f9fafb; border-color: #e5e7eb; }
.overview-warn { background: #fffbeb; border-color: #fde68a; }
.overview-info { background: #eff6ff; border-color: #bfdbfe; }
.overview-success { background: #f0fdf4; border-color: #bbf7d0; }
.overview-danger { background: #fef2f2; border-color: #fecaca; }
/* 安全建议 */
.security-tips {
display: flex;
flex-direction: column;
gap: 14px;
}
.tip-item {
display: flex;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
background: #fafafa;
border: 1px solid #f0f0f0;
}
.tip-icon {
font-size: 22px;
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
}
.tip-icon.level-info { background: #eff6ff; }
.tip-icon.level-warn { background: #fff7ed; }
.tip-content { flex: 1; }
.tip-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 2px;
}
.tip-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,622 @@
<template>
<div class="review-page">
<!-- 页面头部 -->
<div class="page-header">
<div>
<h2 class="page-title">🔍 应用审核管理</h2>
<p class="page-desc">审核开发者提交的应用上架申请</p>
</div>
<a-space>
<a-button @click="loadApps" :loading="loading">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 状态统计 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="12" :md="6" v-for="stat in reviewStats" :key="stat.key">
<div
class="stat-card"
:class="[stat.color, { active: filterStatus === stat.key }]"
@click="handleStatFilter(stat.key)"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<!-- 筛选栏 -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 审核列表</span>
<a-space>
<a-select
v-model:value="filterStatus"
style="width: 140px"
@change="loadApps"
>
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending_review">待审核</a-select-option>
<a-select-option value="published">已上架</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
<a-select-option value="deprecated">已下架</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索应用名称"
style="width: 200px"
@search="loadApps"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="apps"
:loading="loading"
:pagination="pagination"
row-key="productId"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<!-- 应用信息 -->
<template v-if="column.key === 'appInfo'">
<div class="app-info-cell">
<img v-if="record.icon" :src="record.icon" class="app-icon" />
<div v-else class="app-icon-placeholder" :style="{ background: iconBgColor(record.productName) }">
{{ (record.productName || 'A').charAt(0).toUpperCase() }}
</div>
<div class="app-info-text">
<div class="app-name">{{ record.productName }}</div>
<div class="app-code">{{ record.productCode }}</div>
<div class="app-developer" v-if="record.developer">
<UserOutlined style="font-size:11px;margin-right:3px" />{{ record.developer }}
</div>
</div>
</div>
</template>
<!-- 发布状态 -->
<template v-if="column.key === 'publishStatus'">
<a-tag :color="statusColor(record.publishStatus)">
{{ statusText(record.publishStatus) }}
</a-tag>
</template>
<!-- 定价 -->
<template v-if="column.key === 'price'">
<span v-if="record.priceType === 'free' || !record.priceType" class="price-free">免费</span>
<span v-else class="price-paid">¥{{ ((record.price || 0) / 100).toFixed(2) }}
<span class="price-period">{{ subscriptionText(record.subscriptionPeriod) }}</span>
</span>
</template>
<!-- 申请时间 -->
<template v-if="column.key === 'applyTime'">
<span class="text-sm text-gray-500">{{ record.publishTime || record.updateTime || '-' }}</span>
</template>
<!-- 操作 -->
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewDetail(record)">详情</a-button>
<!-- 待审核通过/拒绝 -->
<template v-if="record.publishStatus === 'pending_review'">
<a-popconfirm title="确认通过此应用上架申请?" @confirm="handleApprove(record)">
<a-button type="primary" size="small">通过</a-button>
</a-popconfirm>
<a-button danger size="small" @click="handleReject(record)">拒绝</a-button>
</template>
<!-- 已上架下架 -->
<a-popconfirm
v-if="record.publishStatus === 'published'"
title="确认下架此应用?"
@confirm="handleAdminUnpublish(record)"
>
<a-button danger size="small">下架</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 审核详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:title="`应用详情:${currentApp?.productName || ''}`"
width="700px"
:footer="null"
>
<template v-if="currentApp">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="应用名称">{{ currentApp.productName }}</a-descriptions-item>
<a-descriptions-item label="应用标识">{{ currentApp.productCode }}</a-descriptions-item>
<a-descriptions-item label="开发者">{{ currentApp.developer || '-' }}</a-descriptions-item>
<a-descriptions-item label="发布状态">
<a-tag :color="statusColor(currentApp.publishStatus)">{{ statusText(currentApp.publishStatus) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="定价模式">{{ priceTypeText(currentApp.priceType) }}</a-descriptions-item>
<a-descriptions-item label="价格">
<span v-if="currentApp.priceType === 'free' || !currentApp.priceType">免费</span>
<span v-else>¥{{ ((currentApp.price || 0) / 100).toFixed(2) }}</span>
</a-descriptions-item>
<a-descriptions-item label="提交时间" :span="2">{{ currentApp.publishTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="应用简介" :span="2">
{{ currentApp.description || '-' }}
</a-descriptions-item>
<a-descriptions-item label="详细说明" :span="2">
<div class="detail-desc">{{ currentApp.content || '-' }}</div>
</a-descriptions-item>
<a-descriptions-item v-if="currentApp.rejectReason" label="拒绝原因" :span="2">
<a-alert type="error" :message="currentApp.rejectReason" show-icon />
</a-descriptions-item>
</a-descriptions>
<!-- 待审核时的操作区 -->
<div v-if="currentApp.publishStatus === 'pending_review'" class="detail-actions">
<a-popconfirm title="确认通过此应用上架申请?" @confirm="handleApprove(currentApp)">
<a-button type="primary" :loading="approving"> 审核通过</a-button>
</a-popconfirm>
<a-button danger @click="handleReject(currentApp)"> 拒绝上架</a-button>
</div>
</template>
</a-modal>
<!-- 拒绝原因弹窗 -->
<a-modal
v-model:open="showRejectModal"
title="填写拒绝原因"
:confirm-loading="rejecting"
@ok="confirmReject"
@cancel="showRejectModal = false"
>
<a-form layout="vertical">
<a-form-item label="拒绝原因" required>
<a-textarea
v-model:value="rejectReasonInput"
:rows="4"
placeholder="请填写具体的拒绝原因,以便开发者修改后重新提交"
:maxlength="500"
show-count
/>
</a-form-item>
<div class="reject-tips">
<p>💡 常见拒绝原因</p>
<a-space wrap>
<a-tag
v-for="tip in rejectTips"
:key="tip"
class="reject-tip-tag"
@click="rejectReasonInput = tip"
>{{ tip }}</a-tag>
</a-space>
</div>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined, UserOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
pagePublishReviews,
approvePublishReview,
rejectPublishReview,
unpublishAppProduct,
} from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
definePageMeta({ layout: 'console' })
useHead({ title: '应用审核管理 - 控制台' })
// 加载状态
const loading = ref(false)
const apps = ref<AppProduct[]>([])
// 筛选
const filterStatus = ref('pending_review')
const searchKeyword = ref('')
// 分页
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
// 统计
const reviewStats = reactive([
{ key: 'pending_review', icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 'published', icon: '✅', label: '已上架', value: 0, color: 'green' },
{ key: 'rejected', icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: '', icon: '📦', label: '全部应用', value: 0, color: 'blue' },
])
// 表格列
const columns = [
{ title: '应用信息', key: 'appInfo', width: 260 },
{ title: '审核状态', key: 'publishStatus', width: 110 },
{ title: '定价', key: 'price', width: 130 },
{ title: '提交时间', key: 'applyTime', width: 160 },
{ title: '操作', key: 'action', width: 200 },
]
// 详情弹窗
const showDetailModal = ref(false)
const currentApp = ref<AppProduct | null>(null)
const approving = ref(false)
// 拒绝弹窗
const showRejectModal = ref(false)
const rejectReasonInput = ref('')
const rejecting = ref(false)
const rejectTargetApp = ref<AppProduct | null>(null)
const rejectTips = [
'功能描述不完整,缺少使用说明文档',
'应用简介过于简单,请补充详细功能介绍',
'应用名称与实际功能不符',
'价格设置不合理,请重新评估',
'存在违规内容,请修改后重新提交',
'截图不清晰或与功能描述不符',
]
// 加载审核列表
async function loadApps() {
loading.value = true
try {
const res = await pagePublishReviews({
page: pagination.current,
limit: pagination.pageSize,
publishStatus: filterStatus.value || undefined,
keywords: searchKeyword.value || undefined,
})
apps.value = res?.list || []
pagination.total = res?.count || 0
updateStats()
} catch {
message.error('加载审核列表失败')
} finally {
loading.value = false
}
}
// 更新统计(单独加载全量统计)
async function updateStats() {
try {
const [pendingRes, publishedRes, rejectedRes, allRes] = await Promise.allSettled([
pagePublishReviews({ page: 1, limit: 1, publishStatus: 'pending_review' }),
pagePublishReviews({ page: 1, limit: 1, publishStatus: 'published' }),
pagePublishReviews({ page: 1, limit: 1, publishStatus: 'rejected' }),
pagePublishReviews({ page: 1, limit: 1 }),
])
if (pendingRes.status === 'fulfilled') reviewStats[0].value = pendingRes.value?.count || 0
if (publishedRes.status === 'fulfilled') reviewStats[1].value = publishedRes.value?.count || 0
if (rejectedRes.status === 'fulfilled') reviewStats[2].value = rejectedRes.value?.count || 0
if (allRes.status === 'fulfilled') reviewStats[3].value = allRes.value?.count || 0
} catch { /* ignore */ }
}
// 统计卡片点击筛选
function handleStatFilter(key: string) {
filterStatus.value = key
pagination.current = 1
loadApps()
}
// 分页变化
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadApps()
}
// 查看详情
function handleViewDetail(record: AppProduct) {
currentApp.value = record
showDetailModal.value = true
}
// 审核通过
async function handleApprove(record: AppProduct) {
approving.value = true
try {
await approvePublishReview(record.productId!)
message.success(`${record.productName}」已通过审核并上架`)
showDetailModal.value = false
loadApps()
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
approving.value = false
}
}
// 打开拒绝弹窗
function handleReject(record: AppProduct) {
rejectTargetApp.value = record
rejectReasonInput.value = ''
showRejectModal.value = true
}
// 确认拒绝
async function confirmReject() {
if (!rejectReasonInput.value.trim()) {
message.warning('请填写拒绝原因')
return
}
if (!rejectTargetApp.value) return
rejecting.value = true
try {
await rejectPublishReview({
productId: rejectTargetApp.value.productId!,
rejectReason: rejectReasonInput.value,
})
message.success('已拒绝并通知开发者')
showRejectModal.value = false
showDetailModal.value = false
loadApps()
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
rejecting.value = false
}
}
// 管理员下架
async function handleAdminUnpublish(record: AppProduct) {
try {
await unpublishAppProduct(record.productId!)
message.success(`${record.productName}」已下架`)
loadApps()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
// 状态相关
function statusText(status?: string) {
const map: Record<string, string> = {
developing: '开发中',
pending_review: '待审核',
published: '已上架',
rejected: '已拒绝',
deprecated: '已下架',
}
return map[status || ''] || '开发中'
}
function statusColor(status?: string) {
const map: Record<string, string> = {
developing: 'default',
pending_review: 'orange',
published: 'success',
rejected: 'error',
deprecated: 'default',
}
return map[status || ''] || 'default'
}
function priceTypeText(type?: string) {
const map: Record<string, string> = {
free: '免费',
one_time: '一次性付费',
subscription: '订阅制',
}
return map[type || ''] || '免费'
}
function subscriptionText(period?: string) {
if (period === 'month') return '/月'
if (period === 'year') return '/年'
return ''
}
const PALETTE = ['#4e6ef2', '#f4a261', '#e76f51', '#2a9d8f', '#e9c46a', '#457b9d']
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]
}
onMounted(() => {
loadApps()
})
</script>
<style scoped>
.review-page {
min-height: 100%;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0;
line-height: 1.4;
}
.page-desc {
font-size: 13px;
color: #9ca3af;
margin: 2px 0 0;
}
/* 统计卡片 */
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-card.active { border-color: currentColor; box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-card.active.blue { border-color: #3b82f6; }
.stat-card.active.orange { border-color: #f97316; }
.stat-card.active.green { border-color: #22c55e; }
.stat-card.active.red { border-color: #ef4444; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value {
font-size: 22px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
line-height: 1.2;
}
.stat-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
/* 面板 */
.panel {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
/* 应用信息格 */
.app-info-cell {
display: flex;
align-items: center;
gap: 12px;
}
.app-icon {
width: 44px;
height: 44px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
}
.app-icon-placeholder {
width: 44px;
height: 44px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
}
.app-info-text { flex: 1; min-width: 0; }
.app-name {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
.app-code {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.app-developer {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
/* 价格 */
.price-free { color: #22c55e; font-weight: 500; font-size: 13px; }
.price-paid { color: #f59e0b; font-weight: 600; font-size: 14px; }
.price-period { font-size: 11px; color: rgba(0,0,0,0.45); font-weight: 400; margin-left: 2px; }
/* 详情弹窗 */
.detail-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.detail-desc {
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* 拒绝原因提示 */
.reject-tips {
margin-top: 12px;
padding: 12px;
background: #fafafa;
border-radius: 8px;
}
.reject-tips p {
font-size: 12px;
color: rgba(0,0,0,0.45);
margin: 0 0 8px;
}
.reject-tip-tag {
cursor: pointer;
transition: all 0.15s;
}
.reject-tip-tag:hover {
color: #4f46e5;
border-color: #4f46e5;
}
.mb-6 { margin-bottom: 24px; }
.text-sm { font-size: 12px; }
.text-gray-500 { color: rgba(0,0,0,0.45); }
</style>

781
app/pages/console/apps.vue Normal file
View File

@@ -0,0 +1,781 @@
<template>
<div>
<!-- 页面头部 -->
<div class="page-header">
<div>
<h2 class="page-title">应用中心</h2>
<p class="page-desc">管理你订阅的所有应用快速进入后台</p>
</div>
<a-space>
<a-button @click="navigateTo('/market')">
<template #icon><ShopOutlined /></template>
浏览应用商店
</a-button>
</a-space>
</div>
<!-- Tab 切换 -->
<a-tabs v-model:activeKey="activeTab" class="apps-tabs" @change="handleTabChange">
<a-tab-pane key="my-apps" tab="我的应用">
<AppsCenter :user-id="userId" />
</a-tab-pane>
<a-tab-pane key="purchased" tab="已购应用">
<div class="purchased-section">
<!-- 筛选栏 -->
<div class="filter-bar">
<a-input-search
v-model:value="purchasedSearch"
placeholder="搜索已购应用"
style="width: 300px"
@search="loadPurchasedApps"
/>
<a-select v-model:value="purchasedFilter" style="width: 140px" placeholder="全部状态">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="active">生效中</a-select-option>
<a-select-option value="expired">已过期</a-select-option>
<a-select-option value="cancelled">已取消</a-select-option>
</a-select>
</div>
<!-- 已购应用列表 -->
<a-table
:columns="purchasedColumns"
:data-source="filteredPurchasedApps"
:loading="purchasedLoading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'appInfo'">
<div class="app-info-cell">
<img v-if="record.appIcon" :src="record.appIcon" class="app-icon" />
<div v-else class="app-icon-placeholder" :style="{ background: iconBgColor(record.appName) }">
{{ (record.appName || 'A').charAt(0).toUpperCase() }}
</div>
<div class="app-info-text">
<div class="app-name">{{ record.appName }}</div>
<div class="app-developer">开发者{{ record.developerName }}</div>
</div>
</div>
</template>
<template v-if="column.key === 'subscription'">
<div class="subscription-cell">
<div class="price-type">
<span v-if="record.priceType === 'free'" class="text-green-500">免费</span>
<span v-else class="text-orange-500">¥{{ (record.price || 0) / 100 }}</span>
<a-tag size="small" class="ml-2">{{ priceTypeText(record.priceType) }}</a-tag>
</div>
<div class="expire-time" v-if="record.endTime">
到期{{ record.endTime }}
</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="subscriptionStatusColor(record.status)">
{{ subscriptionStatusText(record.status) }}
</a-tag>
<a-switch
v-if="record.status === 'active'"
v-model:checked="record.enabled"
size="small"
class="ml-2"
@change="(val) => handleToggleApp(record, val)"
/>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="primary" size="small" @click="handleEnterApp(record)">
进入应用
</a-button>
<a-button size="small" @click="handleConfig(record)">
配置
</a-button>
<a-dropdown>
<a-button size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleRenew(record)">续费</a-menu-item>
<a-menu-item @click="handleViewDetail(record)">查看详情</a-menu-item>
<a-menu-divider />
<a-menu-item danger @click="handleUnsubscribe(record)">退订</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
</template>
</a-table>
<!-- 空状态 -->
<div v-if="filteredPurchasedApps.length === 0 && !purchasedLoading" 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>
</a-tab-pane>
<a-tab-pane key="store" tab="应用商店">
<div class="store-section">
<!-- 快捷入口卡片 -->
<div class="quick-cards">
<div class="quick-card" @click="navigateTo('/market')">
<div class="quick-card-icon blue">🛒</div>
<div class="quick-card-content">
<div class="quick-card-title">应用市场</div>
<div class="quick-card-desc">浏览和购买各类应用</div>
</div>
<RightOutlined class="quick-card-arrow" />
</div>
<div class="quick-card" @click="navigateTo('/market?type=plugin')">
<div class="quick-card-icon purple">🔌</div>
<div class="quick-card-content">
<div class="quick-card-title">插件中心</div>
<div class="quick-card-desc">扩展功能的插件</div>
</div>
<RightOutlined class="quick-card-arrow" />
</div>
<div class="quick-card" @click="navigateTo('/developer')">
<div class="quick-card-icon green">🛠</div>
<div class="quick-card-content">
<div class="quick-card-title">开发者中心</div>
<div class="quick-card-desc">开发自己的应用</div>
</div>
<RightOutlined class="quick-card-arrow" />
</div>
</div>
<!-- 推荐应用 -->
<div class="recommend-section">
<div class="section-header">
<h3> 推荐应用</h3>
<a-button type="link" @click="navigateTo('/market')">查看更多</a-button>
</div>
<div class="recommend-apps">
<div
v-for="app in recommendApps"
:key="app.productId"
class="recommend-card"
@click="navigateTo(`/market?app=${app.productId}`)"
>
<img v-if="app.icon" :src="app.icon" class="recommend-icon" />
<div v-else class="recommend-icon-placeholder">{{ (app.productName || 'A').charAt(0) }}</div>
<div class="recommend-info">
<div class="recommend-name">{{ app.productName }}</div>
<div class="recommend-desc">{{ app.description || '暂无描述' }}</div>
</div>
<div class="recommend-price">
<span v-if="app.priceType === 'free'" class="price-free">免费</span>
<span v-else class="price-paid">¥{{ (app.price || 0) / 100 }}</span>
</div>
</div>
</div>
</div>
</div>
</a-tab-pane>
</a-tabs>
<!-- 应用配置弹窗 -->
<a-modal
v-model:open="configModalVisible"
title="应用配置"
width="600px"
@ok="handleSaveConfig"
@cancel="configModalVisible = false"
>
<a-form :model="configForm" layout="vertical">
<a-form-item label="应用名称">
<a-input v-model:value="configForm.appName" disabled />
</a-form-item>
<a-form-item label="启用状态">
<a-switch v-model:checked="configForm.enabled" />
</a-form-item>
<a-form-item label="自动续费">
<a-switch v-model:checked="configForm.autoRenew" />
</a-form-item>
<a-form-item label="配置参数">
<a-textarea v-model:value="configForm.config" :rows="5" placeholder="JSON 格式的配置参数" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ShopOutlined, RightOutlined, MoreOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import AppsCenter from '@/components/console/AppsCenter.vue'
import { pageAppProduct } from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
import { getUserInfo } from '@/api/layout'
import { pageShopOrder } from '@/api/shop/shopOrder'
import type { ShopOrder } from '@/api/shop/shopOrder/model'
definePageMeta({ layout: 'console' })
// ─── 用户信息 ────────────────────────────────────────────────
const userId = ref<string | null>(import.meta.client ? localStorage.getItem('UserId') : null)
const userIdNum = computed(() => (userId.value ? Number(userId.value) : 0))
async function ensureUser() {
if (userIdNum.value) return
try {
const user = await getUserInfo()
const uid = user.userId
if (uid) {
userId.value = String(uid)
}
} catch {}
}
// Tab 状态
const activeTab = ref('my-apps')
// 已购应用状态
const purchasedLoading = ref(false)
const purchasedSearch = ref('')
const purchasedFilter = ref('')
// 推荐应用
const recommendApps = ref<AppProduct[]>([])
// ─── 已购应用数据从订单API加载 ──────────────────────────
interface PurchasedApp {
id: number
orderId: number
orderNo: string
appName: string
appIcon?: string
developerName: string
priceType: 'free' | 'one_time' | 'subscription'
price: number
status: 'active' | 'expired' | 'cancelled'
enabled: boolean
startTime: string
endTime?: string
autoRenew: boolean
config?: string
payPrice?: string
}
const purchasedApps = ref<PurchasedApp[]>([])
// 产品名称映射
const productCatalog: Record<string, string> = {
website: '企业官网',
shop: '电商系统',
mp: '小程序/公众号',
}
function resolveProductCode(order?: ShopOrder | null) {
if (!order?.description) return ''
try {
const extra = JSON.parse(order.description)
return typeof extra?.product === 'string' ? extra.product.trim() : ''
} catch {
return ''
}
}
function resolveProductName(order?: ShopOrder | null) {
const code = resolveProductCode(order)
if (code && productCatalog[code]) return productCatalog[code]
if (code) return code
return '应用服务'
}
function resolveMonths(order?: ShopOrder | null) {
if (!order?.description) return 0
try {
const extra = JSON.parse(order.description)
return typeof extra?.months === 'number' ? extra.months : 0
} catch {
return 0
}
}
function resolveTenantName(order?: ShopOrder | null) {
if (!order?.description) return ''
try {
const extra = JSON.parse(order.description)
return typeof extra?.tenantName === 'string' ? extra.tenantName.trim() : ''
} catch {
return ''
}
}
function isExpired(expirationTime?: string) {
if (!expirationTime) return false
return new Date(expirationTime).getTime() < Date.now()
}
function determinePriceType(order: ShopOrder): 'free' | 'one_time' | 'subscription' {
const months = resolveMonths(order)
if (Number(order.payType) === 12 || Number(order.payPrice) === 0) return 'free'
if (months > 0) return 'subscription'
return 'one_time'
}
function transformOrderToApp(order: ShopOrder): PurchasedApp {
const expired = isExpired(order.expirationTime)
const cancelled = Number(order.orderStatus) === 2 || Number(order.orderStatus) === 3
return {
id: order.orderId || 0,
orderId: order.orderId || 0,
orderNo: order.orderNo || '',
appName: resolveProductName(order),
developerName: resolveTenantName(order) || '官方',
priceType: determinePriceType(order),
price: Number(order.payPrice || order.totalPrice || 0),
status: cancelled ? 'cancelled' : expired ? 'expired' : 'active',
enabled: !expired && !cancelled && Number(order.orderStatus) === 1,
startTime: order.createTime ? String(order.createTime).replace(/T.*/, '') : '',
endTime: order.expirationTime ? String(order.expirationTime).replace(/T.*/, '') : undefined,
autoRenew: resolveMonths(order) > 1,
payPrice: order.payPrice || order.totalPrice,
}
}
// 表格列
const purchasedColumns = [
{ title: '应用信息', key: 'appInfo', width: 280 },
{ title: '订阅信息', key: 'subscription', width: 200 },
{ title: '状态', key: 'status', width: 120 },
{ title: '操作', key: 'action', width: 200 },
]
// 配置弹窗
const configModalVisible = ref(false)
const configForm = reactive({
appName: '',
enabled: true,
autoRenew: false,
config: '',
})
// 计算属性
const filteredPurchasedApps = computed(() => {
let result = [...purchasedApps.value]
if (purchasedFilter.value) {
result = result.filter(app => app.status === purchasedFilter.value)
}
if (purchasedSearch.value) {
const kw = purchasedSearch.value.toLowerCase()
result = result.filter(app =>
app.appName.toLowerCase().includes(kw) ||
app.developerName.toLowerCase().includes(kw)
)
}
return result
})
// 加载推荐应用
async function loadRecommendApps() {
try {
const res = await pageAppProduct({
page: 1,
limit: 4,
status: 1,
})
recommendApps.value = res.list || []
} catch (e) {
console.error('加载推荐应用失败', e)
}
}
// 加载已购应用从订单API
async function loadPurchasedApps() {
purchasedLoading.value = true
try {
await ensureUser()
const uid = userIdNum.value
if (!uid) {
purchasedApps.value = []
return
}
// 加载已支付且已完成的订单,作为已购应用列表
const data = await pageShopOrder({
page: 1,
limit: 100,
userId: uid,
payStatus: 1, // 已支付
})
const list = data?.list || []
purchasedApps.value = list.map(transformOrderToApp)
} catch (e) {
console.error('加载已购应用失败', e)
purchasedApps.value = []
} finally {
purchasedLoading.value = false
}
}
// 处理函数
function handleTabChange(key: string) {
if (key === 'store') {
loadRecommendApps()
} else if (key === 'purchased') {
loadPurchasedApps()
}
}
function priceTypeText(type: string) {
const map: Record<string, string> = {
free: '免费',
one_time: '一次性',
subscription: '订阅',
}
return map[type] || type
}
function subscriptionStatusColor(status: string) {
const map: Record<string, string> = {
active: 'success',
expired: 'error',
cancelled: 'default',
}
return map[status] || 'default'
}
function subscriptionStatusText(status: string) {
const map: Record<string, string> = {
active: '生效中',
expired: '已过期',
cancelled: '已取消',
}
return map[status] || status
}
function iconBgColor(name?: string) {
const PALETTE = ['#4e6ef2', '#f4a261', '#e76f51', '#2a9d8f', '#e9c46a']
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]
}
function handleToggleApp(record: PurchasedApp, enabled: boolean) {
message.success(`${record.appName}${enabled ? '启用' : '禁用'}`)
}
function handleEnterApp(record: PurchasedApp) {
navigateTo('/market')
}
function handleConfig(record: PurchasedApp) {
configForm.appName = record.appName
configForm.enabled = record.enabled
configForm.autoRenew = record.autoRenew
configForm.config = record.config || '{}'
configModalVisible.value = true
}
function handleSaveConfig() {
message.success('配置保存成功')
configModalVisible.value = false
}
function handleRenew(record: PurchasedApp) {
navigateTo('/market')
}
function handleViewDetail(record: PurchasedApp) {
navigateTo('/console/orders')
}
function handleUnsubscribe(record: PurchasedApp) {
navigateTo('/tickets')
}
onMounted(() => {
ensureUser()
loadRecommendApps()
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0;
line-height: 1.4;
}
.page-desc {
font-size: 13px;
color: #9ca3af;
margin: 2px 0 0;
}
/* Tab 样式 */
.apps-tabs :deep(.ant-tabs-nav) {
margin-bottom: 20px;
}
/* 已购应用 */
.purchased-section {
background: #fff;
border-radius: 8px;
padding: 20px;
}
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.app-info-cell {
display: flex;
align-items: center;
gap: 12px;
}
.app-icon {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: cover;
}
.app-icon-placeholder {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
color: #fff;
}
.app-info-text {
flex: 1;
min-width: 0;
}
.app-name {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
.app-developer {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.subscription-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.expire-time {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
/* 应用商店 */
.store-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.quick-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.quick-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.quick-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #d9d9d9;
}
.quick-card-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
.quick-card-icon.blue { background: #eff6ff; }
.quick-card-icon.purple { background: #f5f3ff; }
.quick-card-icon.green { background: #f0fdf4; }
.quick-card-content {
flex: 1;
min-width: 0;
}
.quick-card-title {
font-size: 15px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
}
.quick-card-desc {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.quick-card-arrow {
color: rgba(0, 0, 0, 0.25);
transition: transform 0.2s;
}
.quick-card:hover .quick-card-arrow {
transform: translateX(4px);
color: rgba(0, 0, 0, 0.45);
}
/* 推荐应用 */
.recommend-section {
background: #fff;
border-radius: 12px;
padding: 20px;
border: 1px solid #f0f0f0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.recommend-apps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.recommend-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fafafa;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.recommend-card:hover {
background: #f0f0f0;
}
.recommend-icon {
width: 44px;
height: 44px;
border-radius: 10px;
object-fit: cover;
}
.recommend-icon-placeholder {
width: 44px;
height: 44px;
border-radius: 10px;
background: #4e6ef2;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
}
.recommend-info {
flex: 1;
min-width: 0;
}
.recommend-name {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recommend-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recommend-price {
flex-shrink: 0;
}
.price-free {
color: #22c55e;
font-weight: 500;
}
.price-paid {
color: #f59e0b;
font-weight: 600;
}
/* 空状态 */
.empty-state {
padding: 60px 20px;
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.ml-2 { margin-left: 8px; }
</style>

View File

@@ -0,0 +1,658 @@
<template>
<div class="space-y-4">
<a-page-header title="优惠券" sub-title="可用优惠与使用记录">
<template #extra>
<a-input
v-model:value="codeInput"
placeholder="输入兑换码"
class="w-48"
allow-clear
>
<template #suffix>
<a-button type="link" size="small" style="padding: 0" :loading="redeeming" @click="handleRedeem">
兑换
</a-button>
</template>
</a-input>
</template>
</a-page-header>
<!-- 统计概要 -->
<a-row :gutter="[16, 16]">
<a-col :xs="8" :md="4" v-for="stat in stats" :key="stat.label">
<div class="mini-stat" :class="stat.color">
<div class="mini-stat-value">{{ stat.value }}</div>
<div class="mini-stat-label">{{ stat.label }}</div>
</div>
</a-col>
</a-row>
<a-card :bordered="false" class="card">
<a-spin :spinning="loading">
<!-- Tab 切换 -->
<a-tabs v-model:activeKey="activeTab" @change="onTabChange">
<a-tab-pane key="available" tab="可用优惠券" />
<a-tab-pane key="used" tab="已使用" />
<a-tab-pane key="expired" tab="已过期" />
</a-tabs>
<!-- 可用优惠券列表 -->
<div v-if="activeTab === 'available'" class="coupon-list">
<div v-if="availableCoupons.length === 0" class="coupon-empty">
<a-empty description="暂无可用优惠券">
<template #image>
<div class="empty-icon">🎫</div>
</template>
</a-empty>
</div>
<div v-else class="coupon-grid">
<div
v-for="coupon in availableCoupons"
:key="coupon.id"
class="coupon-card"
:class="coupon.typeClass"
>
<div class="coupon-left">
<div class="coupon-amount">
<span class="coupon-prefix">{{ coupon.discountType === 'percent' ? '' : '¥' }}</span>
<span class="coupon-number">{{ coupon.amount }}</span>
<span class="coupon-suffix" v-if="coupon.discountType === 'percent'">%</span>
</div>
<div class="coupon-condition">{{ coupon.condition }}</div>
</div>
<div class="coupon-divider">
<div class="divider-circle top" />
<div class="divider-line" />
<div class="divider-circle bottom" />
</div>
<div class="coupon-right">
<div class="coupon-name">{{ coupon.name }}</div>
<div class="coupon-scope">{{ coupon.scope }}</div>
<div class="coupon-expire">
<ClockCircleOutlined style="font-size: 11px; margin-right: 3px" />
{{ coupon.expireText }}
</div>
<a-button
size="small"
class="coupon-use-btn"
@click="handleUse(coupon)"
>
立即使用
</a-button>
</div>
</div>
</div>
</div>
<!-- 已使用列表 -->
<div v-if="activeTab === 'used'" class="coupon-list">
<div v-if="usedCoupons.length === 0" class="coupon-empty">
<a-empty description="暂无使用记录" />
</div>
<a-table
v-else
:data-source="usedCoupons"
:pagination="false"
size="middle"
:row-key="(r: any) => r.id"
>
<a-table-column title="优惠券" key="name" width="200">
<template #default="{ record }">
<span class="used-name">{{ record.name }}</span>
</template>
</a-table-column>
<a-table-column title="面额" key="amount" width="120">
<template #default="{ record }">
<span class="used-amount">
{{ record.discountType === 'percent' ? `${record.amount}%` : `¥${record.amount}` }}
</span>
</template>
</a-table-column>
<a-table-column title="适用范围" key="scope" width="180">
<template #default="{ record }">
{{ record.scope }}
</template>
</a-table-column>
<a-table-column title="使用时间" key="usedAt" width="180">
<template #default="{ record }">
{{ record.usedAt || '-' }}
</template>
</a-table-column>
<a-table-column title="关联订单" key="orderNo" width="200">
<template #default="{ record }">
<span v-if="record.orderNo" class="text-gray-500">{{ record.orderNo }}</span>
<span v-else>-</span>
</template>
</a-table-column>
</a-table>
</div>
<!-- 已过期列表 -->
<div v-if="activeTab === 'expired'" class="coupon-list">
<div v-if="expiredCoupons.length === 0" class="coupon-empty">
<a-empty description="暂无过期优惠券" />
</div>
<div v-else class="expired-grid">
<div v-for="coupon in expiredCoupons" :key="coupon.id" class="expired-card">
<div class="expired-left">
<div class="expired-amount">
{{ coupon.discountType === 'percent' ? `${coupon.amount}%` : `¥${coupon.amount}` }}
</div>
</div>
<div class="expired-right">
<div class="expired-name">{{ coupon.name }}</div>
<div class="expired-reason">已过期 · {{ coupon.expireText }}</div>
</div>
</div>
</div>
</div>
<!-- 引导提示 -->
<div v-if="activeTab === 'available'" class="guide-banner mt-4">
<div class="guide-icon">🎁</div>
<div class="guide-text">
<div class="guide-title">还没有优惠券?</div>
<div class="guide-desc">参与活动、充值会员或关注公众号获取更多优惠</div>
</div>
<a-button type="link" @click="navigateTo('/market')">
前往应用商店
</a-button>
</div>
</a-spin>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { ClockCircleOutlined, GiftOutlined } from '@ant-design/icons-vue'
import { getUserInfo } from '@/api/layout'
import { pageShopUserCoupon, listShopUserCoupon, addShopUserCoupon } from '@/api/shop/shopUserCoupon'
import { listShopCoupon } from '@/api/shop/shopCoupon'
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
import type { ShopCoupon } from '@/api/shop/shopCoupon/model'
definePageMeta({ layout: 'console' })
// ─── 状态 ────────────────────────────────────────────────────
const loading = ref(false)
const activeTab = ref('available')
const codeInput = ref('')
const redeeming = ref(false)
const currentUserId = ref<number | null>(null)
// ─── 优惠券类型映射 ──────────────────────────────────────────
const TYPE_MAP: Record<number, { discountType: 'fixed' | 'percent'; amount: number; typeClass: string }> = {
10: { discountType: 'fixed', amount: 0, typeClass: '' }, // 满减券
20: { discountType: 'percent', amount: 0, typeClass: 'blue' }, // 折扣券
30: { discountType: 'fixed', amount: 0, typeClass: 'green' }, // 免费券
}
const SCOPE_MAP: Record<number, string> = {
10: '全部商品',
20: '指定商品',
30: '指定分类',
}
// ─── 数据转换 ────────────────────────────────────────────────
function transformCoupon(raw: ShopUserCoupon) {
const typeInfo = TYPE_MAP[raw.type || 10] || TYPE_MAP[10]
let amount = typeInfo.amount
if (raw.type === 10 && raw.reducePrice) {
amount = Number(raw.reducePrice)
} else if (raw.type === 20 && raw.discount) {
amount = raw.discount
}
const minPrice = raw.minPrice ? Number(raw.minPrice) : 0
const condition = minPrice > 0 ? `${minPrice}元可用` : '无门槛'
const scope = SCOPE_MAP[raw.applyRange || 10] || '全部商品'
const expireText = formatExpire(raw.startTime, raw.endTime)
return {
id: String(raw.id || ''),
name: raw.name || '优惠券',
amount,
discountType: typeInfo.discountType,
condition,
scope,
expireText,
typeClass: typeInfo.typeClass,
usedAt: raw.useTime || '',
orderNo: raw.orderNo || '',
status: raw.status as number,
}
}
function formatExpire(start?: string, end?: string) {
if (!end) return '永久有效'
const e = new Date(end)
const now = new Date()
const diff = e.getTime() - now.getTime()
const days = Math.ceil(diff / 86400000)
if (days <= 0) return '已过期'
if (days <= 7) return `${days}天后过期`
if (days <= 30) return `${Math.ceil(days / 7)}周后过期`
return end.replace(/T.*/, '')
}
// ─── 数据列表 ────────────────────────────────────────────────
const allCoupons = ref<ReturnType<typeof transformCoupon>[]>([])
const availableCoupons = computed(() => allCoupons.value.filter(c => c.status === 0))
const usedCoupons = computed(() => allCoupons.value.filter(c => c.status === 1))
const expiredCoupons = computed(() => allCoupons.value.filter(c => c.status === 2))
const stats = computed(() => [
{ label: '可用', value: String(availableCoupons.value.length), color: 'green' },
{ label: '已使用', value: String(usedCoupons.value.length), color: 'blue' },
{ label: '已过期', value: String(expiredCoupons.value.length), color: 'gray' },
])
// ─── 加载数据 ────────────────────────────────────────────────
async function ensureUser() {
if (currentUserId.value) return
try {
const user = await getUserInfo()
currentUserId.value = user.userId ?? null
} catch {
// fallback to localStorage
if (import.meta.client) {
const uid = localStorage.getItem('UserId')
if (uid) currentUserId.value = Number(uid)
}
}
}
async function loadCoupons() {
loading.value = true
try {
await ensureUser()
const userId = currentUserId.value
if (!userId) {
message.warning('请先登录')
return
}
const res = await pageShopUserCoupon({
userId,
page: 1,
limit: 200,
})
const list = res?.list || []
allCoupons.value = list.map(transformCoupon)
} catch (e) {
console.error('加载优惠券失败', e)
// 不阻塞页面,显示空状态即可
} finally {
loading.value = false
}
}
function onTabChange() {
// Tab 切换不需要重新加载,数据已全部获取
}
// ─── 兑换功能 ────────────────────────────────────────────────
async function handleRedeem() {
const code = codeInput.value?.trim()
if (!code) {
message.warning('请输入兑换码')
return
}
redeeming.value = true
try {
await ensureUser()
const userId = currentUserId.value
// 通过兑换码查找对应优惠券模板
const coupons = await listShopCoupon({ keywords: code })
if (!coupons || coupons.length === 0) {
message.error('兑换码无效,请检查后重试')
return
}
const couponTemplate = coupons[0]
if (couponTemplate.status === 1 || couponTemplate.enabled === '0') {
message.error('该优惠券已停用')
return
}
// 检查是否已领完
if (couponTemplate.totalCount !== -1 && couponTemplate.issuedCount !== undefined && couponTemplate.issuedCount >= couponTemplate.totalCount) {
message.error('该优惠券已被领完')
return
}
// 检查是否已领取过
if (couponTemplate.limitPerUser !== -1) {
const myCoupons = await listShopUserCoupon({ userId: userId!, keywords: couponTemplate.name })
if (myCoupons && myCoupons.length >= (couponTemplate.limitPerUser || 1)) {
message.warning('您已领取过该优惠券,每人限领 ' + couponTemplate.limitPerUser + ' 张')
return
}
}
// 领取优惠券
await addShopUserCoupon({
couponId: couponTemplate.id,
userId: userId!,
name: couponTemplate.name,
description: couponTemplate.description,
type: couponTemplate.type,
reducePrice: couponTemplate.reducePrice,
discount: couponTemplate.discount,
minPrice: couponTemplate.minPrice,
applyRange: couponTemplate.applyRange,
applyRangeConfig: couponTemplate.applyRangeConfig,
startTime: couponTemplate.startTime as string | undefined,
endTime: couponTemplate.endTime as string | undefined,
status: 0,
obtainType: 10, // 主动领取
obtainSource: '兑换码领取',
})
message.success('兑换成功!')
codeInput.value = ''
await loadCoupons()
} catch (e) {
console.error('兑换失败', e)
message.error(e instanceof Error ? e.message : '兑换失败,请稍后重试')
} finally {
redeeming.value = false
}
}
// ─── 使用优惠券 ──────────────────────────────────────────────
function handleUse(coupon: { scope: string }) {
navigateTo('/market')
}
// ─── 初始化 ──────────────────────────────────────────────────
onMounted(() => {
loadCoupons()
})
</script>
<style scoped>
.card {
border-radius: 12px;
}
/* 迷你统计 */
.mini-stat {
padding: 14px;
border-radius: 10px;
border: 1px solid transparent;
text-align: center;
}
.mini-stat.green { background: #f0fdf4; border-color: #bbf7d0; }
.mini-stat.blue { background: #eff6ff; border-color: #dbeafe; }
.mini-stat.gray { background: #f9fafb; border-color: #e5e7eb; }
.mini-stat-value {
font-size: 20px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
}
.mini-stat-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
/* 列表容器 */
.coupon-list { min-height: 200px; }
.coupon-empty {
padding: 60px 0;
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 8px;
}
/* 优惠券卡片 */
.coupon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 16px;
}
@media (max-width: 480px) {
.coupon-grid {
grid-template-columns: 1fr;
}
}
.coupon-card {
display: flex;
border-radius: 12px;
overflow: hidden;
background: #fff;
border: 1px solid #f0f0f0;
transition: all 0.2s;
}
.coupon-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* 左侧金额区 */
.coupon-left {
width: 120px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 12px;
background: linear-gradient(135deg, #f5f3ff, #ede9fe);
}
.coupon-card.blue .coupon-left {
background: linear-gradient(135deg, #eff6ff, #dbeafe);
}
.coupon-card.green .coupon-left {
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
}
.coupon-card.orange .coupon-left {
background: linear-gradient(135deg, #fff7ed, #fed7aa);
}
.coupon-amount {
display: flex;
align-items: baseline;
line-height: 1;
}
.coupon-prefix {
font-size: 14px;
font-weight: 600;
color: #4f46e5;
}
.coupon-card.blue .coupon-prefix { color: #3b82f6; }
.coupon-card.green .coupon-prefix { color: #16a34a; }
.coupon-card.orange .coupon-prefix { color: #ea580c; }
.coupon-number {
font-size: 32px;
font-weight: 700;
color: #4f46e5;
}
.coupon-card.blue .coupon-number { color: #3b82f6; }
.coupon-card.green .coupon-number { color: #16a34a; }
.coupon-card.orange .coupon-number { color: #ea580c; }
.coupon-suffix {
font-size: 14px;
font-weight: 600;
color: #4f46e5;
}
.coupon-card.blue .coupon-suffix { color: #3b82f6; }
.coupon-card.green .coupon-suffix { color: #16a34a; }
.coupon-card.orange .coupon-suffix { color: #ea580c; }
.coupon-condition {
font-size: 11px;
color: rgba(0, 0, 0, 0.4);
margin-top: 4px;
}
/* 分割线 */
.coupon-divider {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 2px;
position: relative;
}
.divider-circle {
width: 14px;
height: 14px;
border-radius: 50%;
background: #f9fafb;
border: 1px solid #f0f0f0;
flex-shrink: 0;
}
.divider-circle.top { margin-bottom: -8px; z-index: 1; }
.divider-circle.bottom { margin-top: -8px; z-index: 1; }
.divider-line {
width: 1px;
flex: 1;
border-left: 1px dashed #e0e0e0;
}
/* 右侧信息 */
.coupon-right {
flex: 1;
min-width: 0;
padding: 14px 16px;
display: flex;
flex-direction: column;
justify-content: center;
}
.coupon-name {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
}
.coupon-scope {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 4px;
}
.coupon-expire {
font-size: 12px;
color: rgba(0, 0, 0, 0.35);
margin-bottom: 10px;
}
.coupon-use-btn {
align-self: flex-start;
border-radius: 6px;
font-size: 12px;
}
/* 已使用列表 */
.used-name {
font-weight: 500;
color: rgba(0, 0, 0, 0.75);
}
.used-amount {
font-weight: 600;
color: #4f46e5;
}
/* 已过期卡片 */
.expired-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.expired-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: 10px;
background: #fafafa;
border: 1px solid #f0f0f0;
opacity: 0.65;
}
.expired-left {
font-size: 18px;
font-weight: 700;
color: #9ca3af;
min-width: 60px;
text-align: center;
}
.expired-right { flex: 1; min-width: 0; }
.expired-name {
font-size: 13px;
font-weight: 500;
color: rgba(0, 0, 0, 0.5);
text-decoration: line-through;
}
.expired-reason {
font-size: 11px;
color: rgba(0, 0, 0, 0.35);
margin-top: 2px;
}
/* 引导横幅 */
.guide-banner {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
border-radius: 10px;
background: linear-gradient(135deg, #faf5ff, #f5f3ff);
border: 1px solid #ede9fe;
}
.guide-icon {
font-size: 32px;
flex-shrink: 0;
}
.guide-text { flex: 1; }
.guide-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.75);
}
.guide-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
margin-top: 2px;
}
</style>

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

@@ -0,0 +1,597 @@
<template>
<div class="console-home">
<!-- 顶部欢迎区 -->
<div class="welcome-section">
<div class="welcome-content">
<h1 class="welcome-title">欢迎回来 👋</h1>
<p class="welcome-subtitle">管理您的应用订单与账号一站式控制台</p>
</div>
<div class="welcome-actions">
<a-button type="primary" size="large" @click="navigateTo('/console/apps')">
<template #icon><AppstoreOutlined /></template>
进入应用中心
</a-button>
<a-button size="large" @click="navigateTo('/market')">
<template #icon><ShopOutlined /></template>
浏览应用商店
</a-button>
</div>
</div>
<!-- 快捷入口 -->
<div class="section-block">
<div class="section-header">
<h3 class="section-title">快捷入口</h3>
</div>
<div class="quick-cards-grid">
<div
v-for="card in quickCards"
:key="card.to"
class="quick-card"
@click="navigateTo(card.to)"
>
<div class="quick-card-icon" :style="{ background: card.bg }">
<component :is="card.icon" :style="{ fontSize: '22px', color: card.color }" />
</div>
<div class="quick-card-info">
<div class="quick-card-label">{{ card.label }}</div>
<div class="quick-card-desc">{{ card.desc }}</div>
</div>
<RightOutlined class="quick-card-arrow" />
</div>
</div>
</div>
<!-- 最近使用的应用 -->
<div class="section-block">
<div class="section-header">
<h3 class="section-title">最近使用</h3>
<NuxtLink to="/console/apps">
<a-button type="link">
查看全部 <RightOutlined />
</a-button>
</NuxtLink>
</div>
<!-- 加载中 -->
<div v-if="recentAppsLoading" class="recent-apps-loading">
<a-skeleton active :paragraph="{ rows: 1 }" />
</div>
<!-- 应用列表 -->
<div v-else-if="recentApps.length > 0" class="recent-apps-grid">
<div
v-for="app in recentApps.slice(0, 6)"
:key="app.productId"
class="recent-app-card"
@click="handleAppClick(app)"
>
<div class="recent-app-icon" :style="{ background: iconBgColor(app.productName) }">
<img
v-if="app.icon"
:src="app.icon"
:alt="app.productName"
class="recent-app-icon-img"
/>
<span v-else class="recent-app-icon-text">{{ appTypeIcon(app.appType) }}</span>
</div>
<div class="recent-app-info">
<div class="recent-app-name">{{ app.productName }}</div>
<div class="recent-app-meta">
<span class="recent-app-type">{{ appTypeName(app.type, app.appType) }}</span>
<span class="recent-app-time">{{ formatTime(app.updateTime || app.createTime) }}</span>
</div>
</div>
<div class="recent-app-entries">
<template v-for="entry in getAppEntries(app)" :key="entry.type">
<a-button
v-if="entry.available"
:type="entry.isPrimary ? 'primary' : 'default'"
size="small"
class="recent-app-enter-btn"
@click.stop="handleEntryClick(entry, app)"
>
<component :is="entry.icon" />
{{ entry.label }}
</a-button>
</template>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="recent-apps-empty">
<a-empty description="暂无最近使用的应用">
<template #image>
<div class="empty-icon">📦</div>
</template>
<a-button type="primary" @click="navigateTo('/console/apps')">去创建应用</a-button>
</a-empty>
</div>
</div>
<!-- 应用详情抽屉 -->
<AppDetail v-model:open="detailOpen" :app="selectedApp" @deleted="handleDeletedFromDetail" @updated="handleUpdatedFromDetail" />
<!-- 小程序扫码弹窗 -->
<QrCodeModal
v-model:open="qrOpen"
:qrcode-url="qrApp?.qrcode"
:app-name="qrApp?.productName"
:title="qrApp ? (APP_TYPE_NAME[qrApp.appType ?? 10] || '小程序') + '二维码' : ''"
:tip="qrApp ? getScanTip(qrApp.appType ?? 20) : ''"
/>
</div>
</template>
<script setup lang="ts">
import {
AppstoreOutlined,
GiftOutlined,
SafetyCertificateOutlined,
ShoppingCartOutlined,
ShoppingOutlined,
UserOutlined,
ShopOutlined,
RightOutlined,
TeamOutlined,
CustomerServiceOutlined,
GlobalOutlined,
QrcodeOutlined,
DownloadOutlined,
SettingOutlined,
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { getJoinedApps, recordVisit } from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
import { APP_TYPE, APP_TYPE_NAME } from '@/api/app/appProduct/model'
import AppDetail from '@/components/developer/AppDetail.vue'
import QrCodeModal from '@/components/QrCodeModal.vue'
import { getAppEntries, executeEntry, getScanTip } from '@/utils/appEntry'
import type { AppEntry } from '@/utils/appEntry'
const router = useRouter()
definePageMeta({ layout: 'console' })
const userId = import.meta.client ? localStorage.getItem('UserId') : null
// 快捷入口配置
const quickCards = [
{
label: '应用中心',
desc: '管理我的应用',
to: '/console/apps',
icon: AppstoreOutlined,
bg: '#eff6ff',
color: '#3b82f6',
},
{
label: '已购产品',
desc: '查看授权与订阅',
to: '/console/products',
icon: ShoppingOutlined,
bg: '#f0fdf4',
color: '#22c55e',
},
{
label: '订单记录',
desc: '历史订单与账单',
to: '/console/orders',
icon: ShoppingCartOutlined,
bg: '#fff7ed',
color: '#f97316',
},
{
label: '优惠券',
desc: '查看可用优惠',
to: '/console/coupons',
icon: GiftOutlined,
bg: '#fdf4ff',
color: '#a855f7',
},
{
label: '成员管理',
desc: '团队与权限管理',
to: '/console/account/members',
icon: TeamOutlined,
bg: '#fefce8',
color: '#eab308',
},
{
label: '工单管理',
desc: '技术支持与反馈',
to: '/console/tickets',
icon: CustomerServiceOutlined,
bg: '#f0f9ff',
color: '#0ea5e9',
},
{
label: '账号安全',
desc: '密码与安全设置',
to: '/console/account/security',
icon: SafetyCertificateOutlined,
bg: '#fff1f2',
color: '#f43f5e',
},
]
// 最近使用的应用
const recentApps = ref<AppProduct[]>([])
const recentAppsLoading = ref(false)
const detailOpen = ref(false)
const selectedApp = ref<AppProduct | null>(null)
// 跳转方法(模板中不能直接调用 navigateTo
function navigateTo(path: string) {
router.push(path)
}
// 入口处理
function handleEntryClick(entry: AppEntry, app: AppProduct) {
if (entry.type === 'scan-qr') {
qrApp.value = app
qrOpen.value = true
return
}
executeEntry(entry)
}
// 扫码弹窗
const qrOpen = ref(false)
const qrApp = ref<AppProduct | null>(null)
// 应用类型名称(使用统一枚举)
function appTypeName(type?: number, appType?: number): string {
return APP_TYPE_NAME[type ?? 10] ?? 'Web 应用'
}
function appTypeIcon(appType?: number): string {
const iconMap: Record<number, string> = {
[APP_TYPE.WEBSITE]: '🌐',
[APP_TYPE.WECHAT_MP]: '📱',
[APP_TYPE.DOUYIN_MP]: '🎵',
[APP_TYPE.BAIDU_MP]: '🔍',
[APP_TYPE.ALIPAY_MP]: '💎',
[APP_TYPE.ANDROID]: '🤖',
[APP_TYPE.IOS]: '🍎',
[APP_TYPE.MACOS]: '💻',
[APP_TYPE.WINDOWS]: '🪟',
[APP_TYPE.PLUGIN]: '🔌',
}
return iconMap[appType ?? 10] ?? '🌐'
}
// 图标背景色
const PALETTE = ['#f56a00', '#7265e6', '#ffbf00', '#00a2ae', '#87d068', '#108ee9']
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]
}
function formatTime(timestamp?: string | number | Date) {
if (!timestamp) return '-'
const date = typeof timestamp === 'string' ? new Date(timestamp) : new Date(timestamp)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60))
if (hours === 0) {
const minutes = Math.floor(diff / (1000 * 60))
return minutes <= 1 ? '刚刚' : `${minutes}分钟前`
}
return `${hours}小时前`
} else if (days === 1) {
return '昨天'
} else if (days < 7) {
return `${days}天前`
} else {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
}
// 加载最近使用的应用(包括我创建的和被邀请参与的应用)
async function loadRecentApps() {
if (!userId) return
recentAppsLoading.value = true
try {
const uid = Number(userId)
// 获取我参与的所有应用(后端按 app_user.update_time 排序,实现"最近使用"
const result = await getJoinedApps({ page: 1, limit: 10 })
recentApps.value = result?.list || []
} catch (e) {
console.error('加载最近应用失败', e)
} finally {
recentAppsLoading.value = false
}
}
function handleAppClick(app: AppProduct) {
// 记录访问(异步,不阻塞)
if (app.productId) {
recordVisit(app.productId)
}
selectedApp.value = app
detailOpen.value = true
}
function handleDeletedFromDetail() {
selectedApp.value = null
detailOpen.value = false
loadRecentApps()
}
function handleUpdatedFromDetail(updatedApp: AppProduct) {
const index = recentApps.value.findIndex((app) => app.productId === updatedApp.productId)
if (index !== -1) {
recentApps.value[index] = { ...recentApps.value[index], ...updatedApp }
}
if (selectedApp.value?.productId === updatedApp.productId) {
selectedApp.value = { ...selectedApp.value, ...updatedApp }
}
}
onMounted(() => {
loadRecentApps()
})
</script>
<style scoped>
/* ===== 页面整体 ===== */
.console-home {
padding-bottom: 24px;
}
/* ===== 欢迎区 ===== */
.welcome-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 32px;
margin-bottom: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.welcome-title {
font-size: 28px;
font-weight: 700;
color: #fff;
margin: 0 0 8px;
}
.welcome-subtitle {
font-size: 15px;
color: rgba(255, 255, 255, 0.85);
margin: 0;
}
.welcome-actions {
display: flex;
gap: 12px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.welcome-section {
flex-direction: column;
text-align: center;
padding: 24px;
}
.welcome-actions {
width: 100%;
justify-content: center;
}
}
/* ===== 区块样式 ===== */
.section-block {
background: #fff;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #f0f0f0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin: 0;
}
/* ===== 快捷入口 ===== */
.quick-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.quick-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fafafa;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.quick-card:hover {
background: #fff;
border-color: #d6e4ff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.quick-card-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.quick-card-info {
flex: 1;
min-width: 0;
}
.quick-card-label {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 2px;
}
.quick-card-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.4;
}
.quick-card-arrow {
color: rgba(0, 0, 0, 0.25);
font-size: 12px;
transition: all 0.2s;
}
.quick-card:hover .quick-card-arrow {
color: rgba(0, 0, 0, 0.45);
transform: translateX(4px);
}
/* ===== 最近使用 ===== */
.recent-apps-loading {
padding: 20px 0;
}
.recent-apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.recent-app-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fafafa;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.recent-app-card:hover {
background: #fff;
border-color: #d6e4ff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.recent-app-icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.recent-app-icon-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.recent-app-icon-text {
font-size: 20px;
}
.recent-app-info {
flex: 1;
min-width: 0;
}
.recent-app-name {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recent-app-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.recent-app-type {
color: rgba(0, 0, 0, 0.45);
background: #f0f0f0;
padding: 1px 6px;
border-radius: 4px;
}
.recent-app-time {
color: rgba(0, 0, 0, 0.35);
}
.recent-app-entries {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.2s;
}
.recent-app-card:hover .recent-app-entries {
opacity: 1;
}
.recent-app-enter-btn {
font-size: 12px;
}
/* 移动端始终显示进入按钮 */
@media (max-width: 768px) {
.recent-app-entries {
opacity: 1;
}
}
.recent-apps-empty {
padding: 40px 0;
}
.empty-icon {
font-size: 48px;
margin-bottom: 8px;
}
</style>

View File

@@ -0,0 +1,326 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import {
listPendingInvites,
acceptInvite,
rejectInvite,
type AppUser
} from '@/api/app/appUser'
import ConsoleLayout from '@/layouts/console.vue'
definePageMeta({
layout: 'console'
})
const router = useRouter()
const invites = ref<AppUser[]>([])
const loading = ref(false)
const activeTab = ref('pending')
// 待确认邀请
const pendingInvites = computed(() => invites.value)
// 加载邀请列表
async function loadInvites() {
try {
loading.value = true
invites.value = await listPendingInvites()
} catch (error) {
console.error('加载邀请列表失败:', error)
message.error('加载邀请列表失败')
} finally {
loading.value = false
}
}
// 接受邀请
async function handleAccept(invite: AppUser) {
if (!invite.id) return
try {
await acceptInvite(invite.id)
message.success('已接受邀请,加入应用成功')
invites.value = invites.value.filter(i => i.id !== invite.id)
// 刷新页面或跳转到应用
setTimeout(() => {
router.push('/developer/apps')
}, 500)
} catch (error: any) {
message.error(error.message || '接受邀请失败')
}
}
// 拒绝邀请
async function handleReject(invite: AppUser) {
if (!invite.id) return
Modal.confirm({
title: '确认拒绝邀请?',
content: `拒绝后将无法加入应用「${invite.productName || '未知应用'}`,
okText: '确认拒绝',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await rejectInvite(invite.id!)
message.success('已拒绝邀请')
invites.value = invites.value.filter(i => i.id !== invite.id)
} catch (error: any) {
message.error(error.message || '拒绝邀请失败')
}
}
})
}
// 获取角色标签文本
function getRoleLabel(role?: string) {
const map: Record<string, string> = {
owner: '所有者',
admin: '管理员',
developer: '开发者',
viewer: '访客'
}
return map[role || ''] || role || '未知'
}
// 获取角色标签颜色
function getRoleColor(role?: string) {
const map: Record<string, string> = {
owner: 'orange',
admin: 'blue',
developer: 'green',
viewer: 'purple'
}
return map[role || ''] || 'default'
}
onMounted(() => {
loadInvites()
})
</script>
<template>
<ConsoleLayout>
<div class="invites-page">
<div class="page-header">
<h1 class="page-title">应用邀请</h1>
<p class="page-desc">管理您收到的应用加入邀请</p>
</div>
<a-card class="invites-card">
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="pending" tab="待确认">
<a-spin :spinning="loading">
<div v-if="pendingInvites.length === 0" class="empty-state">
<a-empty description="暂无待确认的邀请">
<template #extra>
<p class="empty-tip">
当有人邀请您加入应用时邀请将显示在这里
</p>
</template>
</a-empty>
</div>
<div v-else class="invite-list">
<div
v-for="invite in pendingInvites"
:key="invite.id"
class="invite-item"
>
<div class="invite-main">
<a-avatar
:src="invite.icon || '/logo.png'"
:size="64"
class="app-icon"
/>
<div class="invite-info">
<div class="info-header">
<h3 class="app-name">{{ invite.productName || '未知应用' }}</h3>
<a-tag :color="getRoleColor(invite.role)">
{{ getRoleLabel(invite.role) }}
</a-tag>
</div>
<div class="info-meta">
<span class="meta-item">
<UserOutlined />
邀请人{{ invite.username || '未知用户' }}
</span>
<span class="meta-item">
<ClockCircleOutlined />
邀请时间{{ invite.inviteTime }}
</span>
<span v-if="invite.inviteExpireTime" class="meta-item expire">
<ExclamationCircleOutlined />
有效期至{{ invite.inviteExpireTime }}
</span>
</div>
</div>
</div>
<div class="invite-actions">
<a-button
size="large"
@click="handleReject(invite)"
>
拒绝
</a-button>
<a-button
type="primary"
size="large"
@click="handleAccept(invite)"
>
接受邀请
</a-button>
</div>
</div>
</div>
</a-spin>
</a-tab-pane>
<a-tab-pane key="history" tab="历史记录" disabled>
<a-empty description="功能开发中" />
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</ConsoleLayout>
</template>
<style scoped>
.invites-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #262626;
margin-bottom: 8px;
}
.page-desc {
color: #8c8c8c;
font-size: 14px;
}
.invites-card {
border-radius: 8px;
}
.empty-state {
padding: 60px 0;
}
.empty-tip {
color: #8c8c8c;
font-size: 14px;
margin-top: 8px;
}
.invite-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.invite-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px;
background: #fafafa;
border-radius: 8px;
border: 1px solid #f0f0f0;
transition: all 0.3s;
}
.invite-item:hover {
background: #f5f5f5;
border-color: #d9d9d9;
}
.invite-main {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
min-width: 0;
}
.app-icon {
flex-shrink: 0;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.invite-info {
flex: 1;
min-width: 0;
}
.info-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.app-name {
font-size: 18px;
font-weight: 600;
color: #262626;
margin: 0;
}
.info-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
color: #595959;
font-size: 14px;
}
.meta-item.expire {
color: #faad14;
}
.invite-actions {
display: flex;
gap: 12px;
flex-shrink: 0;
margin-left: 24px;
}
@media (max-width: 768px) {
.invites-page {
padding: 16px;
}
.invite-item {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.invite-actions {
margin-left: 0;
width: 100%;
justify-content: flex-end;
}
.info-meta {
flex-direction: column;
gap: 8px;
}
}
</style>

View File

@@ -0,0 +1,562 @@
<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-row :gutter="[16, 16]" class="mb-4">
<a-col :xs="8" :md="4" v-for="stat in invoiceStats" :key="stat.label">
<div class="mini-stat" :class="stat.color">
<div class="mini-stat-value">{{ stat.value }}</div>
<div class="mini-stat-label">{{ stat.label }}</div>
</div>
</a-col>
</a-row>
<a-card :bordered="false" class="card">
<a-alert
class="mb-4"
show-icon
type="info"
message="提交开票申请后,工作人员将在 1-3 个工作日内处理。开票信息可通过「自动填充」快捷录入。"
/>
<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>
<a-form-item label="关联订单(可选)" name="orderNo">
<a-select
v-model:value="form.orderNo"
allow-clear
show-search
placeholder="选择要开票的订单"
:filter-option="orderFilterOption"
:loading="ordersLoading"
style="width: 100%"
>
<a-select-option v-for="order in paidOrders" :key="order.orderNo" :value="order.orderNo">
<div class="order-option">
<span class="order-no">{{ order.orderNo }}</span>
<span class="order-amount">¥{{ formatOrderAmount(order.payPrice || order.totalPrice) }}</span>
<span class="order-time">{{ formatTime(order.createTime) }}</span>
</div>
</a-select-option>
</a-select>
</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="orderNo" width="220">
<template #default="{ record }">
<span v-if="record.orderNo" class="text-gray-500 text-sm">{{ record.orderNo }}</span>
<span v-else class="text-gray-400">-</span>
</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="关联订单" :span="2">{{ detail?.orderNo || '未关联' }}</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'
import { pageShopOrder } from '@/api/shop/shopOrder'
import type { ShopOrder } from '@/api/shop/shopOrder/model'
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
orderNo?: 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
orderNo?: string
}>({
invoiceType: undefined,
invoiceTitle: '',
taxpayerId: '',
email: '',
deliveryMethod: 'digital',
bankName: '',
bankAccount: '',
registeredAddress: '',
registeredPhone: '',
orderNo: undefined,
})
const records = ref<InvoiceApplyRecord[]>([])
const detailOpen = ref(false)
const detail = ref<InvoiceApplyRecord | null>(null)
// ─── 统计 ─────────────────────────────────────────────────────
const invoiceStats = computed(() => [
{ label: '全部', value: String(records.value.length), color: 'blue' },
{ label: '已提交', value: String(records.value.filter(r => r.status === 'submitted').length), color: 'gray' },
{ label: '已开具', value: String(records.value.filter(r => r.status === 'issued').length), color: 'green' },
])
// ─── 订单列表(用于关联开票) ─────────────────────────────────
const ordersLoading = ref(false)
const paidOrders = ref<ShopOrder[]>([])
async function loadPaidOrders() {
ordersLoading.value = true
try {
const user = await getUserInfo()
const userId = user.userId
if (!userId) return
const data = await pageShopOrder({
page: 1,
limit: 100,
userId,
payStatus: 1, // 已支付
orderStatus: 1, // 已完成
})
paidOrders.value = data?.list || []
} catch {
paidOrders.value = []
} finally {
ordersLoading.value = false
}
}
function formatOrderAmount(value?: string) {
const v = typeof value === 'string' ? value.trim() : ''
if (!v) return '0.00'
const n = Number(v)
return Number.isFinite(n) ? n.toFixed(2) : v
}
function orderFilterOption(input: string, option: any) {
return option.value?.toLowerCase().includes(input.toLowerCase())
}
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'
form.orderNo = undefined
}
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(),
orderNo: form.orderNo?.trim() || undefined,
}
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 })
loadPaidOrders()
})
</script>
<style scoped>
.card {
border-radius: 12px;
}
.mini-stat {
padding: 14px;
border-radius: 10px;
border: 1px solid transparent;
text-align: center;
}
.mini-stat.blue { background: #eff6ff; border-color: #dbeafe; }
.mini-stat.green { background: #f0fdf4; border-color: #bbf7d0; }
.mini-stat.gray { background: #f9fafb; border-color: #e5e7eb; }
.mini-stat-value {
font-size: 20px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
}
.mini-stat-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
.order-option {
display: flex;
align-items: center;
gap: 12px;
}
.order-no {
font-family: monospace;
font-size: 13px;
color: rgba(0, 0, 0, 0.75);
}
.order-amount {
font-weight: 600;
color: #ea580c;
font-size: 13px;
}
.order-time {
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
margin-left: auto;
}
</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,539 @@
<template>
<div class="notification-page">
<!-- 页面标题 -->
<div class="page-header">
<div class="header-left">
<h1 class="page-title">消息通知</h1>
<span class="unread-badge" v-if="unreadTotal > 0">
{{ unreadTotal }} 条未读
</span>
</div>
<div class="header-actions">
<a-button @click="handleMarkAll" :loading="markAllLoading" :disabled="unreadTotal === 0">
<CheckOutlined />
全部标记已读
</a-button>
<a-popconfirm
title="确定要清空所有已读消息吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleClearRead"
>
<a-button :disabled="readCount === 0">
<DeleteOutlined />
清空已读
</a-button>
</a-popconfirm>
</div>
</div>
<!-- 过滤 Tabs -->
<div class="filter-bar">
<div class="filter-type-group">
<a-radio-group v-model:value="filterType" button-style="solid" size="middle">
<a-radio-button value="">
全部
<span v-if="unreadTotal > 0" class="tab-count">{{ unreadTotal }}</span>
</a-radio-button>
<a-radio-button
v-for="(info, key) in notificationTypeMap"
:key="key"
:value="key"
>
{{ info.icon }} {{ info.label }}
<span v-if="(unreadByType as any)[key] > 0" class="tab-count">
{{ (unreadByType as any)[key] }}
</span>
</a-radio-button>
</a-radio-group>
</div>
<!-- 已读/未读切换 -->
<div class="filter-read-group">
<a-segmented v-model:value="readFilter" :options="readFilterOptions" size="small" />
</div>
</div>
<!-- 通知列表 -->
<a-spin :spinning="listLoading">
<div class="notification-list">
<template v-if="listData.length">
<div
v-for="item in listData"
:key="item.id"
class="notif-card"
:class="{ unread: !item.isRead }"
@click="handleItemClick(item)"
>
<div class="notif-type-icon" :style="{ background: notificationTypeMap[item.type!]?.color + '15' }">
{{ notificationTypeMap[item.type!]?.icon || '📢' }}
</div>
<div class="notif-body">
<div class="notif-header">
<span class="notif-type-tag" :style="{ color: notificationTypeMap[item.type!]?.color }">
{{ notificationTypeMap[item.type!]?.label || '通知' }}
</span>
<span class="notif-time">{{ formatTime(item.createTime) }}</span>
</div>
<div class="notif-title">{{ item.title }}</div>
<div v-if="item.content" class="notif-content">{{ item.content }}</div>
<div v-if="item.senderName" class="notif-footer">
<a-avatar :size="18" :src="item.senderAvatar" class="sender-avatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<span class="sender-name">{{ item.senderName }}</span>
</div>
</div>
<div class="notif-actions" @click.stop>
<a-dropdown :trigger="['click']">
<a-button type="text" size="small" class="action-btn">
<EllipsisOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-if="!item.isRead" @click="handleMarkRead(item)">
<CheckOutlined /> 标记已读
</a-menu-item>
<a-menu-item @click="handleDelete(item)">
<DeleteOutlined /> 删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<div v-if="!item.isRead" class="unread-dot" />
</div>
</div>
</template>
<a-empty v-else description="暂无通知消息" class="empty-state">
<template #image>
<div class="empty-icon">🔔</div>
</template>
</a-empty>
</div>
<!-- 分页 -->
<div v-if="pagination.total > 0" class="pagination-wrap">
<a-pagination
v-model:current="pagination.page"
:total="pagination.total"
:page-size="pagination.pageSize"
:show-quick-jumper="true"
:show-total="(total: number) => `${total}`"
size="small"
@change="loadData"
/>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import {
CheckOutlined,
DeleteOutlined,
EllipsisOutlined,
UserOutlined,
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
pageNotification,
markRead,
markAllRead,
removeNotification,
clearReadNotifications,
} from '@/api/app/notification'
import {
useNotificationCenter,
notificationTypeMap,
} from '@/composables/useNotificationCenter'
import type { Notification, NotificationType } from '@/api/app/notification/model'
const {
unreadTotal,
unreadByType,
fetchUnreadCount,
startPolling,
stopPolling,
formatTime,
getNotificationLink,
} = useNotificationCenter()
// 过滤状态
const filterType = ref<NotificationType | ''>('')
const readFilter = ref('all')
const readFilterOptions = [
{ label: '全部', value: 'all' },
{ label: '未读', value: 'unread' },
{ label: '已读', value: 'read' },
]
// 列表数据
const listData = ref<Notification[]>([])
const listLoading = ref(false)
const markAllLoading = ref(false)
const readCount = ref(0)
// 分页
const pagination = reactive({
page: 1,
pageSize: 15,
total: 0,
})
// 计算已读数
function calcReadCount() {
readCount.value = listData.value.filter((n) => n.isRead === 1).length
}
// 加载数据
async function loadData() {
listLoading.value = true
try {
const params: Record<string, unknown> = {
page: pagination.page,
limit: pagination.pageSize,
}
if (filterType.value) params.type = filterType.value
if (readFilter.value === 'unread') params.isRead = 0
if (readFilter.value === 'read') params.isRead = 1
const res = await pageNotification(params)
listData.value = res.list ?? []
pagination.total = res.count ?? 0
calcReadCount()
} catch {
message.error('加载通知失败')
} finally {
listLoading.value = false
}
}
// 点击通知项
async function handleItemClick(item: Notification) {
if (!item.isRead && item.id) {
try {
await markRead(item.id)
item.isRead = 1
if (unreadTotal.value > 0) unreadTotal.value--
calcReadCount()
} catch {
// 静默
}
}
const link = getNotificationLink(item)
navigateTo(link)
}
// 标记单条已读
async function handleMarkRead(item: Notification) {
if (!item.id) return
try {
await markRead(item.id)
item.isRead = 1
if (unreadTotal.value > 0) unreadTotal.value--
calcReadCount()
} catch {
message.error('操作失败')
}
}
// 全部标记已读
async function handleMarkAll() {
markAllLoading.value = true
try {
const type = filterType.value || undefined
await markAllRead(type ? { type } : undefined)
listData.value.forEach((n) => { n.isRead = 1 })
unreadTotal.value = 0
calcReadCount()
message.success('已全部标记为已读')
} catch {
message.error('操作失败')
} finally {
markAllLoading.value = false
}
}
// 删除通知
async function handleDelete(item: Notification) {
if (!item.id) return
try {
await removeNotification(item.id)
listData.value = listData.value.filter((n) => n.id !== item.id)
if (!item.isRead && unreadTotal.value > 0) unreadTotal.value--
pagination.total--
calcReadCount()
message.success('已删除')
} catch {
message.error('删除失败')
}
}
// 清空已读
async function handleClearRead() {
try {
const type = filterType.value || undefined
await clearReadNotifications(type ? { type } : undefined)
loadData()
fetchUnreadCount()
message.success('已清空已读消息')
} catch {
message.error('操作失败')
}
}
// 监听过滤变化
watch([filterType, readFilter], () => {
pagination.page = 1
loadData()
})
onMounted(() => {
startPolling()
loadData()
})
onUnmounted(() => {
stopPolling()
})
// SEO
useHead({ title: '消息通知 - 控制台' })
</script>
<style scoped>
.notification-page {
max-width: 1280px;
padding: 0 16px;
margin: 0 auto;
}
/* ===== 页面头部 ===== */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 24px 0 20px;
flex-wrap: wrap;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.page-title {
font-size: 22px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.unread-badge {
background: linear-gradient(135deg, #e6f4ff 0%, #f0f7ff 100%);
color: #1890ff;
font-size: 12px;
font-weight: 600;
padding: 4px 12px;
border-radius: 9999px;
border: 1px solid rgba(24, 144, 255, 0.2);
}
.header-actions {
display: flex;
gap: 8px;
}
/* ===== 过滤栏 ===== */
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
}
.filter-type-group {
flex: 1;
min-width: 0;
}
.filter-bar :deep(.ant-radio-button-wrapper) {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
font-weight: 600;
background: rgba(24, 144, 255, 0.15);
color: #1890ff;
border-radius: 9999px;
margin-left: 4px;
}
/* ===== 通知列表 ===== */
.notification-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.notif-card {
display: flex;
gap: 14px;
padding: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.notif-card:hover {
border-color: #d9d9d9;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.notif-card.unread {
border-color: #e6f4ff;
background: linear-gradient(135deg, #f0f7ff 0%, #fafbff 100%);
}
.notif-card.unread::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 32px;
background: #1890ff;
border-radius: 0 4px 4px 0;
}
.notif-type-icon {
flex-shrink: 0;
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
font-size: 20px;
}
.notif-body {
flex: 1;
min-width: 0;
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.notif-type-tag {
font-size: 12px;
font-weight: 500;
}
.notif-time {
font-size: 12px;
color: rgba(0, 0, 0, 0.35);
}
.notif-title {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5;
}
.notif-card.unread .notif-title {
font-weight: 600;
}
.notif-content {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.6;
margin-top: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.notif-footer {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.sender-avatar {
font-size: 10px;
}
.sender-name {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.notif-actions {
flex-shrink: 0;
display: flex;
align-items: flex-start;
gap: 4px;
}
.action-btn {
color: rgba(0, 0, 0, 0.35);
}
.action-btn:hover {
color: rgba(0, 0, 0, 0.65);
}
.unread-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #1890ff;
flex-shrink: 0;
}
/* ===== 空状态 ===== */
.empty-state {
padding: 80px 0;
}
.empty-icon {
font-size: 64px;
opacity: 0.3;
}
/* ===== 分页 ===== */
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding: 8px 0;
}
</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/description</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', 'description']
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,550 @@
<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-56"
@press-enter="doSearch"
/>
<a-button :loading="loading" @click="refresh">刷新</a-button>
</a-space>
</template>
</a-page-header>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]">
<a-col :xs="8" :md="4" v-for="stat in stats" :key="stat.label">
<div class="mini-stat" :class="stat.color">
<div class="mini-stat-value">{{ stat.value }}</div>
<div class="mini-stat-label">{{ stat.label }}</div>
</div>
</a-col>
</a-row>
<a-card :bordered="false" class="card">
<!-- 筛选 -->
<a-space class="mb-4" wrap>
<a-segmented
v-model:value="statusFilter"
:options="statusOptions"
@change="doSearch"
/>
<a-select
v-model:value="productType"
allow-clear
placeholder="产品类型"
style="min-width: 140px"
@change="doSearch"
>
<a-select-option value="website">企业官网</a-select-option>
<a-select-option value="shop">电商系统</a-select-option>
<a-select-option value="mp">小程序/公众号</a-select-option>
</a-select>
</a-space>
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
<!-- 产品卡片列表 -->
<div v-if="loading" class="product-loading">
<a-spin tip="加载中..." />
</div>
<div v-else-if="productList.length === 0" class="product-empty">
<a-empty description="暂无已购产品">
<a-button type="primary" @click="navigateTo('/market')">
前往应用商店
</a-button>
</a-empty>
</div>
<div v-else class="product-grid">
<div
v-for="product in productList"
:key="product.id"
class="product-card"
>
<div class="product-card-header">
<div class="product-icon" :class="product.typeClass">{{ product.icon }}</div>
<a-tag
:color="product.isActive ? 'green' : 'default'"
class="product-status"
>
{{ product.isActive ? '生效中' : '已过期' }}
</a-tag>
</div>
<div class="product-card-body">
<div class="product-name">{{ product.name }}</div>
<div class="product-desc">{{ product.desc }}</div>
<div class="product-meta">
<span v-if="product.tenantName" class="meta-item">
<span class="meta-dot" /> {{ product.tenantName }}
</span>
<span v-if="product.domain" class="meta-item">
<span class="meta-dot" /> {{ product.domain }}
</span>
</div>
</div>
<a-divider style="margin: 0" />
<div class="product-card-footer">
<div class="product-price">
<span class="price-amount">¥{{ product.price }}</span>
<span class="price-period">/ {{ product.period }}</span>
</div>
<a-space>
<a-button
v-if="product.adminUrl"
size="small"
type="primary"
@click="goAdmin(product)"
>
进入后台
</a-button>
<a-button size="small" @click="openDetail(product)">详情</a-button>
</a-space>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="total > pageSize" class="mt-4 flex justify-end">
<a-pagination
:current="page"
:page-size="pageSize"
:total="total"
size="small"
@change="onPageChange"
/>
</div>
</a-card>
<!-- 详情弹窗 -->
<a-modal
v-model:open="detailOpen"
:title="selectedProduct?.name || '产品详情'"
:width="640"
:footer="null"
>
<template v-if="selectedProduct">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="产品名称">{{ selectedProduct.name }}</a-descriptions-item>
<a-descriptions-item label="产品类型">{{ selectedProduct.typeName }}</a-descriptions-item>
<a-descriptions-item label="授权状态">
<a-tag :color="selectedProduct.isActive ? 'green' : 'default'">
{{ selectedProduct.isActive ? '生效中' : '已过期' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="购买金额">¥{{ selectedProduct.price }}</a-descriptions-item>
<a-descriptions-item label="订阅周期">{{ selectedProduct.period }}</a-descriptions-item>
<a-descriptions-item label="关联租户">{{ selectedProduct.tenantName || '-' }}</a-descriptions-item>
<a-descriptions-item label="绑定域名">
<a-typography-text v-if="selectedProduct.domain" :copyable="{ text: selectedProduct.domain }">
{{ selectedProduct.domain }}
</a-typography-text>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="订单号">
<a-typography-text v-if="selectedProduct.orderNo" :copyable="{ text: selectedProduct.orderNo }">
{{ selectedProduct.orderNo }}
</a-typography-text>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="购买时间">{{ selectedProduct.createTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="到期时间">
<span :class="{ 'text-red-500': !selectedProduct.isActive }">
{{ selectedProduct.expirationTime || '-' }}
</span>
</a-descriptions-item>
</a-descriptions>
<div v-if="selectedProduct.adminUrl" class="mt-4 text-right">
<a-button type="primary" @click="goAdmin(selectedProduct!)">进入管理后台</a-button>
</div>
</template>
</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 pageSize = ref(20)
const total = ref(0)
const keywords = ref('')
const statusFilter = ref('active')
const productType = ref<string | undefined>(undefined)
const currentUserId = ref<number | null>(null)
const statusOptions = [
{ label: '生效中', value: 'active' },
{ label: '已过期', value: 'expired' },
{ label: '全部', value: 'all' },
]
interface ProductItem {
id: string
name: string
desc: string
icon: string
typeClass: string
typeName: string
type: string
isActive: boolean
price: string
period: string
tenantName: string
domain: string
adminUrl: string
orderNo: string
createTime: string
expirationTime: string
raw: ShopOrder
}
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', 'description']
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>
}
const productCatalog: Record<string, { name: string; icon: string; typeClass: string; desc: string }> = {
website: { name: '企业官网', icon: '🌐', typeClass: 'blue', desc: '响应式企业建站系统' },
shop: { name: '电商系统', icon: '🛒', typeClass: 'green', desc: '多端电商解决方案' },
mp: { name: '小程序/公众号', icon: '📱', typeClass: 'purple', desc: '微信生态应用' },
}
function toProductItem(order: ShopOrder): ProductItem {
const extra = parseExtra(order)
const code = typeof extra?.product === 'string' ? extra.product.trim() : ''
const catalog = code && productCatalog[code] ? productCatalog[code] : { name: code || '其他产品', icon: '📦', typeClass: 'gray', desc: '' }
const months = extra?.months
const tenantName = typeof extra?.tenantName === 'string' ? extra.tenantName.trim() : ''
const domain = typeof extra?.domain === 'string' ? extra.domain.trim() : ''
const payPrice = order.payPrice || order.totalPrice || '0'
// 判断是否过期
const expirationTime = order.expirationTime || ''
let isActive = true
if (expirationTime) {
isActive = new Date(expirationTime) > new Date()
}
if (Number(order.orderStatus) === 2 || Number(order.orderStatus) === 3 || Number(order.orderStatus) === 6) {
isActive = false
}
return {
id: `${order.orderId}-${code}`,
name: catalog.name,
desc: catalog.desc,
icon: catalog.icon,
typeClass: catalog.typeClass,
typeName: catalog.name,
type: code,
isActive,
price: typeof months === 'number' || typeof months === 'string' ? payPrice : payPrice,
period: typeof months === 'number' || typeof months === 'string' ? `${months}个月` : '永久',
tenantName,
domain,
adminUrl: isActive && tenantName ? `https://${tenantName}` : '',
orderNo: order.orderNo || '',
createTime: order.createTime || '',
expirationTime,
raw: order,
}
}
const productList = computed(() => {
const result: ProductItem[] = []
const seen = new Set<string>()
for (const order of list.value) {
const product = toProductItem(order)
// 状态筛选
if (statusFilter.value === 'active' && !product.isActive) continue
if (statusFilter.value === 'expired' && product.isActive) continue
// 类型筛选
if (productType.value && product.type !== productType.value) continue
// 关键词筛选
if (keywords.value?.trim()) {
const kw = keywords.value.trim().toLowerCase()
if (!product.name.toLowerCase().includes(kw) && !product.tenantName.toLowerCase().includes(kw)) continue
}
// 去重(按 product + tenantName
const dedupeKey = `${product.type}-${product.tenantName}`
if (seen.has(dedupeKey)) {
// 更新已有项为生效中的
const existing = result.find(p => `${p.type}-${p.tenantName}` === dedupeKey)
if (existing && product.isActive) {
Object.assign(existing, product)
}
continue
}
seen.add(dedupeKey)
result.push(product)
}
return result
})
const stats = computed(() => {
const allProducts = list.value.map(toProductItem)
const activeCount = allProducts.filter(p => p.isActive).length
const expiredCount = allProducts.filter(p => !p.isActive).length
const totalSpent = list.value.reduce((sum, o) => sum + Number(o.payPrice || o.totalPrice || 0), 0)
return [
{ label: '总产品', value: list.value.length, color: 'blue' },
{ label: '生效中', value: activeCount, color: 'green' },
{ label: '已过期', value: expiredCount, color: 'orange' },
{ label: '累计消费', value: `¥${totalSpent.toFixed(0)}`, color: 'purple' },
]
})
const detailOpen = ref(false)
const selectedProduct = ref<ProductItem | null>(null)
function openDetail(product: ProductItem) {
selectedProduct.value = product
detailOpen.value = true
}
function goAdmin(product: ProductItem) {
if (product.adminUrl) {
window.open(product.adminUrl, '_blank')
}
}
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: pageSize.value,
userId,
payStatus: 1, // 只查已支付
keywords: keywords.value?.trim() || undefined,
})
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
}
}
function refresh() { load() }
function doSearch() { page.value = 1; load() }
function onPageChange(p: number) { page.value = p; load() }
onMounted(() => { load() })
</script>
<style scoped>
.card {
border-radius: 12px;
}
/* 迷你统计 */
.mini-stat {
padding: 14px;
border-radius: 10px;
border: 1px solid transparent;
text-align: center;
}
.mini-stat.blue { background: #eff6ff; border-color: #dbeafe; }
.mini-stat.green { background: #f0fdf4; border-color: #bbf7d0; }
.mini-stat.orange { background: #fff7ed; border-color: #fed7aa; }
.mini-stat.purple { background: #f5f3ff; border-color: #e9d5ff; }
.mini-stat-value {
font-size: 20px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
}
.mini-stat-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
/* 加载/空 */
.product-loading {
display: flex;
justify-content: center;
padding: 60px 0;
}
.product-empty {
padding: 60px 0;
}
/* 产品网格 */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
@media (max-width: 768px) {
.product-grid {
grid-template-columns: 1fr;
}
}
/* 产品卡片 */
.product-card {
border: 1px solid #f0f0f0;
border-radius: 12px;
overflow: hidden;
transition: all 0.2s;
}
.product-card:hover {
border-color: #d0d7ff;
box-shadow: 0 4px 16px rgba(79, 70, 229, 0.08);
}
.product-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 0;
}
.product-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.product-icon.blue { background: #eff6ff; }
.product-icon.green { background: #f0fdf4; }
.product-icon.purple { background: #f5f3ff; }
.product-icon.gray { background: #f9fafb; }
.product-status {
font-size: 12px;
}
.product-card-body {
padding: 12px 16px;
}
.product-name {
font-size: 15px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 4px;
}
.product-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
margin-bottom: 8px;
}
.product-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
}
.meta-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: #d1d5db;
}
.product-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
}
.product-price {
display: flex;
align-items: baseline;
gap: 2px;
}
.price-amount {
font-size: 16px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
}
.price-period {
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
}
</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="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>

View File

@@ -0,0 +1,824 @@
<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-52"
@press-enter="doSearch"
/>
<a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
提交工单
</a-button>
</a-space>
</template>
</a-page-header>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]">
<a-col :xs="12" :sm="6" v-for="s in statsCards" :key="s.label">
<div class="stat-card" :class="s.colorClass">
<div class="stat-value">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
</div>
</a-col>
</a-row>
<!-- 主内容 -->
<a-card :bordered="false" class="card">
<!-- 筛选栏 -->
<div class="filter-bar">
<a-segmented
v-model:value="statusFilter"
:options="statusOptions"
@change="doSearch"
/>
<a-space class="filter-right">
<a-select
v-model:value="appFilter"
allow-clear
placeholder="全部应用"
style="min-width: 160px"
@change="doSearch"
>
<a-select-option v-for="app in myApps" :key="app.productId" :value="app.productId">
{{ app.siteName || app.productName }}
</a-select-option>
</a-select>
<a-select
v-model:value="categoryFilter"
allow-clear
placeholder="全部分类"
style="min-width: 120px"
@change="doSearch"
>
<a-select-option v-for="c in categoryOptions" :key="c.value" :value="c.value">
{{ c.label }}
</a-select-option>
</a-select>
</a-space>
</div>
<a-alert v-if="error" class="mb-4" show-icon type="error" :message="error" />
<!-- 工单列表 -->
<a-spin :spinning="loading">
<div v-if="!loading && tickets.length === 0" class="empty-wrap">
<a-empty description="暂无工单">
<a-button type="primary" @click="openCreate">立即提交工单</a-button>
</a-empty>
</div>
<div v-else class="ticket-list">
<div
v-for="ticket in tickets"
:key="ticket.ticketId"
class="ticket-item"
:class="{ 'has-unread': ticket.hasUnread }"
@click="openDetail(ticket)"
>
<div class="ticket-item-left">
<div class="ticket-badge-row">
<a-tag :color="statusColor(ticket.status)" class="status-tag">
{{ statusLabel(ticket.status) }}
</a-tag>
<a-tag :color="priorityColor(ticket.priority)" class="priority-tag">
{{ priorityLabel(ticket.priority) }}
</a-tag>
<a-tag class="category-tag">{{ categoryLabel(ticket.category) }}</a-tag>
<span v-if="ticket.hasUnread" class="unread-dot" />
</div>
<div class="ticket-title">{{ ticket.title }}</div>
<div class="ticket-meta">
<span class="meta-app">📦 {{ ticket.productName || `应用 #${ticket.productId}` }}</span>
<span class="meta-no">{{ ticket.ticketNo }}</span>
<span v-if="ticket.attachments?.length" class="meta-attach">📎 {{ ticket.attachments.length }}个附件</span>
<span class="meta-time">{{ formatTime(ticket.createTime) }}</span>
</div>
</div>
<div class="ticket-item-right">
<div v-if="ticket.assigneeName" class="assignee">
<a-avatar :size="24" :src="ticket.assigneeAvatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<span class="assignee-name">{{ ticket.assigneeName }}</span>
</div>
<div v-else class="assignee-pending">待分配</div>
<div class="reply-count">
<MessageOutlined />
<span>{{ ticket.replyCount }}</span>
</div>
</div>
</div>
</div>
</a-spin>
<!-- 分页 -->
<div v-if="total > pageSize" class="pagination-wrap">
<a-pagination
v-model:current="currentPage"
:total="total"
:page-size="pageSize"
show-quick-jumper
:show-total="(t: number) => `${t}`"
@change="loadTickets"
/>
</div>
</a-card>
<!-- ========= 提交工单弹窗 ========= -->
<a-modal
v-model:open="showCreate"
title="提交工单"
width="640px"
:confirm-loading="submitLoading"
ok-text="提交"
@ok="handleSubmit"
@cancel="showCreate = false"
>
<a-form :model="form" :rules="rules" ref="formRef" layout="vertical" class="mt-2">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="工单标题" name="title">
<a-input v-model:value="form.title" placeholder="简短描述您的问题或需求" allow-clear />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="关联应用" name="productId">
<a-select v-model:value="form.productId" placeholder="选择关联应用" style="width: 100%">
<a-select-option v-for="app in myApps" :key="app.productId" :value="app.productId">
{{ app.siteName || app.productName }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="问题分类" name="category">
<a-select v-model:value="form.category" style="width: 100%">
<a-select-option v-for="c in categoryOptions" :key="c.value" :value="c.value">
{{ c.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="优先级" name="priority">
<a-select v-model:value="form.priority" style="width: 100%">
<a-select-option v-for="p in priorityOptions" :key="p.value" :value="p.value">
{{ p.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="问题描述" name="content">
<a-textarea
v-model:value="form.content"
:rows="6"
placeholder="详细描述您遇到的问题包括操作步骤期望效果实际效果等"
show-count
:maxlength="2000"
/>
</a-form-item>
<a-form-item label="附件可选">
<a-upload
v-model:file-list="createFileList"
:custom-request="handleCreateUpload"
:before-upload="beforeUpload"
:on-remove="handleCreateRemove"
multiple
:max-count="5"
list-type="text"
>
<a-button>
<template #icon><PlusOutlined /></template>
上传附件最多5个单个≤10MB
</a-button>
</a-upload>
</a-form-item>
<div class="form-tip">
<InfoCircleOutlined />
提交后将自动分配给对应应用的技术负责人,通常在 1-2 个工作日内响应
</div>
</a-form>
</a-modal>
<!-- ========= 工单详情抽屉 ========= -->
<a-drawer
v-model:open="showDetail"
:title="`工单详情 — ${currentTicket?.ticketNo}`"
width="600"
placement="right"
>
<template v-if="currentTicket">
<!-- 状态栏 -->
<div class="detail-status-bar">
<a-space size="small" wrap>
<a-tag :color="statusColor(currentTicket.status)" class="text-sm">
{{ statusLabel(currentTicket.status) }}
</a-tag>
<a-tag :color="priorityColor(currentTicket.priority)">
{{ priorityLabel(currentTicket.priority) }}
</a-tag>
<a-tag>{{ categoryLabel(currentTicket.category) }}</a-tag>
</a-space>
<div class="detail-actions">
<a-button
v-if="['pending','assigned','processing'].includes(currentTicket.status)"
size="small"
danger
@click="handleClose"
>
关闭工单
</a-button>
</div>
</div>
<!-- 工单信息 -->
<a-descriptions :column="2" size="small" class="detail-desc">
<a-descriptions-item label="工单编号">{{ currentTicket.ticketNo }}</a-descriptions-item>
<a-descriptions-item label="关联应用">
📦 {{ currentTicket.productName || `#${currentTicket.productId}` }}
</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ formatTime(currentTicket.createTime) }}</a-descriptions-item>
<a-descriptions-item label="处理人">
<span v-if="currentTicket.assigneeName">{{ currentTicket.assigneeName }}</span>
<a-tag v-else color="orange">待分配</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-divider />
<!-- 工单正文 -->
<div class="detail-section-title">问题描述</div>
<div class="detail-content">{{ currentTicket.content }}</div>
<div v-if="currentTicket.attachments?.length" class="detail-attachments">
<span class="detail-attachments-label">📎 附件:</span>
<a
v-for="url in currentTicket.attachments"
:key="url"
:href="getAttachmentUrl(url)"
target="_blank"
class="detail-attachment-link"
>
{{ url.split('/').slice(-1)[0] }}
</a>
</div>
<a-divider />
<!-- 回复列表 -->
<div class="detail-section-title">
沟通记录
<a-spin v-if="repliesLoading" size="small" style="margin-left: 8px" />
</div>
<div class="reply-list">
<div
v-for="reply in replies"
:key="reply.replyId"
class="reply-item"
:class="{ 'reply-staff': reply.isStaff }"
>
<a-avatar :size="32" :src="reply.userAvatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<div class="reply-body">
<div class="reply-header">
<span class="reply-name">{{ reply.userName }}</span>
<a-tag v-if="reply.isStaff" color="blue" class="staff-tag">技术人员</a-tag>
<span class="reply-time">{{ formatTime(reply.createTime) }}</span>
</div>
<div class="reply-content">{{ reply.content }}</div>
<div v-if="reply.attachments?.length" class="reply-attachments">
<a
v-for="url in reply.attachments"
:key="url"
:href="getAttachmentUrl(url)"
target="_blank"
class="reply-attachment-link"
>
📎 {{ url.split('/').slice(-1)[0] }}
</a>
</div>
</div>
</div>
<a-empty v-if="!repliesLoading && replies.length === 0" description="暂无回复" class="mt-4" />
</div>
<!-- 回复输入框 -->
<div
v-if="currentTicket && !['resolved','closed'].includes(currentTicket.status)"
class="reply-input-wrap"
>
<a-textarea
v-model:value="replyContent"
placeholder="输入您的回复..."
:rows="3"
:maxlength="1000"
show-count
/>
<a-upload
v-model:file-list="replyFileList"
:custom-request="handleReplyUpload"
:before-upload="beforeUpload"
:on-remove="handleReplyRemove"
multiple
:max-count="5"
list-type="text"
class="mt-2"
>
<a-button size="small">
<template #icon><PlusOutlined /></template>
添加附件
</a-button>
</a-upload>
<a-button
type="primary"
class="mt-2"
:loading="replyLoading"
:disabled="!replyContent.trim()"
@click="handleReply"
>
发送回复
</a-button>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import {
InfoCircleOutlined,
MessageOutlined,
PlusOutlined,
UserOutlined,
} from '@ant-design/icons-vue'
import {
getMyTickets,
submitTicket,
closeTicket,
getTicketReplies,
replyTicket,
getTicketStats,
} from '@/api/ticket'
import type { Ticket, TicketReply, TicketSubmitForm } from '@/api/ticket/model'
import { pageAppProductAll } from '@/api/app/appProduct'
import { uploadFile } from '@/api/system/file'
import type { UploadFile } from 'ant-design-vue'
definePageMeta({ layout: 'console' })
useHead({ title: '工单管理 - 控制台' })
// ─── 状态 & 筛选 ───────────────────────────────────────────────
const loading = ref(false)
const error = ref('')
const tickets = ref<Ticket[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = 15
const keywords = ref('')
const statusFilter = ref('all')
const appFilter = ref<number | undefined>(undefined)
const categoryFilter = ref<string | undefined>(undefined)
// ─── 应用列表(从 localStorage / API 获取) ───────────────────
const myApps = ref<{ productId: number; siteName?: string; productName?: string }[]>([])
// ─── 统计 ─────────────────────────────────────────────────────
const stats = ref({ total: 0, pending: 0, processing: 0, resolved: 0, closed: 0 })
const statsCards = computed(() => [
{ label: '全部工单', value: stats.value.total, colorClass: 'stat-default' },
{ label: '待处理', value: stats.value.pending, colorClass: 'stat-warning' },
{ label: '处理中', value: stats.value.processing, colorClass: 'stat-processing' },
{ label: '已解决', value: stats.value.resolved, colorClass: 'stat-success' },
])
// ─── 字典 ─────────────────────────────────────────────────────
const statusOptions = [
{ label: '全部', value: 'all' },
{ label: '待处理', value: 'pending' },
{ label: '已分配', value: 'assigned' },
{ label: '处理中', value: 'processing' },
{ label: '已解决', value: 'resolved' },
{ label: '已关闭', value: 'closed' },
]
const categoryOptions = [
{ label: '🐛 Bug 反馈', value: 'bug' },
{ label: '✨ 功能需求', value: 'feature' },
{ label: '❓ 咨询', value: 'consultation' },
{ label: '📢 投诉', value: 'complaint' },
{ label: '📋 其他', value: 'other' },
]
const priorityOptions = [
{ label: '⬇️ 低', value: 'low' },
{ label: '➡️ 普通', value: 'normal' },
{ label: '⬆️ 高', value: 'high' },
{ label: '🔥 紧急', value: 'urgent' },
]
function statusColor(s: string) {
return { pending: 'orange', assigned: 'blue', processing: 'geekblue', resolved: 'green', closed: 'default', rejected: 'red' }[s] || 'default'
}
function statusLabel(s: string) {
return { pending: '待处理', assigned: '已分配', processing: '处理中', resolved: '已解决', closed: '已关闭', rejected: '已拒绝' }[s] || s
}
function priorityColor(p: string) {
return { low: 'default', normal: 'blue', high: 'orange', urgent: 'red' }[p] || 'default'
}
function priorityLabel(p: string) {
return { low: '低', normal: '普通', high: '高', urgent: '紧急' }[p] || p
}
function categoryLabel(c: string) {
return { bug: '🐛 Bug', feature: '✨ 需求', consultation: '❓ 咨询', complaint: '📢 投诉', other: '📋 其他' }[c] || c
}
function formatTime(t: string) {
if (!t) return ''
const d = new Date(t)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
return d.toLocaleDateString('zh-CN')
}
// ─── 加载 ─────────────────────────────────────────────────────
async function loadTickets() {
loading.value = true
error.value = ''
try {
const res = await getMyTickets({
keywords: keywords.value || undefined,
status: statusFilter.value === 'all' ? undefined : (statusFilter.value as any),
productId: appFilter.value,
category: categoryFilter.value as any,
page: currentPage.value,
limit: pageSize,
})
tickets.value = (res?.data as any)?.data?.list || []
total.value = (res?.data as any)?.data?.count || 0
} catch (e: any) {
error.value = e?.message || '加载失败'
} finally {
loading.value = false
}
}
async function loadStats() {
try {
const res = await getTicketStats()
Object.assign(stats.value, (res?.data as any)?.data || {})
} catch {}
}
async function loadMyApps() {
try {
// 取当前登录用户 ID
const userId = Number(localStorage.getItem('UserId') || 0)
if (!userId) return
const res = await pageAppProductAll({
current: 1,
size: 200,
userId: userId,
})
myApps.value = res?.list ?? []
} catch {}
}
function doSearch() {
currentPage.value = 1
loadTickets()
}
// ─── 提交工单 ─────────────────────────────────────────────────
const showCreate = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const form = reactive<TicketSubmitForm>({
title: '',
content: '',
productId: undefined as any,
category: 'bug',
priority: 'normal',
attachments: [],
})
const rules = {
title: [{ required: true, message: '请输入工单标题' }],
productId: [{ required: true, message: '请选择关联应用' }],
content: [{ required: true, message: '请描述您的问题' }, { min: 10, message: '描述不少于10字' }],
}
// 附件上传
type UploadRequestOption = { file?: File; onSuccess?: (body: unknown, file: File) => void; onError?: (err: unknown) => void }
/** 只有图片类型才附加 OSS 图片处理参数 */
function getAttachmentUrl(url: string) {
const imageExts = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i
if (imageExts.test(url)) {
const base = url.split('?')[0]
return `${base}?x-oss-process=image/resize,w_750/quality,Q_90`
}
return url
}
const createFileList = ref<UploadFile[]>([])
const replyFileList = ref<UploadFile[]>([])
function beforeUpload(file: File) {
const maxMb = 10
if (file.size > maxMb * 1024 * 1024) {
message.error(`文件大小不能超过 ${maxMb}MB`)
return false
}
return true
}
async function handleCreateUpload(option: UploadRequestOption) {
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('上传成功但未返回文件地址')
form.attachments = [...(form.attachments || []), url]
createFileList.value = [
...createFileList.value.filter(f => f.status !== 'uploading'),
{ uid: url, name: rawFile.name, status: 'done', url } as UploadFile,
]
option.onSuccess?.(record, rawFile)
} catch (e) {
option.onError?.(e)
message.error(e instanceof Error ? e.message : '上传失败')
}
}
function handleCreateRemove(file: UploadFile) {
form.attachments = (form.attachments || []).filter(u => u !== file.url)
createFileList.value = createFileList.value.filter(f => f.uid !== file.uid)
}
async function handleReplyUpload(option: UploadRequestOption) {
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('上传成功但未返回文件地址')
replyAttachments.value = [...replyAttachments.value, url]
replyFileList.value = [
...replyFileList.value.filter(f => f.status !== 'uploading'),
{ uid: url, name: rawFile.name, status: 'done', url } as UploadFile,
]
option.onSuccess?.(record, rawFile)
} catch (e) {
option.onError?.(e)
message.error(e instanceof Error ? e.message : '上传失败')
}
}
function handleReplyRemove(file: UploadFile) {
replyAttachments.value = replyAttachments.value.filter(u => u !== file.url)
replyFileList.value = replyFileList.value.filter(f => f.uid !== file.uid)
}
function openCreate() {
showCreate.value = true
createFileList.value = []
form.attachments = []
}
async function handleSubmit() {
try {
await formRef.value?.validate()
} catch {
return
}
submitLoading.value = true
try {
// 将附件数组转为 JSON 字符串提交
const submitData = {
...form,
attachments: form.attachments?.length ? JSON.stringify(form.attachments) : null,
}
await submitTicket(submitData)
message.success('工单已提交,将自动分配给技术人员')
showCreate.value = false
Object.assign(form, { title: '', content: '', productId: undefined, category: 'bug', priority: 'normal', attachments: [] })
createFileList.value = []
await loadTickets()
await loadStats()
} catch (e: any) {
message.error(e?.message || '提交失败')
} finally {
submitLoading.value = false
}
}
// ─── 工单详情 ─────────────────────────────────────────────────
const showDetail = ref(false)
const currentTicket = ref<Ticket | null>(null)
const replies = ref<TicketReply[]>([])
const repliesLoading = ref(false)
const replyContent = ref('')
const replyLoading = ref(false)
const replyAttachments = ref<string[]>([])
async function openDetail(ticket: Ticket) {
currentTicket.value = ticket
showDetail.value = true
repliesLoading.value = true
replyContent.value = ''
replyAttachments.value = []
replyFileList.value = []
try {
const res = await getTicketReplies(ticket.ticketId)
replies.value = (res?.data as any)?.data || res?.data || []
} catch {} finally {
repliesLoading.value = false
}
}
async function handleReply() {
if (!replyContent.value.trim()) return
replyLoading.value = true
try {
await replyTicket({
ticketId: currentTicket.value!.ticketId,
content: replyContent.value.trim(),
attachments: replyAttachments.value.length ? JSON.stringify(replyAttachments.value) : undefined,
})
message.success('回复成功')
replyContent.value = ''
replyAttachments.value = []
replyFileList.value = []
const res = await getTicketReplies(currentTicket.value!.ticketId)
replies.value = (res?.data as any)?.data || res?.data || []
// 更新列表中的回复数
const t = tickets.value.find(t => t.ticketId === currentTicket.value!.ticketId)
if (t) t.replyCount++
} catch (e: any) {
message.error(e?.message || '回复失败')
} finally {
replyLoading.value = false
}
}
async function handleClose() {
try {
await closeTicket(currentTicket.value!.ticketId)
message.success('工单已关闭')
showDetail.value = false
await loadTickets()
await loadStats()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
// ─── 初始化 ───────────────────────────────────────────────────
onMounted(() => {
loadMyApps()
loadTickets()
loadStats()
})
</script>
<style scoped>
/* ─── 统计卡片 ─────────────────────────────────────── */
.stat-card {
border-radius: 10px;
padding: 16px 20px;
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.stat-label {
font-size: 12px;
margin-top: 6px;
opacity: 0.7;
}
.stat-default { background: #f5f5f5; color: #333; }
.stat-warning { background: #fff7e6; color: #d46b08; }
.stat-processing { background: #e6f4ff; color: #0958d9; }
.stat-success { background: #f6ffed; color: #389e0d; }
/* ─── 筛选栏 ─────────────────────────────────────── */
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.filter-right { flex-shrink: 0; }
/* ─── 工单列表 ─────────────────────────────────────── */
.ticket-list { display: flex; flex-direction: column; gap: 10px; }
.ticket-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 10px;
border: 1px solid #f0f0f0;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
.ticket-item:hover { border-color: #4f46e5; box-shadow: 0 2px 8px rgba(79,70,229,0.08); }
.ticket-item.has-unread { border-left: 3px solid #4f46e5; }
.ticket-item-left { flex: 1; min-width: 0; }
.ticket-badge-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; }
.status-tag, .priority-tag, .category-tag { font-size: 11px !important; padding: 0 6px !important; }
.unread-dot {
width: 7px; height: 7px;
background: #4f46e5; border-radius: 50%; display: inline-block;
}
.ticket-title {
font-size: 14px; font-weight: 500; color: #1a1a2e;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 4px;
}
.ticket-meta { display: flex; gap: 12px; font-size: 12px; color: #8c8c8c; flex-wrap: wrap; }
.meta-no { font-family: monospace; }
.ticket-item-right {
display: flex; flex-direction: column;
align-items: flex-end; gap: 8px; flex-shrink: 0; margin-left: 16px;
}
.assignee { display: flex; align-items: center; gap: 6px; }
.assignee-name { font-size: 12px; color: #595959; }
.assignee-pending { font-size: 12px; color: #faad14; }
.reply-count { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #8c8c8c; }
/* ─── 分页 ─────────────────────────────────────────── */
.pagination-wrap { display: flex; justify-content: flex-end; margin-top: 20px; }
.empty-wrap { padding: 40px 0; }
/* ─── 表单提示 ─────────────────────────────────────── */
.form-tip {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: #8c8c8c;
background: #f5f5f5; padding: 8px 12px; border-radius: 6px;
}
/* ─── 详情抽屉 ─────────────────────────────────────── */
.detail-status-bar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px; flex-wrap: wrap; gap: 8px;
}
.detail-desc { margin-bottom: 0; }
.detail-section-title {
font-size: 13px; font-weight: 600; color: #595959;
margin-bottom: 10px; display: flex; align-items: center;
}
.detail-content {
font-size: 14px; color: #262626; line-height: 1.7;
white-space: pre-wrap; background: #fafafa;
padding: 12px 14px; border-radius: 8px;
}
.detail-attachments {
margin-top: 8px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
}
.detail-attachments-label { font-size: 12px; color: #8c8c8c; }
.detail-attachment-link {
font-size: 12px; color: #1677ff; text-decoration: none;
background: #f0f5ff; padding: 2px 8px; border-radius: 4px; border: 1px solid #d6e4ff;
}
.detail-attachment-link:hover { text-decoration: underline; background: #e6f0ff; }
/* ─── 回复 ─────────────────────────────────────────── */
.reply-list { display: flex; flex-direction: column; gap: 14px; max-height: 320px; overflow-y: auto; }
.reply-item { display: flex; gap: 10px; align-items: flex-start; }
.reply-staff .reply-body { background: #e6f4ff; }
.reply-body {
flex: 1; background: #f5f5f5; border-radius: 8px; padding: 10px 12px;
}
.reply-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.reply-name { font-weight: 500; font-size: 13px; }
.staff-tag { font-size: 11px !important; padding: 0 5px !important; }
.reply-time { font-size: 11px; color: #8c8c8c; margin-left: auto; }
.reply-content { font-size: 13px; color: #262626; line-height: 1.6; white-space: pre-wrap; }
.reply-attachments { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; }
.reply-attachment-link { font-size: 12px; color: #1677ff; text-decoration: none; }
.reply-attachment-link:hover { text-decoration: underline; }
.reply-input-wrap { margin-top: 16px; }
/* ─── card ─────────────────────────────────────────── */
.card { border-radius: 12px; }
</style>