初始化2
This commit is contained in:
43
app/pages/console/.workbuddy/memory/2026-03-31.md
Normal file
43
app/pages/console/.workbuddy/memory/2026-03-31.md
Normal 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
|
||||
- 优惠券有三套 API:shopCoupon(模板)、shopUserCoupon(管理侧)、userCoupon(前台)
|
||||
28
app/pages/console/.workbuddy/memory/MEMORY.md
Normal file
28
app/pages/console/.workbuddy/memory/MEMORY.md
Normal 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()` API,fallback `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加载
|
||||
- 账号安全:增加登录日志 + 安全概览
|
||||
- 发票记录:添加订单关联 + 统计卡片
|
||||
- 未开通产品:从空壳改为产品展示页
|
||||
|
||||
## 用户偏好
|
||||
- (待补充)
|
||||
347
app/pages/console/account/index.vue
Normal file
347
app/pages/console/account/index.vue
Normal 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>
|
||||
456
app/pages/console/account/kyc.vue
Normal file
456
app/pages/console/account/kyc.vue
Normal 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>
|
||||
442
app/pages/console/account/members.vue
Normal file
442
app/pages/console/account/members.vue
Normal 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>
|
||||
377
app/pages/console/account/security.vue
Normal file
377
app/pages/console/account/security.vue
Normal 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>
|
||||
622
app/pages/console/app-review.vue
Normal file
622
app/pages/console/app-review.vue
Normal 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
781
app/pages/console/apps.vue
Normal 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>
|
||||
658
app/pages/console/coupons.vue
Normal file
658
app/pages/console/coupons.vue
Normal 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
597
app/pages/console/index.vue
Normal 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>
|
||||
326
app/pages/console/invites/index.vue
Normal file
326
app/pages/console/invites/index.vue
Normal 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>
|
||||
562
app/pages/console/invoices.vue
Normal file
562
app/pages/console/invoices.vue
Normal 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>
|
||||
32
app/pages/console/logout.vue
Normal file
32
app/pages/console/logout.vue
Normal 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>
|
||||
|
||||
539
app/pages/console/notifications.vue
Normal file
539
app/pages/console/notifications.vue
Normal 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>
|
||||
394
app/pages/console/orders.vue
Normal file
394
app/pages/console/orders.vue
Normal 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>
|
||||
550
app/pages/console/products.vue
Normal file
550
app/pages/console/products.vue
Normal 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>
|
||||
336
app/pages/console/tenant/index.vue
Normal file
336
app/pages/console/tenant/index.vue
Normal 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>
|
||||
336
app/pages/console/tenant/unopened.vue
Normal file
336
app/pages/console/tenant/unopened.vue
Normal 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>
|
||||
824
app/pages/console/tickets.vue
Normal file
824
app/pages/console/tickets.vue
Normal 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>
|
||||
Reference in New Issue
Block a user