初始版本
This commit is contained in:
17
app/pages/admin/.workbuddy/expert-history.json
Normal file
17
app/pages/admin/.workbuddy/expert-history.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 2,
|
||||
"sessions": {
|
||||
"0b38a56de2914c0bb5c07607a738e572": [
|
||||
{
|
||||
"expertId": "SeniorDeveloper",
|
||||
"name": "Will",
|
||||
"profession": "高级开发工程师",
|
||||
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
|
||||
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
|
||||
"usedAt": 1774917703744,
|
||||
"industryId": "all"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lastUpdated": 1774919990264
|
||||
}
|
||||
51
app/pages/admin/.workbuddy/memory/2026-03-31.md
Normal file
51
app/pages/admin/.workbuddy/memory/2026-03-31.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 2026-03-31 工作日志
|
||||
|
||||
## 平台管理功能完善
|
||||
|
||||
完成了 `/admin` 平台管理后台的所有功能页面,基于 Ant Design Vue 组件,与 `app/layouts/admin.vue` 框架衔接:
|
||||
|
||||
### 新建页面列表
|
||||
| 路由 | 文件 | 说明 |
|
||||
|---|---|---|
|
||||
| `/admin/apps` | `apps.vue` | 应用管理(全量应用列表、状态/官方/市场切换、删除) |
|
||||
| `/admin/market` | `market.vue` | 应用市场(市场上架列表、推荐开关、下架操作) |
|
||||
| `/admin/users` | `users.vue` | 用户管理(分页列表、冻结/解冻、重置密码) |
|
||||
| `/admin/developers` | `developers.vue` | 开发者管理(按userId聚合应用,弹窗查看详情) |
|
||||
| `/admin/tickets` | `tickets.vue` | 工单处理(分配、回复、状态更新) |
|
||||
| `/admin/articles` | `articles.vue` | 文章管理(CRUD + 推荐开关) |
|
||||
| `/admin/announcements` | `announcements.vue` | 公告管理(CRUD + 置顶开关,model=announcement区分) |
|
||||
| `/admin/settings` | `settings.vue` | 平台设置(基础/审核/市场/注册/通知/维护共6个tab) |
|
||||
|
||||
### 使用的 API
|
||||
- `@/api/cms/cmsWebsite` - 应用管理和市场
|
||||
- `@/api/system/user` - 用户管理
|
||||
- `@/api/ticket` - 工单系统
|
||||
- `@/api/cms/cmsArticle` - 文章/公告
|
||||
- `@/api/system/setting` - 平台设置
|
||||
|
||||
### 设计规范
|
||||
- 统一使用 stat-card 统计卡片 + panel 面板布局
|
||||
- 深红黑色调侧边栏(配合 admin.vue layout)
|
||||
- 所有页面支持分页、搜索、状态筛选
|
||||
|
||||
## 平台管理全面检查与完善(09:19)
|
||||
|
||||
### 修复项
|
||||
1. **tickets.vue** - ticket API 直接返回 axios response(无 ApiResult 包装),所有数据解析改为 `(res as any)?.data ?? res`,涵盖 loadTickets、loadStats、handleView、handleSubmitReply、handleAssign
|
||||
2. **market.vue** - loadSummary 推荐数查询重复(两个都是 `market:true`),改为从当前列表 filter 统计;去掉重复的 allSettled 入参
|
||||
3. **articles.vue** - statCards 全部文章 key 从 `undefined` 改为 `-1`;handleStatFilter 中 `-1 → undefined`;active 高亮判断适配 `filterStatus === undefined && stat.key === -1`
|
||||
|
||||
### 完善项
|
||||
4. **index.vue 首页** - 全面重构:加入实时统计数字(应用总数/用户总数/待审核/上架数)、待处理事项面板(带红点提示)、九宫格快速入口(覆盖全部9个页面)
|
||||
5. **公共样式** - 新建 `app/assets/css/admin-common.css`,提取 stat-card/panel/panel-header/page-header 等通用 class,注册到 nuxt.config.ts css 数组
|
||||
|
||||
### API 约定
|
||||
- ticket API:不经过 ApiResult 包装,直接返回 axios response,取值用 `res.data` 或 `res`
|
||||
- cmsWebsite/cmsArticle/user API:经过 ApiResult,返回 `res.data.data`(已在 API 层封装)
|
||||
|
||||
## admin 视角迁移收尾(23:00)
|
||||
|
||||
完成最后两项任务:
|
||||
1. **pages/admin/app-review.vue** 已确认存在(此前已完成),包含完整的审核列表、通过/拒绝弹窗、统计卡片功能
|
||||
2. **config/console-nav.ts** 清理了错误加入的应用审核入口(`console-app-review` 条目),同步移除了不再使用的 `AuditOutlined` import
|
||||
- 应用审核属于平台管理 admin 视角,不应出现在用户控制台导航
|
||||
23
app/pages/admin/.workbuddy/memory/MEMORY.md
Normal file
23
app/pages/admin/.workbuddy/memory/MEMORY.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# MEMORY.md - 项目长期记忆
|
||||
|
||||
## 项目基本信息
|
||||
- **项目路径**:`/Users/gxwebsoft/VUE/nuxt4-5`
|
||||
- **框架**:Nuxt 4 + Ant Design Vue + TypeScript
|
||||
- **UI风格**:管理后台使用深红黑色调(#1a0f0f),布局文件 `app/layouts/admin.vue`
|
||||
- **导航配置**:`app/config/admin-nav.ts`
|
||||
|
||||
## 平台管理后台(/admin)
|
||||
- **已完成页面**:index(首页)、app-review(应用审核)、apps(应用管理)、market(应用市场)、users(用户管理)、developers(开发者管理)、tickets(工单处理)、articles(文章管理)、announcements(公告管理)、settings(平台设置)
|
||||
- **权限校验**:`admin.vue` layout 通过 `isAdmin` 字段校验,非管理员看403
|
||||
- **公告与文章区分**:通过 `model: 'announcement'` 字段区分,共用 `cmsArticle` API
|
||||
|
||||
## API 约定
|
||||
- 应用管理:`pageCmsWebsiteAll` 是管理员专用分页接口
|
||||
- 用户API:`pageUsers` 来自 `@/api/system/user/index`(非 `/api/user`)
|
||||
- 工单API:base 路径 `/api/app/app//ticket`,返回结构 `{ list, count }`;**不经过 ApiResult 包装**,取值用 `(res as any)?.data ?? res`
|
||||
- 设置API:key-value存储,key格式 `platform_*`
|
||||
|
||||
## 设计规范
|
||||
- stat-card 统计卡片:4色系(blue/green/orange/red),可点击筛选
|
||||
- panel 面板:白底 + f0f0f0 边框 + 12px border-radius
|
||||
- 分页统一:`current/pageSize/total/showSizeChanger/showQuickJumper`
|
||||
508
app/pages/admin/all-apps.vue
Normal file
508
app/pages/admin/all-apps.vue
Normal file
@@ -0,0 +1,508 @@
|
||||
<template>
|
||||
<div class="all-apps-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 stats" :key="stat.key">
|
||||
<div
|
||||
class="stat-card"
|
||||
:class="[stat.color, { active: filterTenantId === 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 wrap>
|
||||
<a-select
|
||||
v-model:value="filterTenantId"
|
||||
style="width: 180px"
|
||||
allow-clear
|
||||
placeholder="全部租户"
|
||||
:loading="loadingTenants"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option v-for="t in tenantList" :key="t.tenantId" :value="t.tenantId">
|
||||
{{ t.tenantName }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="filterStatus" style="width: 130px" @change="handleSearch">
|
||||
<a-select-option value="">全部状态</a-select-option>
|
||||
<a-select-option :value="0">未开通</a-select-option>
|
||||
<a-select-option :value="1">运行中</a-select-option>
|
||||
<a-select-option :value="2">维护中</a-select-option>
|
||||
<a-select-option :value="3">已关闭</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="filterType" style="width: 140px" allow-clear placeholder="全部类型" @change="handleSearch">
|
||||
<a-select-option v-for="(name, key) in APP_TYPE_NAME" :key="key" :value="Number(key)">
|
||||
{{ name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索应用名称/标识/租户"
|
||||
style="width: 240px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="apps"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="productId"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
:scroll="{ x: 1400 }"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 所属租户 -->
|
||||
<template v-if="column.key === 'tenantName'">
|
||||
<a-tag>{{ record.tenantId }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 类型 -->
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag color="blue">{{ APP_TYPE_NAME[record.appType ?? 10] || '未知' }}</a-tag>
|
||||
<a-tag v-if="record.official === 1" color="gold" style="margin-left:4px">官方</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge :status="statusBadge(record.status)" :text="statusText(record.status)" />
|
||||
</template>
|
||||
|
||||
<!-- 发布状态 -->
|
||||
<template v-if="column.key === 'publishStatus'">
|
||||
<a-tag v-if="record.publishStatus" :color="pubStatusColor(record.publishStatus)">
|
||||
{{ pubStatusText(record.publishStatus) }}
|
||||
</a-tag>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 域名 -->
|
||||
<template v-if="column.key === 'domain'">
|
||||
<a v-if="record.domain" :href="'https://' + record.domain" target="_blank" class="domain-link">
|
||||
{{ record.domain }}
|
||||
</a>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 创建时间 -->
|
||||
<template v-if="column.key === 'createTime'">
|
||||
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">详情</a-button>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="link" size="small">更多 <DownOutlined /></a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => handleMoreAction(key as string, record)">
|
||||
<a-menu-item key="toggle-status">
|
||||
{{ record.status === 1 ? '🔒 暂停运行' : '▶️ 恢复运行' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="toggle-official">
|
||||
{{ record.official === 1 ? '取消官方' : '设为官方' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="toggle-market">
|
||||
{{ record.market === 1 ? '下架市场' : '上架市场' }}
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="delete" class="danger-item">🗑️ 删除应用</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showDetailModal"
|
||||
:title="`应用详情:${currentApp?.productName || ''}`"
|
||||
width="720px"
|
||||
: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="所属租户">
|
||||
<a-tag color="purple">{{ getTenantName(currentApp.tenantId) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="开发者">{{ currentApp.developer || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="应用类型">
|
||||
<a-tag color="blue">{{ APP_TYPE_NAME[currentApp.appType ?? 10] || '未知' }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="运行状态">
|
||||
<a-badge :status="statusBadge(currentApp.status)" :text="statusText(currentApp.status)" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="发布状态">
|
||||
<a-tag v-if="currentApp.publishStatus" :color="pubStatusColor(currentApp.publishStatus)">
|
||||
{{ pubStatusText(currentApp.publishStatus) }}
|
||||
</a-tag>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="绑定域名" :span="2">
|
||||
<a v-if="currentApp.domain" :href="'https://' + currentApp.domain" target="_blank">{{ currentApp.domain }}</a>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="ICP备案">{{ currentApp.icpNo || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="安装次数">{{ currentApp.installs ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="评分">{{ currentApp.rating ? currentApp.rating + ' ⭐' : '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="到期时间">{{ currentApp.expirationTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间" :span="2">{{ currentApp.createTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="currentApp.description" label="应用简介" :span="2">
|
||||
{{ currentApp.description }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, DownOutlined } from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { pageAppProduct, updateAppProduct, removeAppProduct } from '@/api/app/appProduct'
|
||||
import type { AppProduct } from '@/api/app/appProduct/model'
|
||||
import { APP_TYPE_NAME } from '@/api/app/appProduct/model'
|
||||
import { listTenant } from '@/api/system/tenant/index'
|
||||
import type { Tenant } from '@/api/system/tenant/model'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '全局应用管理 - 平台管理' })
|
||||
|
||||
const loading = ref(false)
|
||||
const apps = ref<AppProduct[]>([])
|
||||
const filterStatus = ref<number | ''>('')
|
||||
const filterType = ref<number | ''>('')
|
||||
const filterTenantId = ref<number | ''>('')
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
// 租户列表
|
||||
const tenantList = ref<Tenant[]>([])
|
||||
const loadingTenants = ref(false)
|
||||
|
||||
async function loadTenantList() {
|
||||
loadingTenants.value = true
|
||||
try {
|
||||
const res = await listTenant()
|
||||
tenantList.value = res || []
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loadingTenants.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getTenantName(tenantId?: number) {
|
||||
if (!tenantId) return '-'
|
||||
const tenant = tenantList.value.find(t => t.tenantId === tenantId)
|
||||
return tenant?.tenantName || `租户${tenantId}`
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
const stats = computed(() => {
|
||||
const total = apps.value.length
|
||||
const running = apps.value.filter(a => a.status === 1).length
|
||||
const maintenance = apps.value.filter(a => a.status === 2).length
|
||||
const closed = apps.value.filter(a => a.status === 3).length
|
||||
return [
|
||||
{ key: '', icon: '📦', label: '全部应用', value: pagination.total, color: 'blue' },
|
||||
{ key: 1, icon: '✅', label: '运行中', value: running, color: 'green' },
|
||||
{ key: 2, icon: '🔧', label: '维护中', value: maintenance, color: 'orange' },
|
||||
{ key: 3, icon: '⛔', label: '已关闭', value: closed, color: 'red' },
|
||||
]
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '应用信息', key: 'appInfo', width: 250, fixed: 'left' },
|
||||
{ title: '所属租户', key: 'tenantName', width: 150 },
|
||||
{ title: '类型', key: 'type', width: 140 },
|
||||
{ title: '运行状态', key: 'status', width: 110 },
|
||||
{ title: '发布状态', key: 'publishStatus', width: 110 },
|
||||
{ title: '绑定域名', key: 'domain', width: 180 },
|
||||
{ title: '创建时间', key: 'createTime', width: 110 },
|
||||
{ title: '操作', key: 'action', width: 160, fixed: 'right' },
|
||||
]
|
||||
|
||||
const showDetailModal = ref(false)
|
||||
const currentApp = ref<AppProduct | null>(null)
|
||||
|
||||
async function loadApps() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageAppProduct({
|
||||
current: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
status: filterStatus.value !== '' ? (filterStatus.value as number) : undefined,
|
||||
appType: filterType.value !== '' ? (filterType.value as number) : undefined,
|
||||
tenantId: filterTenantId.value !== '' ? (filterTenantId.value as number) : undefined,
|
||||
keywords: searchKeyword.value || undefined,
|
||||
})
|
||||
apps.value = res?.list || []
|
||||
pagination.total = res?.count || 0
|
||||
} catch {
|
||||
message.error('加载应用列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatFilter(key: number | '') {
|
||||
filterTenantId.value = key
|
||||
pagination.current = 1
|
||||
loadApps()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadApps()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadApps()
|
||||
}
|
||||
|
||||
function handleView(record: AppProduct) {
|
||||
currentApp.value = record
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
async function handleMoreAction(key: string, record: AppProduct) {
|
||||
if (key === 'toggle-status') {
|
||||
const newStatus = record.status === 1 ? 3 : 1
|
||||
try {
|
||||
await updateAppProduct({ productId: record.productId, status: newStatus })
|
||||
message.success('状态已更新')
|
||||
loadApps()
|
||||
} catch (e: any) { message.error(e?.message || '操作失败') }
|
||||
}
|
||||
if (key === 'toggle-official') {
|
||||
try {
|
||||
await updateAppProduct({ productId: record.productId, official: record.official ? 0 : 1 })
|
||||
message.success(record.official ? '已取消官方标记' : '已设为官方应用')
|
||||
loadApps()
|
||||
} catch (e: any) { message.error(e?.message || '操作失败') }
|
||||
}
|
||||
if (key === 'toggle-market') {
|
||||
try {
|
||||
await updateAppProduct({ productId: record.productId, market: record.market ? 0 : 1 })
|
||||
message.success(record.market ? '已从市场下架' : '已上架至应用市场')
|
||||
loadApps()
|
||||
} catch (e: any) { message.error(e?.message || '操作失败') }
|
||||
}
|
||||
if (key === 'delete') {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除应用「${record.productName}」吗?此操作不可恢复!`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await removeAppProduct(record.productId)
|
||||
message.success('应用已删除')
|
||||
loadApps()
|
||||
} catch (e: any) { message.error(e?.message || '删除失败') }
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(status?: number) {
|
||||
const map: Record<number, string> = { 0: '未开通', 1: '运行中', 2: '维护中', 3: '已关闭', 4: '已欠费', 5: '违规停止' }
|
||||
return map[status ?? -1] || '未知'
|
||||
}
|
||||
|
||||
function statusBadge(status?: number): 'success' | 'warning' | 'error' | 'default' {
|
||||
if (status === 1) return 'success'
|
||||
if (status === 2) return 'warning'
|
||||
if (status === 3 || status === 5) return 'error'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function pubStatusColor(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
developing: 'default',
|
||||
pending_review: 'orange',
|
||||
published: 'success',
|
||||
rejected: 'error',
|
||||
deprecated: 'default',
|
||||
}
|
||||
return map[status || ''] || 'default'
|
||||
}
|
||||
|
||||
function pubStatusText(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
developing: '开发中',
|
||||
pending_review: '待审核',
|
||||
published: '已上架',
|
||||
rejected: '已拒绝',
|
||||
deprecated: '已下架',
|
||||
}
|
||||
return map[status || ''] || '-'
|
||||
}
|
||||
|
||||
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(() => {
|
||||
loadTenantList()
|
||||
loadApps()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.all-apps-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;
|
||||
}
|
||||
|
||||
.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 { 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;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.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); }
|
||||
|
||||
.domain-link { font-size: 13px; color: #4f46e5; text-decoration: none; }
|
||||
.domain-link:hover { text-decoration: underline; }
|
||||
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-gray { color: rgba(0,0,0,0.45); }
|
||||
.text-gray-400 { color: #9ca3af; }
|
||||
|
||||
.danger-item { color: #ff4d4f !important; }
|
||||
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
</style>
|
||||
603
app/pages/admin/announcements.vue
Normal file
603
app/pages/admin/announcements.vue
Normal file
@@ -0,0 +1,603 @@
|
||||
<template>
|
||||
<div class="announcements-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">📢 公告管理</h2>
|
||||
<p class="page-desc">发布和管理平台公告,支持草稿、置顶、封面和预览</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
发布公告
|
||||
</a-button>
|
||||
<a-button @click="loadAnnouncements" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="[16, 16]" class="mb-6">
|
||||
<a-col :xs="12" :md="8">
|
||||
<div class="stat-card blue">
|
||||
<div class="stat-icon">📢</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ totalCount }}</div>
|
||||
<div class="stat-label">全部公告</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="8">
|
||||
<div class="stat-card green">
|
||||
<div class="stat-icon">✅</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ publishedCount }}</div>
|
||||
<div class="stat-label">已发布</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="8">
|
||||
<div class="stat-card orange">
|
||||
<div class="stat-icon">⭐</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ recommendCount }}</div>
|
||||
<div class="stat-label">置顶公告</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">📋 公告列表</span>
|
||||
<a-space wrap>
|
||||
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
|
||||
<a-select-option :value="undefined">全部状态</a-select-option>
|
||||
<a-select-option :value="0">已发布</a-select-option>
|
||||
<a-select-option :value="1">草稿</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索公告标题"
|
||||
style="width: 220px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="announcements"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="articleId"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'info'">
|
||||
<div class="ann-info-cell">
|
||||
<img v-if="record.image" :src="record.image" class="ann-thumb" />
|
||||
<div v-else class="ann-thumb-empty">📢</div>
|
||||
<div class="ann-info-text">
|
||||
<div class="ann-title">
|
||||
<span v-if="record.recommend" class="pin-badge">📌 置顶</span>
|
||||
{{ record.title }}
|
||||
</div>
|
||||
<div class="ann-overview">{{ record.overview || '暂无摘要' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 0 ? 'success' : 'default'">
|
||||
{{ record.status === 0 ? '已发布' : '草稿' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'views'">
|
||||
<span class="text-sm text-gray">👁 {{ record.actualViews || 0 }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'recommend'">
|
||||
<a-switch
|
||||
:checked="!!record.recommend"
|
||||
size="small"
|
||||
@change="(val: boolean) => handleTogglePin(record, val)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'createTime'">
|
||||
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">预览</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-popconfirm title="确认删除此公告?" @confirm="handleDelete(record)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showFormModal"
|
||||
:title="editing?.articleId ? '编辑公告' : '发布公告'"
|
||||
width="760px"
|
||||
:confirm-loading="saving"
|
||||
@ok="handleSave"
|
||||
@cancel="showFormModal = false"
|
||||
>
|
||||
<a-form :model="formData" layout="vertical">
|
||||
<a-form-item label="公告标题" required>
|
||||
<a-input
|
||||
v-model:value="formData.title"
|
||||
placeholder="请输入公告标题"
|
||||
:maxlength="200"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="封面图">
|
||||
<div class="cover-upload-wrap">
|
||||
<div v-if="formData.image" class="cover-preview-card">
|
||||
<img :src="formData.image" class="cover-preview-image" />
|
||||
<div class="cover-preview-actions">
|
||||
<a-button size="small" @click="handlePreviewImage(formData.image)">预览</a-button>
|
||||
<a-button size="small" danger @click="handleRemoveCover">移除</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<a-upload
|
||||
accept="image/*"
|
||||
:show-upload-list="false"
|
||||
:before-upload="beforeImageUpload"
|
||||
:custom-request="handleCoverUpload"
|
||||
>
|
||||
<a-button :loading="imageUploading">上传封面</a-button>
|
||||
</a-upload>
|
||||
<div class="field-hint">支持 jpg/png/webp,适合公告 banner 场景,单张不超过 5MB</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="公告摘要">
|
||||
<a-textarea
|
||||
v-model:value="formData.overview"
|
||||
:rows="2"
|
||||
placeholder="简短描述公告内容"
|
||||
:maxlength="300"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="公告内容" required>
|
||||
<a-textarea v-model:value="formData.content" :rows="10" placeholder="公告正文内容..." />
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="formData.status">
|
||||
<a-select-option :value="0">立即发布</a-select-option>
|
||||
<a-select-option :value="1">保存为草稿</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="是否置顶">
|
||||
<a-switch v-model:checked="formPin" />
|
||||
<span class="switch-tip">置顶公告将优先展示在列表顶部</span>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showPreviewModal"
|
||||
:title="previewData?.title || '公告预览'"
|
||||
width="760px"
|
||||
:footer="null"
|
||||
>
|
||||
<template v-if="previewData">
|
||||
<div class="preview-meta">
|
||||
<span class="text-sm text-gray">发布时间:{{ previewData.createTime?.substring(0, 16) || '-' }}</span>
|
||||
<a-tag v-if="previewData.recommend" color="orange">置顶</a-tag>
|
||||
<a-tag :color="previewData.status === 0 ? 'success' : 'default'">
|
||||
{{ previewData.status === 0 ? '已发布' : '草稿' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div v-if="previewData.image" class="preview-cover-wrap">
|
||||
<img :src="previewData.image" class="preview-cover" />
|
||||
</div>
|
||||
<div class="preview-summary" v-if="previewData.overview">{{ previewData.overview }}</div>
|
||||
<a-divider />
|
||||
<div class="preview-content" v-html="previewData.content || previewData.overview || '暂无内容'"></div>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:open="showImagePreview" title="封面预览" :footer="null" width="640px">
|
||||
<img v-if="previewImageUrl" :src="previewImageUrl" class="image-preview-modal" />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
pageAppArticle as pageCmsArticle,
|
||||
addAppArticle as addCmsArticle,
|
||||
updateAppArticle as updateCmsArticle,
|
||||
removeAppArticle as removeCmsArticle,
|
||||
} from '@/api/app/article'
|
||||
import { uploadFile } from '@/api/system/file'
|
||||
import type { AppArticle as CmsArticle } from '@/api/app/article/model'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '公告管理 - 平台管理' })
|
||||
|
||||
type UploadRequestOption = {
|
||||
file?: File
|
||||
onSuccess?: (body: unknown, file: File) => void
|
||||
onError?: (err: unknown) => void
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const imageUploading = ref(false)
|
||||
const announcements = ref<CmsArticle[]>([])
|
||||
const filterStatus = ref<number | undefined>(undefined)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const totalCount = ref(0)
|
||||
const publishedCount = ref(0)
|
||||
const recommendCount = ref(0)
|
||||
|
||||
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showSizeChanger: true, showQuickJumper: true })
|
||||
|
||||
const columns = [
|
||||
{ title: '公告信息', key: 'info', width: 420 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '阅读量', key: 'views', width: 100 },
|
||||
{ title: '置顶', key: 'recommend', width: 80 },
|
||||
{ title: '发布时间', key: 'createTime', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 170 },
|
||||
]
|
||||
|
||||
const showFormModal = ref(false)
|
||||
const saving = ref(false)
|
||||
const editing = ref<CmsArticle | null>(null)
|
||||
const formData = reactive<CmsArticle>({ title: '', overview: '', content: '', status: 0, image: '' })
|
||||
const formPin = ref(false)
|
||||
|
||||
const showPreviewModal = ref(false)
|
||||
const previewData = ref<CmsArticle | null>(null)
|
||||
const showImagePreview = ref(false)
|
||||
const previewImageUrl = ref('')
|
||||
|
||||
const ANNOUNCE_MODEL = 'announcement'
|
||||
|
||||
async function loadAnnouncements() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageCmsArticle({
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize,
|
||||
model: ANNOUNCE_MODEL,
|
||||
status: filterStatus.value,
|
||||
keywords: searchKeyword.value || undefined,
|
||||
})
|
||||
announcements.value = res?.list || []
|
||||
pagination.total = res?.count || 0
|
||||
loadStats()
|
||||
} catch {
|
||||
message.error('加载公告列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const [allRes, pubRes, pinRes] = await Promise.allSettled([
|
||||
pageCmsArticle({ page: 1, limit: 1, model: ANNOUNCE_MODEL }),
|
||||
pageCmsArticle({ page: 1, limit: 1, model: ANNOUNCE_MODEL, status: 0 }),
|
||||
pageCmsArticle({ page: 1, limit: 1, model: ANNOUNCE_MODEL, recommend: 1 }),
|
||||
])
|
||||
totalCount.value = allRes.status === 'fulfilled' ? allRes.value?.count || 0 : 0
|
||||
publishedCount.value = pubRes.status === 'fulfilled' ? pubRes.value?.count || 0 : 0
|
||||
recommendCount.value = pinRes.status === 'fulfilled' ? pinRes.value?.count || 0 : 0
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: { current: number; pageSize: number }) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(formData, {
|
||||
articleId: undefined,
|
||||
title: '',
|
||||
overview: '',
|
||||
content: '',
|
||||
status: 0,
|
||||
image: '',
|
||||
})
|
||||
formPin.value = false
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
editing.value = null
|
||||
resetForm()
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: CmsArticle) {
|
||||
editing.value = record
|
||||
Object.assign(formData, {
|
||||
articleId: record.articleId,
|
||||
title: record.title || '',
|
||||
overview: record.overview || '',
|
||||
content: record.content || '',
|
||||
status: record.status ?? 0,
|
||||
image: record.image || '',
|
||||
})
|
||||
formPin.value = !!record.recommend
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function handleView(record: CmsArticle) {
|
||||
previewData.value = record
|
||||
showPreviewModal.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formData.title?.trim()) {
|
||||
message.warning('请输入公告标题')
|
||||
return
|
||||
}
|
||||
if (!formData.content?.trim()) {
|
||||
message.warning('请输入公告内容')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const data: CmsArticle = {
|
||||
...formData,
|
||||
model: ANNOUNCE_MODEL,
|
||||
recommend: formPin.value ? 1 : 0,
|
||||
}
|
||||
if (editing.value?.articleId) {
|
||||
await updateCmsArticle(data)
|
||||
message.success('公告已更新')
|
||||
} else {
|
||||
await addCmsArticle(data)
|
||||
message.success('公告已发布')
|
||||
}
|
||||
showFormModal.value = false
|
||||
loadAnnouncements()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(record: CmsArticle) {
|
||||
try {
|
||||
await removeCmsArticle(record.articleId)
|
||||
message.success('公告已删除')
|
||||
loadAnnouncements()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePin(record: CmsArticle, val: boolean) {
|
||||
try {
|
||||
await updateCmsArticle({ articleId: record.articleId, recommend: val ? 1 : 0 })
|
||||
message.success(val ? '已置顶' : '已取消置顶')
|
||||
loadAnnouncements()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function beforeImageUpload(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error('只能上传图片文件')
|
||||
return false
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
message.error('图片大小不能超过 5MB')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleCoverUpload(option: UploadRequestOption) {
|
||||
const rawFile = option.file
|
||||
if (!rawFile) return
|
||||
|
||||
imageUploading.value = true
|
||||
try {
|
||||
const record = await uploadFile(rawFile)
|
||||
const url = (record?.url || record?.downloadUrl || '').trim()
|
||||
if (!url) throw new Error('上传成功但未返回图片地址')
|
||||
formData.image = url
|
||||
option.onSuccess?.(record, rawFile)
|
||||
message.success('封面上传成功')
|
||||
} catch (e) {
|
||||
option.onError?.(e)
|
||||
message.error(e instanceof Error ? e.message : '封面上传失败')
|
||||
} finally {
|
||||
imageUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveCover() {
|
||||
formData.image = ''
|
||||
}
|
||||
|
||||
function handlePreviewImage(url?: string) {
|
||||
if (!url) return
|
||||
previewImageUrl.value = url
|
||||
showImagePreview.value = true
|
||||
}
|
||||
|
||||
onMounted(() => loadAnnouncements())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.announcements-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; }
|
||||
.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;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
|
||||
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
|
||||
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
|
||||
.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;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
|
||||
|
||||
.ann-info-cell { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.ann-thumb {
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
.ann-thumb-empty {
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ann-info-text { flex: 1; min-width: 0; }
|
||||
.ann-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ann-overview {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 320px;
|
||||
}
|
||||
.pin-badge {
|
||||
font-size: 11px;
|
||||
color: #f97316;
|
||||
background: #fff7ed;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
margin-right: 6px;
|
||||
border: 1px solid #fed7aa;
|
||||
}
|
||||
|
||||
.cover-upload-wrap { display: flex; flex-direction: column; gap: 10px; }
|
||||
.cover-preview-card {
|
||||
width: 240px;
|
||||
padding: 8px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.cover-preview-image {
|
||||
width: 100%;
|
||||
height: 132px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
.cover-preview-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.field-hint { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
|
||||
.switch-tip { margin-left: 8px; font-size: 12px; color: rgba(0, 0, 0, 0.45); }
|
||||
|
||||
.preview-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
|
||||
.preview-cover-wrap { margin: 16px 0 12px; }
|
||||
.preview-cover {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
.preview-summary {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #fafafa;
|
||||
border-radius: 10px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.preview-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.image-preview-modal {
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-gray { color: rgba(0, 0, 0, 0.45); }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
</style>
|
||||
763
app/pages/admin/app-review.vue
Normal file
763
app/pages/admin/app-review.vue
Normal file
@@ -0,0 +1,763 @@
|
||||
<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 === 'appType'">
|
||||
<a-tag color="blue">{{ APP_TYPE_NAME[record.appType ?? 10] || '未知' }}</a-tag>
|
||||
</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="应用类型">
|
||||
<a-tag color="blue">{{ APP_TYPE_NAME[currentApp.appType ?? 10] || '未知' }}</a-tag>
|
||||
</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="提交时间">{{ 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="currentAppEntries.length > 0" class="entry-section">
|
||||
<h4 class="entry-title">🚀 应用入口</h4>
|
||||
<div class="entry-list">
|
||||
<div
|
||||
v-for="entry in currentAppEntries"
|
||||
:key="entry.type"
|
||||
class="entry-item"
|
||||
:class="{ disabled: !entry.available }"
|
||||
>
|
||||
<div class="entry-info">
|
||||
<component :is="entryIcon(entry.icon)" style="font-size: 18px; margin-right: 8px;" />
|
||||
<div>
|
||||
<div class="entry-label">{{ entry.label }}</div>
|
||||
<div class="entry-url" v-if="entry.url">{{ entry.type === 'scan-qr' ? '已配置小程序码' : entry.url }}</div>
|
||||
<div class="entry-url text-gray-400" v-else>未配置</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="entry.available"
|
||||
type="primary"
|
||||
size="small"
|
||||
ghost
|
||||
@click="handleEntryClick(entry)"
|
||||
>
|
||||
{{ entry.type === 'visit-site' ? '访问' : entry.type === 'download' ? '下载' : entry.type === 'scan-qr' ? '扫码' : '进入' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 待审核时的操作区 -->
|
||||
<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>
|
||||
|
||||
<!-- 扫码弹窗 -->
|
||||
<QrCodeModal
|
||||
v-model:open="showQrModal"
|
||||
:title="qrModalTitle"
|
||||
:tip="qrModalTip"
|
||||
:image-url="qrModalUrl"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, UserOutlined, GlobalOutlined, QrcodeOutlined, DownloadOutlined, SettingOutlined } 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'
|
||||
import { APP_TYPE_NAME } from '@/api/app/appProduct/model'
|
||||
import { getAppEntries, executeEntry, getScanTip } from '@/utils/appEntry'
|
||||
import type { AppEntry } from '@/utils/appEntry'
|
||||
import QrCodeModal from '@/components/QrCodeModal.vue'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
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: 'appType', width: 120 },
|
||||
{ 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]
|
||||
}
|
||||
|
||||
const showQrModal = ref(false)
|
||||
const qrModalTitle = ref('')
|
||||
const qrModalTip = ref('')
|
||||
const qrModalUrl = ref('')
|
||||
|
||||
const currentAppEntries = computed(() => {
|
||||
if (!currentApp.value) return []
|
||||
return getAppEntries(currentApp.value)
|
||||
})
|
||||
|
||||
function entryIcon(iconName: string) {
|
||||
const map: Record<string, any> = {
|
||||
GlobalOutlined,
|
||||
QrcodeOutlined,
|
||||
DownloadOutlined,
|
||||
SettingOutlined,
|
||||
}
|
||||
return map[iconName] || GlobalOutlined
|
||||
}
|
||||
|
||||
function handleEntryClick(entry: AppEntry) {
|
||||
const needQr = executeEntry(entry)
|
||||
if (needQr && entry.type === 'scan-qr' && currentApp.value) {
|
||||
qrModalTitle.value = currentApp.value.productName || '小程序码'
|
||||
qrModalTip.value = getScanTip(currentApp.value.appType || 20)
|
||||
qrModalUrl.value = entry.url || ''
|
||||
showQrModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
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); }
|
||||
.text-gray-400 { color: #9ca3af; }
|
||||
|
||||
.entry-section {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0,0,0,0.85);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.entry-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.entry-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.entry-item.disabled { opacity: 0.5; }
|
||||
|
||||
.entry-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0,0,0,0.85);
|
||||
}
|
||||
|
||||
.entry-url {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 400px;
|
||||
}
|
||||
</style>
|
||||
656
app/pages/admin/apps.vue
Normal file
656
app/pages/admin/apps.vue
Normal file
@@ -0,0 +1,656 @@
|
||||
<template>
|
||||
<div class="apps-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 stats" :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 wrap>
|
||||
<a-select v-model:value="filterStatus" style="width: 130px" @change="handleSearch">
|
||||
<a-select-option value="">全部状态</a-select-option>
|
||||
<a-select-option :value="0">未开通</a-select-option>
|
||||
<a-select-option :value="1">运行中</a-select-option>
|
||||
<a-select-option :value="2">维护中</a-select-option>
|
||||
<a-select-option :value="3">已关闭</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="filterType" style="width: 140px" allow-clear placeholder="全部类型" @change="handleSearch">
|
||||
<a-select-option v-for="(name, key) in APP_TYPE_NAME" :key="key" :value="Number(key)">
|
||||
{{ name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-model:value="filterTenantId"
|
||||
style="width: 180px"
|
||||
allow-clear
|
||||
placeholder="筛选租户"
|
||||
:loading="loadingTenants"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option v-for="t in tenantList" :key="t.tenantId" :value="t.tenantId">
|
||||
{{ t.tenantName }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索应用名称/标识"
|
||||
style="width: 210px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="apps"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="productId"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
>
|
||||
<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 || record.username">
|
||||
<UserOutlined style="font-size:11px;margin-right:3px" />{{ record.developer || record.username }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 类型 -->
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag color="blue">{{ APP_TYPE_NAME[record.appType ?? 10] || '未知' }}</a-tag>
|
||||
<a-tag v-if="record.official === 1" color="gold" style="margin-left:4px">官方</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 所属租户 -->
|
||||
<template v-if="column.key === 'tenantName'">
|
||||
<span class="tenant-name-cell" v-if="record.tenantId">
|
||||
{{ getTenantName(record.tenantId) }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge :status="statusBadge(record.status)" :text="statusText(record.status)" />
|
||||
</template>
|
||||
|
||||
<!-- 发布状态 -->
|
||||
<template v-if="column.key === 'publishStatus'">
|
||||
<a-tag v-if="record.publishStatus" :color="pubStatusColor(record.publishStatus)">
|
||||
{{ pubStatusText(record.publishStatus) }}
|
||||
</a-tag>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 域名 -->
|
||||
<template v-if="column.key === 'domain'">
|
||||
<a v-if="record.domain" :href="'https://' + record.domain" target="_blank" class="domain-link">
|
||||
{{ record.domain }}
|
||||
</a>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 创建时间 -->
|
||||
<template v-if="column.key === 'createTime'">
|
||||
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">详情</a-button>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="link" size="small">更多 <DownOutlined /></a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => handleMoreAction(key as string, record)">
|
||||
<a-menu-item key="toggle-status">
|
||||
{{ record.status === 1 ? '🔒 暂停运行' : '▶️ 恢复运行' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="toggle-official">
|
||||
{{ record.official === 1 ? '取消官方' : '设为官方' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="toggle-market">
|
||||
{{ record.market === 1 ? '下架市场' : '上架市场' }}
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="delete" class="danger-item">🗑️ 删除应用</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showDetailModal"
|
||||
:title="`应用详情:${currentApp?.productName || ''}`"
|
||||
width="720px"
|
||||
: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="blue">{{ APP_TYPE_NAME[currentApp.appType ?? 10] || '未知' }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="运行状态">
|
||||
<a-badge :status="statusBadge(currentApp.status)" :text="statusText(currentApp.status)" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="发布状态">
|
||||
<a-tag v-if="currentApp.publishStatus" :color="pubStatusColor(currentApp.publishStatus)">
|
||||
{{ pubStatusText(currentApp.publishStatus) }}
|
||||
</a-tag>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="绑定域名" :span="2">
|
||||
<a v-if="currentApp.domain" :href="'https://' + currentApp.domain" target="_blank">{{ currentApp.domain }}</a>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="ICP备案">{{ currentApp.icpNo || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="安装次数">{{ currentApp.installs ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="评分">{{ currentApp.rating ? currentApp.rating + ' ⭐' : '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="到期时间">{{ currentApp.expirationTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间" :span="2">{{ currentApp.createTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="currentApp.description" label="应用简介" :span="2">
|
||||
{{ currentApp.description }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 应用入口信息 -->
|
||||
<div v-if="currentAppEntries.length > 0" class="entry-section">
|
||||
<h4 class="entry-title">🚀 应用入口</h4>
|
||||
<div class="entry-list">
|
||||
<div
|
||||
v-for="entry in currentAppEntries"
|
||||
:key="entry.type"
|
||||
class="entry-item"
|
||||
:class="{ disabled: !entry.available }"
|
||||
>
|
||||
<div class="entry-info">
|
||||
<component :is="entryIcon(entry.icon)" style="font-size: 18px; margin-right: 8px;" />
|
||||
<div>
|
||||
<div class="entry-label">{{ entry.label }}</div>
|
||||
<div class="entry-url" v-if="entry.url">{{ entry.type === 'scan-qr' ? '已配置小程序码' : entry.url }}</div>
|
||||
<div class="entry-url text-gray-400" v-else>未配置</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="entry.available"
|
||||
type="primary"
|
||||
size="small"
|
||||
ghost
|
||||
@click="handleEntryClick(entry)"
|
||||
>
|
||||
{{ entry.type === 'visit-site' ? '访问' : entry.type === 'download' ? '下载' : entry.type === 'scan-qr' ? '扫码' : '进入' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 扫码弹窗 -->
|
||||
<QrCodeModal
|
||||
v-model:open="showQrModal"
|
||||
:title="qrModalTitle"
|
||||
:tip="qrModalTip"
|
||||
:image-url="qrModalUrl"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, UserOutlined, DownOutlined, GlobalOutlined, QrcodeOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { pageAppProduct, updateAppProduct, removeAppProduct } from '@/api/app/appProduct'
|
||||
import type { AppProduct } from '@/api/app/appProduct/model'
|
||||
import { APP_TYPE, APP_TYPE_NAME } from '@/api/app/appProduct/model'
|
||||
import { getAppEntries, executeEntry, getScanTip } from '@/utils/appEntry'
|
||||
import type { AppEntry } from '@/utils/appEntry'
|
||||
import QrCodeModal from '@/components/QrCodeModal.vue'
|
||||
import { listTenant } from '@/api/system/tenant/index'
|
||||
import type { Tenant } from '@/api/system/tenant/model'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '应用管理 - 平台管理' })
|
||||
|
||||
const loading = ref(false)
|
||||
const apps = ref<AppProduct[]>([])
|
||||
const filterStatus = ref<number | ''>('')
|
||||
const filterType = ref<number | ''>('')
|
||||
const filterTenantId = ref<number | ''>('')
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 租户列表
|
||||
const tenantList = ref<Tenant[]>([])
|
||||
const loadingTenants = ref(false)
|
||||
|
||||
async function loadTenantList() {
|
||||
loadingTenants.value = true
|
||||
try {
|
||||
const res = await listTenant()
|
||||
tenantList.value = res || []
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loadingTenants.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getTenantName(tenantId?: number) {
|
||||
if (!tenantId) return '-'
|
||||
const tenant = tenantList.value.find(t => t.tenantId === tenantId)
|
||||
return tenant?.tenantName || `租户${tenantId}`
|
||||
}
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
const stats = reactive([
|
||||
{ key: 1, icon: '✅', label: '运行中', value: 0, color: 'green' },
|
||||
{ key: 2, icon: '🔧', label: '维护中', value: 0, color: 'orange' },
|
||||
{ key: 3, icon: '⛔', label: '已关闭', value: 0, color: 'red' },
|
||||
{ key: '', icon: '📦', label: '全部应用', value: 0, color: 'blue' },
|
||||
])
|
||||
|
||||
const columns = [
|
||||
{ title: '应用信息', key: 'appInfo', width: 250 },
|
||||
{ title: '所属租户', key: 'tenantName', width: 150 },
|
||||
{ title: '类型', key: 'type', width: 120 },
|
||||
{ title: '运行状态', key: 'status', width: 110 },
|
||||
{ title: '发布状态', key: 'publishStatus', width: 110 },
|
||||
{ title: '绑定域名', key: 'domain', width: 180 },
|
||||
{ title: '创建时间', key: 'createTime', width: 110 },
|
||||
{ title: '操作', key: 'action', width: 160 },
|
||||
]
|
||||
|
||||
const showDetailModal = ref(false)
|
||||
const currentApp = ref<AppProduct | null>(null)
|
||||
|
||||
async function loadApps() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageAppProduct({
|
||||
current: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
status: filterStatus.value !== '' ? (filterStatus.value as number) : undefined,
|
||||
appType: filterType.value !== '' ? (filterType.value as number) : undefined,
|
||||
keywords: searchKeyword.value || undefined,
|
||||
tenantId: filterTenantId.value !== '' ? (filterTenantId.value as number) : undefined,
|
||||
})
|
||||
apps.value = res?.list || []
|
||||
pagination.total = res?.count || 0
|
||||
updateStats()
|
||||
} catch {
|
||||
message.error('加载应用列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStats() {
|
||||
try {
|
||||
const [runningRes, maintenanceRes, closedRes, allRes] = await Promise.allSettled([
|
||||
pageAppProduct({ current: 1, size: 1, status: 1 }),
|
||||
pageAppProduct({ current: 1, size: 1, status: 2 }),
|
||||
pageAppProduct({ current: 1, size: 1, status: 3 }),
|
||||
pageAppProduct({ current: 1, size: 1 }),
|
||||
])
|
||||
if (runningRes.status === 'fulfilled') stats[0].value = runningRes.value?.count || 0
|
||||
if (maintenanceRes.status === 'fulfilled') stats[1].value = maintenanceRes.value?.count || 0
|
||||
if (closedRes.status === 'fulfilled') stats[2].value = closedRes.value?.count || 0
|
||||
if (allRes.status === 'fulfilled') stats[3].value = allRes.value?.count || 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleStatFilter(key: number | '') {
|
||||
filterStatus.value = key
|
||||
pagination.current = 1
|
||||
loadApps()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadApps()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadApps()
|
||||
}
|
||||
|
||||
function handleView(record: AppProduct) {
|
||||
currentApp.value = record
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
async function handleMoreAction(key: string, record: AppProduct) {
|
||||
if (key === 'toggle-status') {
|
||||
const newStatus = record.status === 1 ? 3 : 1
|
||||
try {
|
||||
await updateAppProduct({ productId: record.productId, status: newStatus })
|
||||
message.success('状态已更新')
|
||||
loadApps()
|
||||
} catch (e: any) { message.error(e?.message || '操作失败') }
|
||||
}
|
||||
if (key === 'toggle-official') {
|
||||
try {
|
||||
await updateAppProduct({ productId: record.productId, official: !record.official })
|
||||
message.success(record.official ? '已取消官方标记' : '已设为官方应用')
|
||||
loadApps()
|
||||
} catch (e: any) { message.error(e?.message || '操作失败') }
|
||||
}
|
||||
if (key === 'toggle-market') {
|
||||
try {
|
||||
await updateAppProduct({ productId: record.productId, market: !record.market })
|
||||
message.success(record.market ? '已从市场下架' : '已上架至应用市场')
|
||||
loadApps()
|
||||
} catch (e: any) { message.error(e?.message || '操作失败') }
|
||||
}
|
||||
if (key === 'delete') {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除应用「${record.productName}」吗?此操作不可恢复!`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await removeAppProduct(record.productId)
|
||||
message.success('应用已删除')
|
||||
loadApps()
|
||||
} catch (e: any) { message.error(e?.message || '删除失败') }
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(status?: number) {
|
||||
const map: Record<number, string> = { 0: '未开通', 1: '运行中', 2: '维护中', 3: '已关闭', 4: '已欠费', 5: '违规停止' }
|
||||
return map[status ?? -1] || '未知'
|
||||
}
|
||||
|
||||
function statusBadge(status?: number): 'success' | 'warning' | 'error' | 'default' {
|
||||
if (status === 1) return 'success'
|
||||
if (status === 2) return 'warning'
|
||||
if (status === 3 || status === 5) return 'error'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function pubStatusColor(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
developing: 'default',
|
||||
pending_review: 'orange',
|
||||
published: 'success',
|
||||
rejected: 'error',
|
||||
deprecated: 'default',
|
||||
}
|
||||
return map[status || ''] || 'default'
|
||||
}
|
||||
|
||||
function pubStatusText(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
developing: '开发中',
|
||||
pending_review: '待审核',
|
||||
published: '已上架',
|
||||
rejected: '已拒绝',
|
||||
deprecated: '已下架',
|
||||
}
|
||||
return map[status || ''] || '-'
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
const showQrModal = ref(false)
|
||||
const qrModalTitle = ref('')
|
||||
const qrModalTip = ref('')
|
||||
const qrModalUrl = ref('')
|
||||
|
||||
const currentAppEntries = computed(() => {
|
||||
if (!currentApp.value) return []
|
||||
return getAppEntries(currentApp.value)
|
||||
})
|
||||
|
||||
/** 入口图标组件映射 */
|
||||
function entryIcon(iconName: string) {
|
||||
const map: Record<string, any> = {
|
||||
GlobalOutlined,
|
||||
QrcodeOutlined,
|
||||
DownloadOutlined,
|
||||
SettingOutlined,
|
||||
}
|
||||
return map[iconName] || GlobalOutlined
|
||||
}
|
||||
|
||||
function handleEntryClick(entry: AppEntry) {
|
||||
const needQr = executeEntry(entry)
|
||||
if (needQr && entry.type === 'scan-qr' && currentApp.value) {
|
||||
qrModalTitle.value = currentApp.value.productName || '小程序码'
|
||||
qrModalTip.value = getScanTip(currentApp.value.appType || 20)
|
||||
qrModalUrl.value = entry.url || ''
|
||||
showQrModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTenantList()
|
||||
loadApps()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.apps-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;
|
||||
}
|
||||
|
||||
.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 { 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;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.tenant-name-cell {
|
||||
font-size: 13px;
|
||||
color: rgba(0,0,0,0.75);
|
||||
}
|
||||
|
||||
.domain-link { font-size: 13px; color: #4f46e5; text-decoration: none; }
|
||||
.domain-link:hover { text-decoration: underline; }
|
||||
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-gray { color: rgba(0,0,0,0.45); }
|
||||
.text-gray-400 { color: #9ca3af; }
|
||||
|
||||
.danger-item { color: #ff4d4f !important; }
|
||||
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
|
||||
.entry-section {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0,0,0,0.85);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.entry-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.entry-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.entry-item.disabled { opacity: 0.5; }
|
||||
|
||||
.entry-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0,0,0,0.85);
|
||||
}
|
||||
|
||||
.entry-url {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 400px;
|
||||
}
|
||||
</style>
|
||||
516
app/pages/admin/article-categories.vue
Normal file
516
app/pages/admin/article-categories.vue
Normal file
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<div class="article-categories-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">🗂️ 文章分类</h2>
|
||||
<p class="page-desc">统一维护文章分类、层级、排序与展示状态</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button @click="navigateTo('/admin/articles')">返回文章管理</a-button>
|
||||
<a-button @click="loadCategories" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增分类
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="[16, 16]" class="mb-6">
|
||||
<a-col :xs="12" :md="8">
|
||||
<div class="stat-card blue">
|
||||
<div class="stat-icon">🗂️</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ categories.length }}</div>
|
||||
<div class="stat-label">全部分类</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="8">
|
||||
<div class="stat-card green">
|
||||
<div class="stat-icon">✅</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ enabledCount }}</div>
|
||||
<div class="stat-label">启用中</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="8">
|
||||
<div class="stat-card orange">
|
||||
<div class="stat-icon">⭐</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ recommendCount }}</div>
|
||||
<div class="stat-label">推荐分类</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">📋 分类列表</span>
|
||||
<a-space wrap>
|
||||
<a-select v-model:value="filterStatus" style="width: 140px" @change="handleSearch">
|
||||
<a-select-option :value="undefined">全部状态</a-select-option>
|
||||
<a-select-option :value="0">正常</a-select-option>
|
||||
<a-select-option :value="1">禁用</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
style="width: 240px"
|
||||
placeholder="搜索分类名称 / 标识 / 路径"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="pagedCategories"
|
||||
:loading="loading"
|
||||
:pagination="tablePagination"
|
||||
row-key="categoryId"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'info'">
|
||||
<div class="category-info-cell">
|
||||
<div class="category-title-row">
|
||||
<span class="category-title">{{ record.title || '-' }}</span>
|
||||
<a-tag v-if="record.parentId" color="blue">上级:{{ resolveParentName(record.parentId) }}</a-tag>
|
||||
</div>
|
||||
<div class="category-meta">
|
||||
<span v-if="record.categoryCode">标识:{{ record.categoryCode }}</span>
|
||||
<span v-if="record.path" class="meta-item">路径:{{ record.path }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag>{{ typeText(record.type) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'sortNumber'">
|
||||
<span>{{ record.sortNumber ?? 0 }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'count'">
|
||||
<span>{{ record.count ?? 0 }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 0 ? 'success' : 'default'">
|
||||
{{ record.status === 0 ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'flags'">
|
||||
<div class="flag-list">
|
||||
<a-tag v-if="record.recommend" color="gold">推荐</a-tag>
|
||||
<a-tag v-if="record.showIndex" color="green">首页</a-tag>
|
||||
<a-tag v-if="record.hide" color="default">隐藏</a-tag>
|
||||
<span v-if="!record.recommend && !record.showIndex && !record.hide" class="text-gray">-</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'createTime'">
|
||||
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-popconfirm title="确认删除此分类?" @confirm="handleDelete(record)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showFormModal"
|
||||
:title="editingCategory?.categoryId ? '编辑分类' : '新增分类'"
|
||||
width="720px"
|
||||
:confirm-loading="saving"
|
||||
@ok="handleSave"
|
||||
@cancel="showFormModal = false"
|
||||
>
|
||||
<a-form :model="formData" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="分类名称" required>
|
||||
<a-input
|
||||
v-model:value="formData.title"
|
||||
placeholder="请输入分类名称"
|
||||
:maxlength="80"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="分类标识">
|
||||
<a-input
|
||||
v-model:value="formData.categoryCode"
|
||||
placeholder="例如 news / tutorial"
|
||||
:maxlength="60"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="上级分类">
|
||||
<a-select
|
||||
v-model:value="formData.parentId"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
placeholder="无上级则留空"
|
||||
:options="parentOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="分类类型">
|
||||
<a-select v-model:value="formData.type">
|
||||
<a-select-option :value="0">列表</a-select-option>
|
||||
<a-select-option :value="1">单页</a-select-option>
|
||||
<a-select-option :value="2">外链</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="访问路径">
|
||||
<a-input v-model:value="formData.path" placeholder="例如 /news 或 https://example.com" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="排序值">
|
||||
<a-input-number v-model:value="formData.sortNumber" :min="0" :precision="0" class="w-full" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="formData.status">
|
||||
<a-select-option :value="0">正常</a-select-option>
|
||||
<a-select-option :value="1">禁用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="推荐分类">
|
||||
<a-switch v-model:checked="formRecommend" />
|
||||
<span class="switch-tip">用于前台推荐位</span>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="首页显示">
|
||||
<a-switch v-model:checked="formShowIndex" />
|
||||
<span class="switch-tip">首页导航可见</span>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="是否隐藏">
|
||||
<a-switch v-model:checked="formHide" />
|
||||
<span class="switch-tip">仅注册不展示</span>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
listAppArticleCategory as listCmsArticleCategory,
|
||||
addAppArticleCategory as addCmsArticleCategory,
|
||||
updateAppArticleCategory as updateCmsArticleCategory,
|
||||
removeAppArticleCategory as removeCmsArticleCategory,
|
||||
} from '@/api/app/articleCategory'
|
||||
import type { AppArticleCategory as CmsArticleCategory } from '@/api/app/articleCategory/model'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '文章分类 - 平台管理' })
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const categories = ref<CmsArticleCategory[]>([])
|
||||
const filterStatus = ref<number | undefined>(undefined)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '分类信息', key: 'info', width: 360 },
|
||||
{ title: '类型', key: 'type', width: 100 },
|
||||
{ title: '排序', key: 'sortNumber', width: 90 },
|
||||
{ title: '文章数', key: 'count', width: 90 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '标记', key: 'flags', width: 180 },
|
||||
{ title: '创建时间', key: 'createTime', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 140 },
|
||||
]
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
|
||||
return [...categories.value]
|
||||
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
|
||||
.filter(item => {
|
||||
if (!keyword) return true
|
||||
return [item.title, item.categoryCode, item.path]
|
||||
.some(value => String(value || '').toLowerCase().includes(keyword))
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const sortDiff = (a.sortNumber || 0) - (b.sortNumber || 0)
|
||||
if (sortDiff !== 0) return sortDiff
|
||||
return (b.categoryId || 0) - (a.categoryId || 0)
|
||||
})
|
||||
})
|
||||
|
||||
const pagedCategories = computed(() => {
|
||||
const start = (pagination.current - 1) * pagination.pageSize
|
||||
return filteredCategories.value.slice(start, start + pagination.pageSize)
|
||||
})
|
||||
|
||||
const tablePagination = computed(() => ({
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: filteredCategories.value.length,
|
||||
showSizeChanger: pagination.showSizeChanger,
|
||||
showQuickJumper: pagination.showQuickJumper,
|
||||
}))
|
||||
|
||||
const enabledCount = computed(() => categories.value.filter(item => item.status === 0).length)
|
||||
const recommendCount = computed(() => categories.value.filter(item => !!item.recommend).length)
|
||||
|
||||
const showFormModal = ref(false)
|
||||
const editingCategory = ref<CmsArticleCategory | null>(null)
|
||||
const formData = reactive<CmsArticleCategory>({
|
||||
title: '',
|
||||
categoryCode: '',
|
||||
parentId: undefined,
|
||||
type: 0,
|
||||
path: '',
|
||||
sortNumber: 0,
|
||||
status: 0,
|
||||
})
|
||||
const formRecommend = ref(false)
|
||||
const formShowIndex = ref(false)
|
||||
const formHide = ref(false)
|
||||
|
||||
const parentOptions = computed(() =>
|
||||
categories.value
|
||||
.filter(item => item.categoryId && item.categoryId !== editingCategory.value?.categoryId)
|
||||
.map(item => ({
|
||||
value: item.categoryId,
|
||||
label: item.title || `分类 ${item.categoryId}`,
|
||||
}))
|
||||
)
|
||||
|
||||
async function loadCategories() {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await listCmsArticleCategory()
|
||||
categories.value = list || []
|
||||
ensurePaginationInRange()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载分类列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePaginationInRange() {
|
||||
const total = filteredCategories.value.length
|
||||
const maxPage = Math.max(1, Math.ceil(total / pagination.pageSize))
|
||||
if (pagination.current > maxPage) {
|
||||
pagination.current = maxPage
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
}
|
||||
|
||||
function handleTableChange(pag: { current: number; pageSize: number }) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
ensurePaginationInRange()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(formData, {
|
||||
categoryId: undefined,
|
||||
title: '',
|
||||
categoryCode: '',
|
||||
parentId: undefined,
|
||||
type: 0,
|
||||
path: '',
|
||||
sortNumber: 0,
|
||||
status: 0,
|
||||
})
|
||||
formRecommend.value = false
|
||||
formShowIndex.value = false
|
||||
formHide.value = false
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
editingCategory.value = null
|
||||
resetForm()
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: CmsArticleCategory) {
|
||||
editingCategory.value = record
|
||||
Object.assign(formData, {
|
||||
categoryId: record.categoryId,
|
||||
title: record.title || '',
|
||||
categoryCode: record.categoryCode || '',
|
||||
parentId: record.parentId,
|
||||
type: record.type ?? 0,
|
||||
path: record.path || '',
|
||||
sortNumber: record.sortNumber ?? 0,
|
||||
status: record.status ?? 0,
|
||||
})
|
||||
formRecommend.value = !!record.recommend
|
||||
formShowIndex.value = !!record.showIndex
|
||||
formHide.value = !!record.hide
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formData.title?.trim()) {
|
||||
message.warning('请输入分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const data: CmsArticleCategory = {
|
||||
...formData,
|
||||
recommend: formRecommend.value ? 1 : 0,
|
||||
showIndex: formShowIndex.value ? 1 : 0,
|
||||
hide: formHide.value ? 1 : 0,
|
||||
}
|
||||
if (editingCategory.value?.categoryId) {
|
||||
await updateCmsArticleCategory(data)
|
||||
message.success('分类已更新')
|
||||
} else {
|
||||
await addCmsArticleCategory(data)
|
||||
message.success('分类已创建')
|
||||
}
|
||||
showFormModal.value = false
|
||||
await loadCategories()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(record: CmsArticleCategory) {
|
||||
try {
|
||||
await removeCmsArticleCategory(record.categoryId)
|
||||
message.success('分类已删除')
|
||||
await loadCategories()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function resolveParentName(parentId?: number) {
|
||||
if (!parentId) return '-'
|
||||
return categories.value.find(item => item.categoryId === parentId)?.title || `分类 ${parentId}`
|
||||
}
|
||||
|
||||
function typeText(type?: number) {
|
||||
const map: Record<number, string> = {
|
||||
0: '列表',
|
||||
1: '单页',
|
||||
2: '外链',
|
||||
}
|
||||
return map[type ?? 0] || '列表'
|
||||
}
|
||||
|
||||
watch([filteredCategories, () => pagination.pageSize], () => {
|
||||
ensurePaginationInRange()
|
||||
})
|
||||
|
||||
onMounted(() => loadCategories())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.article-categories-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; }
|
||||
.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;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
|
||||
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
|
||||
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
|
||||
.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;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
|
||||
|
||||
.category-info-cell { display: flex; flex-direction: column; gap: 6px; }
|
||||
.category-title-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.category-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
|
||||
.category-meta { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
|
||||
.meta-item { margin-left: 8px; }
|
||||
.flag-list { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.switch-tip { display: block; margin-top: 6px; font-size: 12px; color: rgba(0, 0, 0, 0.45); }
|
||||
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-gray { color: rgba(0, 0, 0, 0.45); }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
.w-full { width: 100%; }
|
||||
</style>
|
||||
859
app/pages/admin/articles.vue
Normal file
859
app/pages/admin/articles.vue
Normal file
@@ -0,0 +1,859 @@
|
||||
<template>
|
||||
<div class="articles-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">📝 文章管理</h2>
|
||||
<p class="page-desc">管理平台文章内容,支持分类、封面、推荐与状态流转</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button @click="navigateTo('/admin/article-categories')">
|
||||
分类管理
|
||||
</a-button>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增文章
|
||||
</a-button>
|
||||
<a-button @click="loadArticles" :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 statCards" :key="stat.key">
|
||||
<div
|
||||
class="stat-card"
|
||||
:class="[
|
||||
stat.color,
|
||||
{
|
||||
active:
|
||||
(filterStatus === undefined && stat.key === -1) ||
|
||||
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 wrap>
|
||||
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
|
||||
<a-select-option :value="undefined">全部状态</a-select-option>
|
||||
<a-select-option :value="0">已发布</a-select-option>
|
||||
<a-select-option :value="1">待审核</a-select-option>
|
||||
<a-select-option :value="2">已驳回</a-select-option>
|
||||
<a-select-option :value="3">违规</a-select-option>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-model:value="filterCategoryId"
|
||||
allow-clear
|
||||
placeholder="全部分类"
|
||||
style="width: 180px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="item in categoryOptions"
|
||||
:key="item.categoryId"
|
||||
:value="item.categoryId"
|
||||
>
|
||||
{{ item.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索标题 / 摘要 / 作者"
|
||||
style="width: 240px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="pagedArticles"
|
||||
:loading="loading"
|
||||
:pagination="tablePagination"
|
||||
row-key="articleId"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'info'">
|
||||
<div class="article-info-cell">
|
||||
<img v-if="record.image" :src="record.image" class="article-thumb" />
|
||||
<div v-else class="article-thumb-empty">📄</div>
|
||||
<div class="article-info-text">
|
||||
<div class="article-title">{{ record.title }}</div>
|
||||
<div class="article-meta">
|
||||
<span v-if="record.author">✍️ {{ record.author }}</span>
|
||||
<span v-if="resolveCategoryName(record)" class="meta-item">📁 {{ resolveCategoryName(record) }}</span>
|
||||
</div>
|
||||
<div class="article-overview">{{ record.overview || '暂无摘要' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'metrics'">
|
||||
<div class="metrics-cell">
|
||||
<div>👁 {{ record.actualViews || 0 }} 次阅读</div>
|
||||
<div>❤️ {{ record.likes || 0 }} 点赞</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'recommend'">
|
||||
<a-switch
|
||||
:checked="!!record.recommend"
|
||||
size="small"
|
||||
@change="(val: boolean) => handleToggleRecommend(record, val)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'createTime'">
|
||||
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">预览</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-popconfirm title="确认删除此文章?" @confirm="handleDelete(record)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showFormModal"
|
||||
:title="editingArticle?.articleId ? '编辑文章' : '新增文章'"
|
||||
width="760px"
|
||||
:confirm-loading="saving"
|
||||
@ok="handleSave"
|
||||
@cancel="showFormModal = false"
|
||||
>
|
||||
<a-form :model="formData" layout="vertical">
|
||||
<a-form-item label="文章标题" required>
|
||||
<a-input
|
||||
v-model:value="formData.title"
|
||||
placeholder="请输入文章标题"
|
||||
:maxlength="200"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="作者">
|
||||
<a-input v-model:value="formData.author" placeholder="文章作者" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="来源">
|
||||
<a-input v-model:value="formData.source" placeholder="文章来源" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="文章分类">
|
||||
<a-select
|
||||
v-model:value="formData.categoryId"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
placeholder="请选择文章分类"
|
||||
:options="categorySelectOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="formData.status">
|
||||
<a-select-option :value="0">已发布</a-select-option>
|
||||
<a-select-option :value="1">待审核</a-select-option>
|
||||
<a-select-option :value="2">已驳回</a-select-option>
|
||||
<a-select-option :value="3">违规</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="封面图">
|
||||
<div class="cover-upload-wrap">
|
||||
<div v-if="formData.image" class="cover-preview-card">
|
||||
<img :src="formData.image" class="cover-preview-image" />
|
||||
<div class="cover-preview-actions">
|
||||
<a-button size="small" @click="handlePreviewImage(formData.image)">预览</a-button>
|
||||
<a-button size="small" danger @click="handleRemoveCover">移除</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<a-upload
|
||||
accept="image/*"
|
||||
:show-upload-list="false"
|
||||
:before-upload="beforeImageUpload"
|
||||
:custom-request="handleCoverUpload"
|
||||
>
|
||||
<a-button :loading="imageUploading">上传封面</a-button>
|
||||
</a-upload>
|
||||
<div class="field-hint">支持 jpg/png/webp,建议横版封面,单张不超过 5MB</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="文章摘要">
|
||||
<a-textarea
|
||||
v-model:value="formData.overview"
|
||||
:rows="3"
|
||||
placeholder="文章简短描述"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="文章内容" required>
|
||||
<div class="content-editor-wrap">
|
||||
<div class="editor-tabs">
|
||||
<a-radio-group v-model:value="editorMode" button-style="solid" size="small">
|
||||
<a-radio-button value="edit">编辑</a-radio-button>
|
||||
<a-radio-button value="preview">预览</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div v-show="editorMode === 'edit'">
|
||||
<MarkdownEditor
|
||||
v-model="formData.content"
|
||||
placeholder="请输入 Markdown 内容,支持 # 标题、**加粗**、*斜体*、[链接](url)、、代码块等语法"
|
||||
:show-preview="false"
|
||||
min-height="320px"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="editorMode === 'preview'" class="preview-only-mode">
|
||||
<MarkdownRenderer v-if="formData.content" :content="formData.content" />
|
||||
<div v-else class="empty-preview">暂无内容</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="内容格式">
|
||||
<a-tag :color="isMarkdown ? 'blue' : 'default'">
|
||||
{{ isMarkdown ? 'Markdown' : '纯文本/HTML' }}
|
||||
</a-tag>
|
||||
<span class="format-hint">
|
||||
当前编辑器支持 Markdown 语法编写
|
||||
</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="是否推荐">
|
||||
<a-switch v-model:checked="formRecommend" />
|
||||
<span class="switch-tip">推荐文章将优先出现在列表与前台推荐位</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showPreviewModal"
|
||||
:title="previewData?.title || '文章预览'"
|
||||
width="760px"
|
||||
:footer="null"
|
||||
>
|
||||
<template v-if="previewData">
|
||||
<div class="preview-meta">
|
||||
<a-tag :color="statusColor(previewData.status)">{{ statusText(previewData.status) }}</a-tag>
|
||||
<a-tag v-if="previewData.recommend" color="gold">推荐</a-tag>
|
||||
<a-tag v-if="previewData.categoryName" color="blue">{{ previewData.categoryName }}</a-tag>
|
||||
<span class="preview-meta-text">{{ previewData.createTime?.substring(0, 16) || '-' }}</span>
|
||||
</div>
|
||||
<div v-if="previewData.image" class="preview-cover-wrap">
|
||||
<img :src="previewData.image" class="preview-cover" />
|
||||
</div>
|
||||
<div class="preview-summary" v-if="previewData.overview">{{ previewData.overview }}</div>
|
||||
<a-divider />
|
||||
<div class="preview-content">
|
||||
<MarkdownRenderer v-if="isPreviewMarkdown" :content="previewData.content" />
|
||||
<div v-else v-html="previewData.content || previewData.overview || '暂无内容'"></div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:open="showImagePreview" title="封面预览" :footer="null" width="640px">
|
||||
<img v-if="previewImageUrl" :src="previewImageUrl" class="image-preview-modal" />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
listAppArticle as listCmsArticle,
|
||||
addAppArticle as addCmsArticle,
|
||||
updateAppArticle as updateCmsArticle,
|
||||
removeAppArticle as removeCmsArticle,
|
||||
} from '@/api/app/article'
|
||||
import { listAppArticleCategory as listCmsArticleCategory } from '@/api/app/articleCategory'
|
||||
import { uploadFile } from '@/api/system/file'
|
||||
import type { AppArticle as CmsArticle } from '@/api/app/article/model'
|
||||
import type { AppArticleCategory as CmsArticleCategory } from '@/api/app/articleCategory/model'
|
||||
import MarkdownEditor from '@/components/admin/MarkdownEditor.vue'
|
||||
import MarkdownRenderer from '@/components/admin/MarkdownRenderer.vue'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '文章管理 - 平台管理' })
|
||||
|
||||
type UploadRequestOption = {
|
||||
file?: File
|
||||
onSuccess?: (body: unknown, file: File) => void
|
||||
onError?: (err: unknown) => void
|
||||
}
|
||||
|
||||
const ANNOUNCE_MODEL = 'announcement'
|
||||
|
||||
const loading = ref(false)
|
||||
const imageUploading = ref(false)
|
||||
const allArticles = ref<CmsArticle[]>([])
|
||||
const categoryOptions = ref<CmsArticleCategory[]>([])
|
||||
const filterStatus = ref<number | undefined>(undefined)
|
||||
const filterCategoryId = ref<number | undefined>(undefined)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
const statCards = reactive([
|
||||
{ key: 0, icon: '✅', label: '已发布', value: 0, color: 'green' },
|
||||
{ key: 1, icon: '⏳', label: '待审核', value: 0, color: 'orange' },
|
||||
{ key: 2, icon: '❌', label: '已驳回', value: 0, color: 'red' },
|
||||
{ key: -1, icon: '📝', label: '全部文章', value: 0, color: 'blue' },
|
||||
])
|
||||
|
||||
const columns = [
|
||||
{ title: '文章信息', key: 'info', width: 360 },
|
||||
{ title: '状态', key: 'status', width: 110 },
|
||||
{ title: '数据', key: 'metrics', width: 120 },
|
||||
{ title: '推荐', key: 'recommend', width: 80 },
|
||||
{ title: '创建时间', key: 'createTime', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 170 },
|
||||
]
|
||||
|
||||
const showFormModal = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingArticle = ref<CmsArticle | null>(null)
|
||||
const formData = reactive<CmsArticle>({
|
||||
title: '',
|
||||
author: '',
|
||||
source: '',
|
||||
overview: '',
|
||||
content: '',
|
||||
status: 0,
|
||||
categoryId: undefined,
|
||||
image: '',
|
||||
})
|
||||
const formRecommend = ref(false)
|
||||
const editorMode = ref<'edit' | 'preview'>('edit')
|
||||
|
||||
const isMarkdown = computed(() => {
|
||||
return formData.content && (
|
||||
/[#*`_\[\]()!>-]/.test(formData.content) ||
|
||||
/^(#{1,6}\s|[-*]\s|\d+\.\s|>)/m.test(formData.content)
|
||||
)
|
||||
})
|
||||
|
||||
const isPreviewMarkdown = computed(() => {
|
||||
if (!previewData.value?.content) return false
|
||||
return (
|
||||
/[#*`_\[\]()!>-]/.test(previewData.value.content) ||
|
||||
/^(#{1,6}\s|[-*]\s|\d+\.\s|>)/m.test(previewData.value.content)
|
||||
)
|
||||
})
|
||||
|
||||
const showPreviewModal = ref(false)
|
||||
const previewData = ref<CmsArticle | null>(null)
|
||||
const showImagePreview = ref(false)
|
||||
const previewImageUrl = ref('')
|
||||
|
||||
const categorySelectOptions = computed(() =>
|
||||
categoryOptions.value.map(item => ({
|
||||
value: item.categoryId,
|
||||
label: item.title || `分类 ${item.categoryId}`,
|
||||
}))
|
||||
)
|
||||
|
||||
const standardArticles = computed(() => allArticles.value.filter(isStandardArticle))
|
||||
|
||||
const filteredArticles = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
|
||||
return [...standardArticles.value]
|
||||
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
|
||||
.filter(item => filterCategoryId.value === undefined || item.categoryId === filterCategoryId.value)
|
||||
.filter(item => {
|
||||
if (!keyword) return true
|
||||
return [item.title, item.overview, item.author, item.source]
|
||||
.some(value => String(value || '').toLowerCase().includes(keyword))
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const timeA = a.createTime || ''
|
||||
const timeB = b.createTime || ''
|
||||
if (timeA && timeB && timeA !== timeB) {
|
||||
return timeB.localeCompare(timeA)
|
||||
}
|
||||
return (b.articleId || 0) - (a.articleId || 0)
|
||||
})
|
||||
})
|
||||
|
||||
const pagedArticles = computed(() => {
|
||||
const start = (pagination.current - 1) * pagination.pageSize
|
||||
return filteredArticles.value.slice(start, start + pagination.pageSize)
|
||||
})
|
||||
|
||||
const tablePagination = computed(() => ({
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: filteredArticles.value.length,
|
||||
showSizeChanger: pagination.showSizeChanger,
|
||||
showQuickJumper: pagination.showQuickJumper,
|
||||
}))
|
||||
|
||||
async function loadCategories(silent = false) {
|
||||
try {
|
||||
const list = await listCmsArticleCategory({ status: 0 })
|
||||
categoryOptions.value = (list || [])
|
||||
.filter(item => item.categoryId)
|
||||
.sort((a, b) => (a.sortNumber || 0) - (b.sortNumber || 0))
|
||||
} catch (e: any) {
|
||||
if (!silent) {
|
||||
message.error(e?.message || '加载文章分类失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await listCmsArticle()
|
||||
allArticles.value = list || []
|
||||
ensurePaginationInRange()
|
||||
updateStats()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载文章列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const list = standardArticles.value
|
||||
statCards[0].value = list.filter(item => item.status === 0).length
|
||||
statCards[1].value = list.filter(item => item.status === 1).length
|
||||
statCards[2].value = list.filter(item => item.status === 2).length
|
||||
statCards[3].value = list.length
|
||||
}
|
||||
|
||||
function ensurePaginationInRange() {
|
||||
const total = filteredArticles.value.length
|
||||
const maxPage = Math.max(1, Math.ceil(total / pagination.pageSize))
|
||||
if (pagination.current > maxPage) {
|
||||
pagination.current = maxPage
|
||||
}
|
||||
}
|
||||
|
||||
function isStandardArticle(item: CmsArticle) {
|
||||
return (item.model || '').trim() !== ANNOUNCE_MODEL
|
||||
}
|
||||
|
||||
function handleStatFilter(key: number) {
|
||||
filterStatus.value = key === -1 ? undefined : key
|
||||
pagination.current = 1
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
}
|
||||
|
||||
function handleTableChange(pag: { current: number; pageSize: number }) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
ensurePaginationInRange()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(formData, {
|
||||
articleId: undefined,
|
||||
title: '',
|
||||
author: '',
|
||||
source: '',
|
||||
overview: '',
|
||||
content: '',
|
||||
status: 0,
|
||||
categoryId: undefined,
|
||||
image: '',
|
||||
})
|
||||
formRecommend.value = false
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
editingArticle.value = null
|
||||
resetForm()
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: CmsArticle) {
|
||||
editingArticle.value = record
|
||||
Object.assign(formData, {
|
||||
articleId: record.articleId,
|
||||
title: record.title || '',
|
||||
author: record.author || '',
|
||||
source: record.source || '',
|
||||
overview: record.overview || '',
|
||||
content: record.content || '',
|
||||
status: record.status ?? 0,
|
||||
categoryId: record.categoryId,
|
||||
image: record.image || '',
|
||||
})
|
||||
formRecommend.value = !!record.recommend
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function handleView(record: CmsArticle) {
|
||||
previewData.value = {
|
||||
...record,
|
||||
categoryName: resolveCategoryName(record),
|
||||
}
|
||||
showPreviewModal.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formData.title?.trim()) {
|
||||
message.warning('请输入文章标题')
|
||||
return
|
||||
}
|
||||
if (!formData.content?.trim()) {
|
||||
message.warning('请输入文章内容')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const data: CmsArticle = {
|
||||
...formData,
|
||||
model: undefined,
|
||||
categoryName: resolveCategoryNameById(formData.categoryId),
|
||||
recommend: formRecommend.value ? 1 : 0,
|
||||
}
|
||||
if (editingArticle.value?.articleId) {
|
||||
await updateCmsArticle(data)
|
||||
message.success('文章已更新')
|
||||
} else {
|
||||
await addCmsArticle(data)
|
||||
message.success('文章已创建')
|
||||
}
|
||||
showFormModal.value = false
|
||||
await loadArticles()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(record: CmsArticle) {
|
||||
try {
|
||||
await removeCmsArticle(record.articleId)
|
||||
message.success('文章已删除')
|
||||
await loadArticles()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleRecommend(record: CmsArticle, val: boolean) {
|
||||
try {
|
||||
await updateCmsArticle({ articleId: record.articleId, recommend: val ? 1 : 0 })
|
||||
message.success(val ? '已加入推荐' : '已取消推荐')
|
||||
await loadArticles()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function beforeImageUpload(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error('只能上传图片文件')
|
||||
return false
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
message.error('图片大小不能超过 5MB')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleCoverUpload(option: UploadRequestOption) {
|
||||
const rawFile = option.file
|
||||
if (!rawFile) return
|
||||
|
||||
imageUploading.value = true
|
||||
try {
|
||||
const record = await uploadFile(rawFile)
|
||||
const url = (record?.url || record?.downloadUrl || '').trim()
|
||||
if (!url) throw new Error('上传成功但未返回图片地址')
|
||||
formData.image = url
|
||||
option.onSuccess?.(record, rawFile)
|
||||
message.success('封面上传成功')
|
||||
} catch (e) {
|
||||
option.onError?.(e)
|
||||
message.error(e instanceof Error ? e.message : '封面上传失败')
|
||||
} finally {
|
||||
imageUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveCover() {
|
||||
formData.image = ''
|
||||
}
|
||||
|
||||
function handlePreviewImage(url?: string) {
|
||||
if (!url) return
|
||||
previewImageUrl.value = url
|
||||
showImagePreview.value = true
|
||||
}
|
||||
|
||||
function resolveCategoryName(record: CmsArticle) {
|
||||
return record.categoryName || resolveCategoryNameById(record.categoryId)
|
||||
}
|
||||
|
||||
function resolveCategoryNameById(categoryId?: number) {
|
||||
if (!categoryId) return ''
|
||||
return categoryOptions.value.find(item => item.categoryId === categoryId)?.title || ''
|
||||
}
|
||||
|
||||
function statusText(status?: number) {
|
||||
const map: Record<number, string> = {
|
||||
0: '已发布',
|
||||
1: '待审核',
|
||||
2: '已驳回',
|
||||
3: '违规',
|
||||
}
|
||||
return map[status ?? -1] || '-'
|
||||
}
|
||||
|
||||
function statusColor(status?: number) {
|
||||
const map: Record<number, string> = {
|
||||
0: 'success',
|
||||
1: 'orange',
|
||||
2: 'error',
|
||||
3: 'volcano',
|
||||
}
|
||||
return map[status ?? -1] || 'default'
|
||||
}
|
||||
|
||||
watch([filteredArticles, () => pagination.pageSize], () => {
|
||||
ensurePaginationInRange()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories(true)
|
||||
await loadArticles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.articles-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; }
|
||||
.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 { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); }
|
||||
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
|
||||
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
|
||||
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
|
||||
.stat-card.active.blue { border-color: #3b82f6; }
|
||||
.stat-card.active.green { border-color: #22c55e; }
|
||||
.stat-card.active.orange { border-color: #f97316; }
|
||||
.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;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
|
||||
|
||||
.article-info-cell { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.article-thumb {
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
.article-thumb-empty {
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.article-info-text { flex: 1; min-width: 0; }
|
||||
.article-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.article-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.meta-item { margin-left: 8px; }
|
||||
.article-overview {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.metrics-cell { font-size: 12px; color: rgba(0, 0, 0, 0.45); line-height: 1.7; }
|
||||
|
||||
.cover-upload-wrap { display: flex; flex-direction: column; gap: 10px; }
|
||||
.cover-preview-card {
|
||||
width: 220px;
|
||||
padding: 8px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.cover-preview-image {
|
||||
width: 100%;
|
||||
height: 124px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
.cover-preview-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.field-hint { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
|
||||
.switch-tip { margin-left: 8px; font-size: 12px; color: rgba(0, 0, 0, 0.45); }
|
||||
|
||||
.content-editor-wrap {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.preview-only-mode {
|
||||
padding: 16px;
|
||||
min-height: 320px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.empty-preview {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.format-hint {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.preview-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.preview-meta-text { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
|
||||
.preview-cover-wrap { margin: 16px 0 12px; }
|
||||
.preview-cover {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
.preview-summary {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #fafafa;
|
||||
border-radius: 10px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.preview-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
.image-preview-modal {
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-gray { color: rgba(0, 0, 0, 0.45); }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
</style>
|
||||
415
app/pages/admin/developers.vue
Normal file
415
app/pages/admin/developers.vue
Normal file
@@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<div class="developers-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">🧑💻 开发者管理</h2>
|
||||
<p class="page-desc">管理平台上有应用发布记录的开发者账号</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button @click="loadData" :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 stats" :key="stat.label">
|
||||
<div class="stat-card" :class="stat.color">
|
||||
<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="filter-bar">
|
||||
<a-radio-group v-model:value="filterType" button-style="solid" @change="handleFilterChange">
|
||||
<a-radio-button :value="2">开发者用户</a-radio-button>
|
||||
<a-radio-button :value="null">全部用户</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索用户名/昵称/手机号"
|
||||
style="width: 240px"
|
||||
allow-clear
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 开发者列表 -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">🧑💻 用户列表</span>
|
||||
<a-tag color="blue">共 {{ pagination.total }} 人</a-tag>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="developers"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="userId"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 开发者信息 -->
|
||||
<template v-if="column.key === 'devInfo'">
|
||||
<div class="dev-info-cell">
|
||||
<a-avatar :size="38" :src="record.avatar || record.avatarUrl">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<div class="dev-info-text">
|
||||
<div class="dev-name">{{ record.nickname || record.username || '-' }}</div>
|
||||
<div class="dev-sub" v-if="record.username">@{{ record.username }}</div>
|
||||
<div class="dev-sub" v-if="record.phone || record.mobile">
|
||||
📱 {{ record.phone || record.mobile }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 用户类型 -->
|
||||
<template v-if="column.key === 'userType'">
|
||||
<a-tag v-if="record.type === 2" color="purple">开发者</a-tag>
|
||||
<a-tag v-else-if="record.type === 1" color="blue">企业用户</a-tag>
|
||||
<a-tag v-else color="default">普通用户</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 应用数量 -->
|
||||
<template v-if="column.key === 'appCount'">
|
||||
<a-space>
|
||||
<a-tag color="blue">{{ appCountMap[record.userId!] ?? 0 }} 个应用</a-tag>
|
||||
<a-tag color="success" v-if="publishedCountMap[record.userId!]">
|
||||
{{ publishedCountMap[record.userId!] }} 已上架
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!-- 注册时间 -->
|
||||
<template v-if="column.key === 'createTime'">
|
||||
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 0 ? 'success' : 'error'">
|
||||
{{ record.status === 0 ? '正常' : '已冻结' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleViewDev(record)">查看应用</a-button>
|
||||
<a-popconfirm
|
||||
v-if="record.type !== 2"
|
||||
title="确认将该用户设为开发者?"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="handleSetDeveloper(record, 2)"
|
||||
>
|
||||
<a-button type="link" size="small">设为开发者</a-button>
|
||||
</a-popconfirm>
|
||||
<a-popconfirm
|
||||
v-else
|
||||
title="确认取消该用户的开发者资质?"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="handleSetDeveloper(record, 0)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>取消资质</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 开发者应用列表弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showAppsModal"
|
||||
:title="`${currentDev?.nickname || currentDev?.username || '开发者'} 的应用`"
|
||||
width="780px"
|
||||
:footer="null"
|
||||
>
|
||||
<div v-if="loadingApps" class="modal-spin">
|
||||
<a-spin />
|
||||
</div>
|
||||
<template v-else>
|
||||
<a-empty v-if="devApps.length === 0" description="该开发者暂无应用" />
|
||||
<div v-else class="dev-apps-grid">
|
||||
<div v-for="app in devApps" :key="app.productId" class="dev-app-card">
|
||||
<div class="dev-app-header">
|
||||
<img v-if="app.icon" :src="app.icon" class="dev-app-icon" />
|
||||
<div v-else class="dev-app-icon-placeholder" :style="{ background: iconBgColor(app.productName) }">
|
||||
{{ (app.productName || 'A').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="dev-app-info">
|
||||
<div class="dev-app-name">
|
||||
{{ app.productName }}
|
||||
<a-tag color="blue" style="margin-left:6px;font-size:11px">{{ APP_TYPE_NAME[app.appType ?? 10] || '网站' }}</a-tag>
|
||||
</div>
|
||||
<div class="dev-app-code">{{ app.productCode }}</div>
|
||||
</div>
|
||||
<a-tag :color="pubStatusColor(app.publishStatus)" style="margin-left:auto">
|
||||
{{ pubStatusText(app.publishStatus) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="dev-app-desc">{{ app.description || '暂无简介' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getUserAppStats, pageAppProductAll } from '@/api/app/appProduct'
|
||||
import { pageUsers, updateUser } from '@/api/system/user/index'
|
||||
import type { AppProduct } from '@/api/app/appProduct/model'
|
||||
import { APP_TYPE_NAME } from '@/api/app/appProduct/model'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '开发者管理 - 平台管理' })
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingApps = ref(false)
|
||||
const developers = ref<User[]>([])
|
||||
const searchKeyword = ref('')
|
||||
const filterType = ref<number | null>(2) // 默认只看开发者
|
||||
|
||||
// 应用数量映射 userId -> count
|
||||
const appCountMap = ref<Record<number, number>>({})
|
||||
const publishedCountMap = ref<Record<number, number>>({})
|
||||
|
||||
const showAppsModal = ref(false)
|
||||
const currentDev = ref<User | null>(null)
|
||||
const devApps = ref<AppProduct[]>([])
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
const stats = reactive([
|
||||
{ icon: '🧑💻', label: '开发者总数', value: 0, color: 'blue' },
|
||||
{ icon: '📦', label: '应用总数', value: 0, color: 'green' },
|
||||
{ icon: '✅', label: '已上架应用', value: 0, color: 'orange' },
|
||||
{ icon: '⏳', label: '待审核', value: 0, color: 'red' },
|
||||
])
|
||||
|
||||
const columns = [
|
||||
{ title: '用户', key: 'devInfo', width: 240 },
|
||||
{ title: '类型', key: 'userType', width: 100 },
|
||||
{ title: '应用数量', key: 'appCount', width: 180 },
|
||||
{ title: '注册时间', key: 'createTime', width: 120 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 160 },
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageUsers({
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize,
|
||||
keywords: searchKeyword.value || undefined,
|
||||
type: filterType.value ?? undefined,
|
||||
})
|
||||
developers.value = res?.list || []
|
||||
pagination.total = res?.count || 0
|
||||
stats[0].value = pagination.total
|
||||
// 加载完用户后,单次请求批量加载应用数量
|
||||
loadAppCounts()
|
||||
} catch {
|
||||
message.error('加载用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppCounts() {
|
||||
if (!developers.value.length) return
|
||||
try {
|
||||
// 单次 POST 请求,一条 SQL 批量统计所有用户的应用数
|
||||
const userIds = developers.value.map(u => u.userId!).filter(Boolean)
|
||||
const rows = await getUserAppStats(userIds)
|
||||
const countMap: Record<number, number> = {}
|
||||
const pubMap: Record<number, number> = {}
|
||||
let totalApps = 0
|
||||
let totalPublished = 0
|
||||
for (const row of rows) {
|
||||
const uid = Number(row.userId)
|
||||
const total = Number(row.totalCount) || 0
|
||||
const pub = Number(row.publishedCount) || 0
|
||||
countMap[uid] = total
|
||||
if (pub > 0) pubMap[uid] = pub
|
||||
totalApps += total
|
||||
totalPublished += pub
|
||||
}
|
||||
appCountMap.value = countMap
|
||||
publishedCountMap.value = pubMap
|
||||
stats[1].value = totalApps
|
||||
stats[2].value = totalPublished
|
||||
} catch { /* ignore */ }
|
||||
// 异步加载全局统计(待审核数)
|
||||
loadPendingCount()
|
||||
}
|
||||
|
||||
async function loadPendingCount() {
|
||||
try {
|
||||
const res = await pageAppProductAll({ current: 1, size: 1, publishStatus: 'pending_review' })
|
||||
stats[3].value = res?.count ?? 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleFilterChange() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleSetDeveloper(record: User, type: number) {
|
||||
try {
|
||||
await updateUser({ userId: record.userId, type })
|
||||
record.type = type
|
||||
message.success(type === 2 ? '已设为开发者用户' : '已取消开发者资质')
|
||||
// 如果当前只展示开发者,取消后刷新列表
|
||||
if (filterType.value === 2 && type !== 2) loadData()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewDev(record: User) {
|
||||
currentDev.value = record
|
||||
showAppsModal.value = true
|
||||
loadingApps.value = true
|
||||
try {
|
||||
const res = await pageAppProductAll({
|
||||
current: 1,
|
||||
size: 100,
|
||||
userId: record.userId,
|
||||
})
|
||||
devApps.value = res?.list || []
|
||||
} catch {
|
||||
message.error('加载应用列表失败')
|
||||
} finally {
|
||||
loadingApps.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pubStatusColor(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
developing: 'default', pending_review: 'orange',
|
||||
published: 'success', rejected: 'error', deprecated: 'default',
|
||||
}
|
||||
return map[status || ''] || 'default'
|
||||
}
|
||||
|
||||
function pubStatusText(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
developing: '开发中', pending_review: '待审核',
|
||||
published: '已上架', rejected: '已拒绝', deprecated: '已下架',
|
||||
}
|
||||
return map[status || ''] || '-'
|
||||
}
|
||||
|
||||
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(() => loadData())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.developers-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; }
|
||||
.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; transition: all 0.2s;
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||||
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
|
||||
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
|
||||
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
|
||||
.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; flex-wrap: wrap; gap: 10px;
|
||||
}
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
|
||||
|
||||
.filter-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
flex-wrap: wrap; gap: 12px; margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dev-info-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.dev-info-text { flex: 1; min-width: 0; }
|
||||
.dev-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
|
||||
.dev-sub { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 1px; }
|
||||
|
||||
/* 应用弹窗卡片 */
|
||||
.dev-apps-grid { display: flex; flex-direction: column; gap: 12px; max-height: 520px; overflow-y: auto; padding-right: 4px; }
|
||||
.dev-app-card {
|
||||
border: 1px solid #f0f0f0; border-radius: 10px; padding: 14px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.dev-app-card:hover { border-color: #d0d0ff; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.dev-app-header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
.dev-app-icon { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; }
|
||||
.dev-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; flex-shrink: 0;
|
||||
}
|
||||
.dev-app-info { flex: 1; }
|
||||
.dev-app-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
|
||||
.dev-app-code { font-size: 12px; color: rgba(0,0,0,0.45); }
|
||||
.dev-app-desc { font-size: 12px; color: rgba(0,0,0,0.45); padding-left: 52px; }
|
||||
|
||||
.modal-spin { display: flex; align-items: center; justify-content: center; min-height: 200px; }
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-gray { color: rgba(0,0,0,0.45); }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
</style>
|
||||
805
app/pages/admin/git-review.vue
Normal file
805
app/pages/admin/git-review.vue
Normal file
@@ -0,0 +1,805 @@
|
||||
<template>
|
||||
<div class="review-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">🔧 Git 审核管理</h2>
|
||||
<p class="page-desc">审核开发者的 Git 账号绑定与仓库权限申请</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button @click="loadAll" :loading="loadingGit || loadingPerm">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
|
||||
<!-- Git 账号审核 -->
|
||||
<a-tab-pane key="git-account" tab="Git 账号审核">
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="[16, 16]" class="mb-6">
|
||||
<a-col :xs="8" :md="6" v-for="stat in gitStats" :key="stat.key">
|
||||
<div
|
||||
class="stat-card"
|
||||
:class="[stat.color, { active: gitFilter.status === stat.key }]"
|
||||
@click="handleGitStatFilter(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">📋 Git 账号绑定列表</span>
|
||||
<a-space>
|
||||
<a-input-search
|
||||
v-model:value="gitFilter.keyword"
|
||||
placeholder="搜索用户名/邮箱"
|
||||
style="width: 200px"
|
||||
@search="loadGitAccounts"
|
||||
allow-clear
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="gitColumns"
|
||||
:data-source="gitAccounts"
|
||||
:loading="loadingGit"
|
||||
:pagination="gitPagination"
|
||||
row-key="id"
|
||||
@change="handleGitTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'userInfo'">
|
||||
<div class="user-info-cell">
|
||||
<a-avatar :size="36" class="user-avatar">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<div class="user-info-text">
|
||||
<div class="user-name">{{ record.username }}</div>
|
||||
<div class="user-email" v-if="record.email">{{ record.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'remark'">
|
||||
<span class="text-gray">{{ record.remark || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="gitStatusColor(record.status)">
|
||||
{{ gitStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'time'">
|
||||
<span class="text-sm text-gray">{{ formatTime(record.updateTime || record.createTime) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleViewGitDetail(record)">详情</a-button>
|
||||
<template v-if="record.status === 'pending'">
|
||||
<a-popconfirm title="确认通过此 Git 账号绑定?" @confirm="handleApproveGit(record)">
|
||||
<a-button type="primary" size="small">通过</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button danger size="small" @click="handleRejectGit(record)">拒绝</a-button>
|
||||
</template>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 仓库权限审核 -->
|
||||
<a-tab-pane key="permission-request" tab="仓库权限审核">
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="[16, 16]" class="mb-6">
|
||||
<a-col :xs="8" :md="6" v-for="stat in permStats" :key="stat.key">
|
||||
<div
|
||||
class="stat-card"
|
||||
:class="[stat.color, { active: permFilter.status === stat.key }]"
|
||||
@click="handlePermStatFilter(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-input-search
|
||||
v-model:value="permFilter.keyword"
|
||||
placeholder="搜索用户名/仓库名"
|
||||
style="width: 200px"
|
||||
@search="loadPermRequests"
|
||||
allow-clear
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="permColumns"
|
||||
:data-source="permRequests"
|
||||
:loading="loadingPerm"
|
||||
:pagination="permPagination"
|
||||
row-key="id"
|
||||
@change="handlePermTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'applicant'">
|
||||
<div class="user-info-cell">
|
||||
<a-avatar :size="32" class="user-avatar">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<span class="user-name">{{ record.gitUsername }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'repo'">
|
||||
<a-tag color="blue">{{ record.repo }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'reason'">
|
||||
<a-tooltip :title="record.reason">
|
||||
<span class="reason-text">{{ record.reason }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="permStatusColor(record.status)">
|
||||
{{ permStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'time'">
|
||||
<span class="text-sm text-gray">{{ formatTime(record.createdAt || record.reviewedAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleViewPermDetail(record)">详情</a-button>
|
||||
<template v-if="record.status === 'pending'">
|
||||
<a-popconfirm title="确认通过此仓库权限申请?" @confirm="handleApprovePerm(record)">
|
||||
<a-button type="primary" size="small">通过</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button danger size="small" @click="handleRejectPerm(record)">拒绝</a-button>
|
||||
</template>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<!-- Git 账号详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showGitDetailModal"
|
||||
:title="`Git 账号详情:${currentGit?.username || ''}`"
|
||||
width="560px"
|
||||
:footer="null"
|
||||
>
|
||||
<template v-if="currentGit">
|
||||
<a-descriptions :column="2" bordered size="small">
|
||||
<a-descriptions-item label="Gitea 用户名">{{ currentGit.username }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ currentGit.email || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="gitStatusColor(currentGit.status)">{{ gitStatusText(currentGit.status) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="用户ID">{{ currentGit.userId }}</a-descriptions-item>
|
||||
<a-descriptions-item label="提交时间">{{ formatTime(currentGit.createTime) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间">{{ formatTime(currentGit.updateTime) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="备注" :span="2">{{ currentGit.remark || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="currentGit.verificationNote" label="审核备注" :span="2">
|
||||
<a-alert :type="currentGit.status === 'rejected' ? 'error' : 'success'" :message="currentGit.verificationNote" show-icon />
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div v-if="currentGit.status === 'pending'" class="detail-actions">
|
||||
<a-popconfirm title="确认通过此 Git 账号绑定?" @confirm="handleApproveGit(currentGit)">
|
||||
<a-button type="primary" :loading="approvingGit">✅ 审核通过</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button danger @click="handleRejectGit(currentGit)">❌ 拒绝绑定</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 权限申请详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showPermDetailModal"
|
||||
:title="`权限申请详情:${currentPerm?.repoName || ''}`"
|
||||
width="560px"
|
||||
:footer="null"
|
||||
>
|
||||
<template v-if="currentPerm">
|
||||
<a-descriptions :column="2" bordered size="small">
|
||||
<a-descriptions-item label="申请人">{{ currentPerm.gitUsername }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="permStatusColor(currentPerm.status)">{{ permStatusText(currentPerm.status) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="仓库" :span="2">
|
||||
<a-tag color="blue">{{ currentPerm.repo }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="申请理由" :span="2">
|
||||
<div class="detail-reason">{{ currentPerm.reason }}</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="申请时间">{{ formatTime(currentPerm.createdAt) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="审核时间">{{ formatTime(currentPerm.reviewedAt) || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="currentPerm.reviewerName" label="审核人">{{ currentPerm.reviewerName }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="currentPerm.rejectReason" label="拒绝原因" :span="2">
|
||||
<a-alert type="error" :message="currentPerm.rejectReason" show-icon />
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div v-if="currentPerm.status === 'pending'" class="detail-actions">
|
||||
<a-popconfirm title="确认通过此仓库权限申请?" @confirm="handleApprovePerm(currentPerm)">
|
||||
<a-button type="primary" :loading="approvingPerm">✅ 审核通过</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button danger @click="handleRejectPerm(currentPerm)">❌ 拒绝申请</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 拒绝原因弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showRejectModal"
|
||||
:title="rejectModalTitle"
|
||||
: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="rejectPlaceholder"
|
||||
: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 dayjs from 'dayjs'
|
||||
import {
|
||||
pageGitAccounts,
|
||||
approveGitAccount,
|
||||
rejectGitAccount,
|
||||
pagePermissionRequestsAdmin,
|
||||
approvePermissionRequest,
|
||||
rejectPermissionRequest,
|
||||
type GitAccountItem,
|
||||
type PermissionRequestItem,
|
||||
} from '@/api/developer'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: 'Git 审核管理 - 平台管理' })
|
||||
|
||||
// ==================== 通用 ====================
|
||||
const activeTab = ref('git-account')
|
||||
|
||||
function formatTime(time?: string) {
|
||||
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
|
||||
// ==================== 拒绝弹窗 ====================
|
||||
const showRejectModal = ref(false)
|
||||
const rejectReasonInput = ref('')
|
||||
const rejecting = ref(false)
|
||||
const rejectType = ref<'git' | 'perm'>('git')
|
||||
const rejectTargetId = ref<number>(0)
|
||||
const rejectTargetRecord = ref<GitAccountItem | PermissionRequestItem | null>(null)
|
||||
|
||||
const rejectModalTitle = computed(() => rejectType.value === 'git' ? '拒绝 Git 账号绑定' : '拒绝仓库权限申请')
|
||||
const rejectPlaceholder = computed(() => rejectType.value === 'git'
|
||||
? '请填写拒绝原因,以便开发者了解问题并修改'
|
||||
: '请填写拒绝原因,以便开发者了解问题')
|
||||
|
||||
const rejectTips = [
|
||||
'用户名与 Gitea 平台不一致,请核实后重新提交',
|
||||
'提交信息不完整,请补充后重新提交',
|
||||
'该账号存在异常,请联系管理员核实',
|
||||
'仓库暂时不对外开放权限申请',
|
||||
'申请理由不充分,请详细说明使用场景',
|
||||
]
|
||||
|
||||
function handleTabChange() {
|
||||
// tab 切换时无需额外操作,数据已加载
|
||||
}
|
||||
|
||||
// ==================== Git 账号审核 ====================
|
||||
const loadingGit = ref(false)
|
||||
const gitAccounts = ref<GitAccountItem[]>([])
|
||||
const gitFilter = reactive({ status: '', keyword: '' })
|
||||
const gitPagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
const gitStats = reactive([
|
||||
{ key: 'pending', icon: '⏳', label: '待审核', value: 0, color: 'orange' },
|
||||
{ key: 'verified', icon: '✅', label: '已通过', value: 0, color: 'green' },
|
||||
{ key: 'rejected', icon: '❌', label: '已拒绝', value: 0, color: 'red' },
|
||||
{ key: '', icon: '📦', label: '全部', value: 0, color: 'blue' },
|
||||
])
|
||||
|
||||
const gitColumns = [
|
||||
{ title: '用户信息', key: 'userInfo', width: 220 },
|
||||
{ title: '备注', key: 'remark', ellipsis: true },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '更新时间', key: 'time', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 200 },
|
||||
]
|
||||
|
||||
// 详情弹窗
|
||||
const showGitDetailModal = ref(false)
|
||||
const currentGit = ref<GitAccountItem | null>(null)
|
||||
const approvingGit = ref(false)
|
||||
|
||||
async function loadGitAccounts() {
|
||||
loadingGit.value = true
|
||||
try {
|
||||
const res = await pageGitAccounts({
|
||||
page: gitPagination.current,
|
||||
size: gitPagination.pageSize,
|
||||
status: gitFilter.status || undefined,
|
||||
keyword: gitFilter.keyword || undefined,
|
||||
})
|
||||
const listData = (res as any)?.data?.data
|
||||
gitAccounts.value = listData?.records || []
|
||||
gitPagination.total = listData?.total || 0
|
||||
updateGitStats()
|
||||
} catch {
|
||||
message.error('加载 Git 账号列表失败')
|
||||
} finally {
|
||||
loadingGit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGitStats() {
|
||||
try {
|
||||
const [pendingRes, verifiedRes, rejectedRes, allRes] = await Promise.allSettled([
|
||||
pageGitAccounts({ page: 1, size: 1, status: 'pending' }),
|
||||
pageGitAccounts({ page: 1, size: 1, status: 'verified' }),
|
||||
pageGitAccounts({ page: 1, size: 1, status: 'rejected' }),
|
||||
pageGitAccounts({ page: 1, size: 1 }),
|
||||
])
|
||||
const extract = (r: any) => (r.status === 'fulfilled' ? (r.value as any)?.data?.data?.total || 0 : 0)
|
||||
gitStats[0].value = extract(pendingRes)
|
||||
gitStats[1].value = extract(verifiedRes)
|
||||
gitStats[2].value = extract(rejectedRes)
|
||||
gitStats[3].value = extract(allRes)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleGitStatFilter(key: string) {
|
||||
gitFilter.status = key
|
||||
gitPagination.current = 1
|
||||
loadGitAccounts()
|
||||
}
|
||||
|
||||
function handleGitTableChange(pag: any) {
|
||||
gitPagination.current = pag.current
|
||||
gitPagination.pageSize = pag.pageSize
|
||||
loadGitAccounts()
|
||||
}
|
||||
|
||||
function handleViewGitDetail(record: GitAccountItem) {
|
||||
currentGit.value = record
|
||||
showGitDetailModal.value = true
|
||||
}
|
||||
|
||||
async function handleApproveGit(record: GitAccountItem) {
|
||||
approvingGit.value = true
|
||||
try {
|
||||
const res = await approveGitAccount(record.id) as any
|
||||
if (res?.data?.code === 200 || res?.data?.code === 0) {
|
||||
message.success(`Git 账号「${record.username}」已通过审核`)
|
||||
showGitDetailModal.value = false
|
||||
loadGitAccounts()
|
||||
} else {
|
||||
message.error(res?.data?.message || '操作失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.data?.message || e?.message || '操作失败')
|
||||
} finally {
|
||||
approvingGit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRejectGit(record: GitAccountItem) {
|
||||
rejectType.value = 'git'
|
||||
rejectTargetId.value = record.id
|
||||
rejectTargetRecord.value = record
|
||||
rejectReasonInput.value = ''
|
||||
showRejectModal.value = true
|
||||
}
|
||||
|
||||
// ==================== 权限申请审核 ====================
|
||||
const loadingPerm = ref(false)
|
||||
const permRequests = ref<PermissionRequestItem[]>([])
|
||||
const permFilter = reactive({ status: '', keyword: '' })
|
||||
const permPagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
const permStats = reactive([
|
||||
{ key: 'pending', icon: '⏳', label: '待审核', value: 0, color: 'orange' },
|
||||
{ key: 'approved', icon: '✅', label: '已通过', value: 0, color: 'green' },
|
||||
{ key: 'rejected', icon: '❌', label: '已拒绝', value: 0, color: 'red' },
|
||||
{ key: '', icon: '📦', label: '全部', value: 0, color: 'blue' },
|
||||
])
|
||||
|
||||
const permColumns = [
|
||||
{ title: '申请人', key: 'applicant', width: 160 },
|
||||
{ title: '仓库', key: 'repo', width: 200 },
|
||||
{ title: '申请理由', key: 'reason', ellipsis: true },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '申请时间', key: 'time', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 200 },
|
||||
]
|
||||
|
||||
// 详情弹窗
|
||||
const showPermDetailModal = ref(false)
|
||||
const currentPerm = ref<PermissionRequestItem | null>(null)
|
||||
const approvingPerm = ref(false)
|
||||
|
||||
async function loadPermRequests() {
|
||||
loadingPerm.value = true
|
||||
try {
|
||||
const res = await pagePermissionRequestsAdmin({
|
||||
page: permPagination.current,
|
||||
size: permPagination.pageSize,
|
||||
status: permFilter.status || undefined,
|
||||
keyword: permFilter.keyword || undefined,
|
||||
})
|
||||
const listData = (res as any)?.data?.data
|
||||
permRequests.value = listData?.records || []
|
||||
permPagination.total = listData?.total || 0
|
||||
updatePermStats()
|
||||
} catch {
|
||||
message.error('加载权限申请列表失败')
|
||||
} finally {
|
||||
loadingPerm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePermStats() {
|
||||
try {
|
||||
const [pendingRes, approvedRes, rejectedRes, allRes] = await Promise.allSettled([
|
||||
pagePermissionRequestsAdmin({ page: 1, size: 1, status: 'pending' }),
|
||||
pagePermissionRequestsAdmin({ page: 1, size: 1, status: 'approved' }),
|
||||
pagePermissionRequestsAdmin({ page: 1, size: 1, status: 'rejected' }),
|
||||
pagePermissionRequestsAdmin({ page: 1, size: 1 }),
|
||||
])
|
||||
const extract = (r: any) => (r.status === 'fulfilled' ? (r.value as any)?.data?.data?.total || 0 : 0)
|
||||
permStats[0].value = extract(pendingRes)
|
||||
permStats[1].value = extract(approvedRes)
|
||||
permStats[2].value = extract(rejectedRes)
|
||||
permStats[3].value = extract(allRes)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function handlePermStatFilter(key: string) {
|
||||
permFilter.status = key
|
||||
permPagination.current = 1
|
||||
loadPermRequests()
|
||||
}
|
||||
|
||||
function handlePermTableChange(pag: any) {
|
||||
permPagination.current = pag.current
|
||||
permPagination.pageSize = pag.pageSize
|
||||
loadPermRequests()
|
||||
}
|
||||
|
||||
function handleViewPermDetail(record: PermissionRequestItem) {
|
||||
currentPerm.value = record
|
||||
showPermDetailModal.value = true
|
||||
}
|
||||
|
||||
async function handleApprovePerm(record: PermissionRequestItem) {
|
||||
approvingPerm.value = true
|
||||
try {
|
||||
const res = await approvePermissionRequest(record.id) as any
|
||||
if (res?.data?.code === 200 || res?.data?.code === 0) {
|
||||
message.success(`仓库权限「${record.repo}」已通过审核`)
|
||||
showPermDetailModal.value = false
|
||||
loadPermRequests()
|
||||
} else {
|
||||
message.error(res?.data?.message || '操作失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.data?.message || e?.message || '操作失败')
|
||||
} finally {
|
||||
approvingPerm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRejectPerm(record: PermissionRequestItem) {
|
||||
rejectType.value = 'perm'
|
||||
rejectTargetId.value = record.id
|
||||
rejectTargetRecord.value = record
|
||||
rejectReasonInput.value = ''
|
||||
showRejectModal.value = true
|
||||
}
|
||||
|
||||
// ==================== 确认拒绝 ====================
|
||||
async function confirmReject() {
|
||||
if (!rejectReasonInput.value.trim()) {
|
||||
message.warning('请填写拒绝原因')
|
||||
return
|
||||
}
|
||||
rejecting.value = true
|
||||
try {
|
||||
if (rejectType.value === 'git') {
|
||||
const res = await rejectGitAccount(rejectTargetId.value, rejectReasonInput.value) as any
|
||||
if (res?.data?.code === 200 || res?.data?.code === 0) {
|
||||
message.success('已拒绝 Git 账号绑定申请')
|
||||
showGitDetailModal.value = false
|
||||
showRejectModal.value = false
|
||||
loadGitAccounts()
|
||||
} else {
|
||||
message.error(res?.data?.message || '操作失败')
|
||||
}
|
||||
} else {
|
||||
const res = await rejectPermissionRequest(rejectTargetId.value, rejectReasonInput.value) as any
|
||||
if (res?.data?.code === 200 || res?.data?.code === 0) {
|
||||
message.success('已拒绝仓库权限申请')
|
||||
showPermDetailModal.value = false
|
||||
showRejectModal.value = false
|
||||
loadPermRequests()
|
||||
} else {
|
||||
message.error(res?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.data?.message || e?.message || '操作失败')
|
||||
} finally {
|
||||
rejecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 状态映射 ====================
|
||||
function gitStatusText(status?: string) {
|
||||
const map: Record<string, string> = { pending: '待审核', verified: '已通过', rejected: '已拒绝' }
|
||||
return map[status || ''] || status || '-'
|
||||
}
|
||||
|
||||
function gitStatusColor(status?: string) {
|
||||
const map: Record<string, string> = { pending: 'orange', verified: 'success', rejected: 'error' }
|
||||
return map[status || ''] || 'default'
|
||||
}
|
||||
|
||||
function permStatusText(status?: string) {
|
||||
const map: Record<string, string> = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
|
||||
return map[status || ''] || status || '-'
|
||||
}
|
||||
|
||||
function permStatusColor(status?: string) {
|
||||
const map: Record<string, string> = { pending: 'orange', approved: 'success', rejected: 'error' }
|
||||
return map[status || ''] || 'default'
|
||||
}
|
||||
|
||||
// ==================== 加载全部 ====================
|
||||
function loadAll() {
|
||||
loadGitAccounts()
|
||||
loadPermRequests()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</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);
|
||||
}
|
||||
|
||||
/* 用户信息格 */
|
||||
.user-info-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
flex-shrink: 0;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.user-info-text { flex: 1; min-width: 0; }
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.reason-text {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 详情弹窗 */
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.detail-reason {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 拒绝原因提示 */
|
||||
.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 { color: rgba(0,0,0,0.45); }
|
||||
</style>
|
||||
271
app/pages/admin/index.vue
Normal file
271
app/pages/admin/index.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="admin-home">
|
||||
<!-- 欢迎横幅 -->
|
||||
<div class="welcome-banner">
|
||||
<div class="welcome-left">
|
||||
<h2 class="welcome-title">🎛️ 平台管理中心</h2>
|
||||
<p class="welcome-sub">欢迎回来,{{ adminName }},今日数据已更新</p>
|
||||
</div>
|
||||
<div class="welcome-right">
|
||||
<a-space>
|
||||
<a-tag color="red" style="font-size:13px;padding:4px 12px">超级管理员</a-tag>
|
||||
<a-button size="small" @click="loadStats" :loading="loadingStats">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 核心数据统计 -->
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="12" :sm="12" :md="6" v-for="stat in coreStats" :key="stat.label">
|
||||
<div class="stat-block" :class="stat.color" @click="navigateTo(stat.to)" :style="{ cursor: stat.to ? 'pointer' : 'default' }">
|
||||
<div class="stat-block-header">
|
||||
<span class="stat-block-icon">{{ stat.icon }}</span>
|
||||
<span class="stat-block-label">{{ stat.label }}</span>
|
||||
</div>
|
||||
<div class="stat-block-value">
|
||||
<template v-if="loadingStats">
|
||||
<a-skeleton-input :active="true" size="small" style="width:60px" />
|
||||
</template>
|
||||
<template v-else>{{ stat.value }}</template>
|
||||
</div>
|
||||
<div class="stat-block-desc">{{ stat.desc }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 待办事项 + 快速入口 -->
|
||||
<a-row :gutter="[16, 16]">
|
||||
<!-- 待处理事项 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">🔔 待处理事项</span>
|
||||
</div>
|
||||
<div class="todo-list">
|
||||
<div
|
||||
v-for="todo in todoItems"
|
||||
:key="todo.label"
|
||||
class="todo-item"
|
||||
:class="{ 'todo-item-urgent': todo.urgent }"
|
||||
@click="navigateTo(todo.to)"
|
||||
>
|
||||
<div class="todo-dot" :class="todo.dotColor"></div>
|
||||
<div class="todo-content">
|
||||
<span class="todo-label">{{ todo.label }}</span>
|
||||
<a-tag :color="todo.tagColor" style="margin-left:8px">
|
||||
<template v-if="loadingStats">...</template>
|
||||
<template v-else>{{ todo.value }}</template>
|
||||
</a-tag>
|
||||
</div>
|
||||
<RightOutlined class="todo-arrow" />
|
||||
</div>
|
||||
<div v-if="!loadingStats && todoItems.every(t => t.value === 0)" class="todo-empty">
|
||||
🎉 暂无待处理事项,一切正常!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 快速导航 -->
|
||||
<a-col :xs="24" :md="12">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">⚡ 快速入口</span>
|
||||
</div>
|
||||
<div class="quick-grid">
|
||||
<div
|
||||
v-for="item in quickLinks"
|
||||
:key="item.to"
|
||||
class="quick-card"
|
||||
@click="navigateTo(item.to)"
|
||||
>
|
||||
<div class="quick-icon" :style="{ background: item.bg }">{{ item.icon }}</div>
|
||||
<div class="quick-label">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||||
import { getUserInfo } from '@/api/layout'
|
||||
import { getToken } from '@/utils/token-util'
|
||||
import { pageAppProductAll } from '@/api/app/appProduct'
|
||||
import { pageUsers } from '@/api/system/user'
|
||||
import { listAppArticle as listCmsArticle } from '@/api/app/article'
|
||||
import { pageGitAccounts } from '@/api/developer'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '平台管理首页' })
|
||||
|
||||
const adminName = ref('管理员')
|
||||
const loadingStats = ref(false)
|
||||
|
||||
const coreStats = reactive([
|
||||
{ icon: '📦', label: '应用总数', value: 0, desc: '全平台应用', color: 'blue', to: '/admin/apps' },
|
||||
{ icon: '👥', label: '用户总数', value: 0, desc: '注册用户', color: 'green', to: '/admin/users' },
|
||||
{ icon: '⏳', label: '待审核应用', value: 0, desc: '等待审核中', color: 'orange', to: '/admin/app-review' },
|
||||
{ icon: '🛒', label: '上架应用', value: 0, desc: '市场在售', color: 'purple', to: '/admin/market' },
|
||||
])
|
||||
|
||||
const todoItems = reactive([
|
||||
{ label: '待审核应用', value: 0, to: '/admin/app-review', tagColor: 'orange', dotColor: 'dot-orange', urgent: false },
|
||||
{ label: '待审核Git账号', value: 0, to: '/admin/git-review', tagColor: 'cyan', dotColor: 'dot-cyan', urgent: false },
|
||||
{ label: '草稿文章', value: 0, to: '/admin/articles', tagColor: 'blue', dotColor: 'dot-blue', urgent: false },
|
||||
{ label: '冻结用户', value: 0, to: '/admin/users', tagColor: 'red', dotColor: 'dot-red', urgent: false },
|
||||
])
|
||||
|
||||
const ANNOUNCE_MODEL = 'announcement'
|
||||
|
||||
const quickLinks = [
|
||||
{ to: '/admin/app-review', icon: '🔍', label: '应用审核', bg: '#fff7ed' },
|
||||
{ to: '/admin/git-review', icon: '🔧', label: 'Git 审核', bg: '#ecfdf5' },
|
||||
{ to: '/admin/apps', icon: '📦', label: '应用管理', bg: '#eff6ff' },
|
||||
{ to: '/admin/market', icon: '🛒', label: '应用市场', bg: '#faf5ff' },
|
||||
{ to: '/admin/users', icon: '👥', label: '用户管理', bg: '#f0fdf4' },
|
||||
{ to: '/admin/developers', icon: '🧑💻', label: '开发者', bg: '#f0f9ff' },
|
||||
{ to: '/admin/tickets', icon: '🎫', label: '工单处理', bg: '#fdf4ff' },
|
||||
{ to: '/admin/articles', icon: '📝', label: '文章管理', bg: '#fefce8' },
|
||||
{ to: '/admin/article-categories', icon: '🗂️', label: '文章分类', bg: '#ecfeff' },
|
||||
{ to: '/admin/announcements', icon: '📢', label: '公告管理', bg: '#fff1f2' },
|
||||
{ to: '/admin/settings', icon: '⚙️', label: '平台设置', bg: '#f9fafb' },
|
||||
]
|
||||
|
||||
async function loadStats() {
|
||||
loadingStats.value = true
|
||||
try {
|
||||
const [appsRes, usersRes, pendingRes, marketRes, draftRes, frozenRes, gitPendingRes] = await Promise.allSettled([
|
||||
pageAppProductAll({ current: 1, size: 1 }),
|
||||
pageUsers({ page: 1, limit: 1 }),
|
||||
pageAppProductAll({ current: 1, size: 1, publishStatus: 'pending_review' }),
|
||||
pageAppProductAll({ current: 1, size: 1, publishStatus: 'published' }),
|
||||
listCmsArticle({ status: 1 }),
|
||||
pageUsers({ page: 1, limit: 1, status: 1 }),
|
||||
pageGitAccounts({ page: 1, size: 1, status: 'pending' }),
|
||||
])
|
||||
|
||||
coreStats[0].value = appsRes.status === 'fulfilled' ? appsRes.value?.count || 0 : 0
|
||||
coreStats[1].value = usersRes.status === 'fulfilled' ? usersRes.value?.count || 0 : 0
|
||||
coreStats[2].value = pendingRes.status === 'fulfilled' ? pendingRes.value?.count || 0 : 0
|
||||
coreStats[3].value = marketRes.status === 'fulfilled' ? marketRes.value?.count || 0 : 0
|
||||
|
||||
const pendingCount = pendingRes.status === 'fulfilled' ? pendingRes.value?.count || 0 : 0
|
||||
const draftCount = draftRes.status === 'fulfilled'
|
||||
? (draftRes.value || []).filter(item => (item.model || '').trim() !== ANNOUNCE_MODEL).length
|
||||
: 0
|
||||
const frozenCount = frozenRes.status === 'fulfilled' ? frozenRes.value?.count || 0 : 0
|
||||
const gitPendingCount = gitPendingRes.status === 'fulfilled' ? (gitPendingRes.value as any)?.data?.data?.total || 0 : 0
|
||||
|
||||
todoItems[0].value = pendingCount
|
||||
todoItems[0].urgent = pendingCount > 0
|
||||
todoItems[1].value = gitPendingCount
|
||||
todoItems[1].urgent = gitPendingCount > 0
|
||||
todoItems[2].value = draftCount
|
||||
todoItems[3].value = frozenCount
|
||||
} catch { /* ignore */ } finally {
|
||||
loadingStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
// 并发加载用户信息和统计数据
|
||||
Promise.allSettled([
|
||||
getUserInfo().then(me => {
|
||||
adminName.value = me?.nickname?.trim() || me?.username?.trim() || '管理员'
|
||||
}),
|
||||
loadStats(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 欢迎横幅 */
|
||||
.welcome-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #1a0f0f 0%, #3d1515 100%);
|
||||
border-radius: 14px;
|
||||
padding: 24px 28px;
|
||||
color: #fff;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.welcome-title { font-size: 20px; font-weight: 700; color: #fff; margin: 0 0 6px; }
|
||||
.welcome-sub { font-size: 14px; color: rgba(255,255,255,0.7); margin: 0; }
|
||||
|
||||
/* 核心统计块 */
|
||||
.stat-block {
|
||||
padding: 18px 20px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.stat-block:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.08); }
|
||||
.stat-block.blue { background: #eff6ff; border-color: #dbeafe; }
|
||||
.stat-block.green { background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.stat-block.orange { background: #fff7ed; border-color: #fed7aa; }
|
||||
.stat-block.purple { background: #faf5ff; border-color: #e9d5ff; }
|
||||
|
||||
.stat-block-header { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
|
||||
.stat-block-icon { font-size: 18px; }
|
||||
.stat-block-label { font-size: 13px; color: rgba(0,0,0,0.55); }
|
||||
.stat-block-value { font-size: 32px; font-weight: 800; color: rgba(0,0,0,0.85); line-height: 1.1; margin-bottom: 4px; }
|
||||
.stat-block-desc { font-size: 12px; color: rgba(0,0,0,0.4); }
|
||||
|
||||
/* Panel */
|
||||
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
|
||||
.panel-header { padding: 14px 18px; border-bottom: 1px solid #f5f5f5; }
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
|
||||
|
||||
/* 待办 */
|
||||
.todo-list { padding: 8px 0; }
|
||||
.todo-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 18px; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.todo-item:hover { background: #f9fafb; }
|
||||
.todo-item-urgent .todo-label { font-weight: 600; }
|
||||
.todo-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot-orange { background: #f97316; }
|
||||
.dot-blue { background: #3b82f6; }
|
||||
.dot-cyan { background: #06b6d4; }
|
||||
.dot-red { background: #ef4444; }
|
||||
.todo-content { flex: 1; display: flex; align-items: center; }
|
||||
.todo-label { font-size: 14px; color: rgba(0,0,0,0.75); }
|
||||
.todo-arrow { font-size: 11px; color: rgba(0,0,0,0.3); }
|
||||
.todo-empty { text-align: center; padding: 20px 0; color: rgba(0,0,0,0.4); font-size: 14px; }
|
||||
|
||||
/* 快速入口九宫格 */
|
||||
.quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.quick-card {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 8px; padding: 18px 12px; background: #fff;
|
||||
cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.quick-card:hover { background: #f9fafb; }
|
||||
.quick-icon {
|
||||
width: 44px; height: 44px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 22px;
|
||||
}
|
||||
.quick-label { font-size: 13px; color: rgba(0,0,0,0.75); font-weight: 500; }
|
||||
</style>
|
||||
525
app/pages/admin/market.vue
Normal file
525
app/pages/admin/market.vue
Normal file
@@ -0,0 +1,525 @@
|
||||
<template>
|
||||
<div class="market-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">
|
||||
<div class="stat-card blue">
|
||||
<div class="stat-icon">🛒</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ totalMarket }}</div>
|
||||
<div class="stat-label">市场总应用数</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="6">
|
||||
<div class="stat-card gold">
|
||||
<div class="stat-icon">⭐</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ totalRecommend }}</div>
|
||||
<div class="stat-label">推荐应用数</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="6">
|
||||
<div class="stat-card green">
|
||||
<div class="stat-icon">🏅</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ totalOfficial }}</div>
|
||||
<div class="stat-label">官方应用数</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="6">
|
||||
<div class="stat-card purple">
|
||||
<div class="stat-icon">🔌</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ totalPlugin }}</div>
|
||||
<div class="stat-label">插件数</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 市场应用列表 -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">🛒 市场应用列表</span>
|
||||
<a-space wrap>
|
||||
<a-select v-model:value="filterPublish" style="width: 130px" @change="handleSearch">
|
||||
<a-select-option value="">全部状态</a-select-option>
|
||||
<a-select-option value="published">已上架</a-select-option>
|
||||
<a-select-option value="deprecated">已下架</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="filterOfficial" style="width: 120px" @change="handleSearch">
|
||||
<a-select-option value="">全部类型</a-select-option>
|
||||
<a-select-option value="official">官方</a-select-option>
|
||||
<a-select-option value="third">第三方</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索应用"
|
||||
style="width: 200px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="apps"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="productId"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
>
|
||||
<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 }}
|
||||
<a-tag v-if="record.official" color="gold" style="margin-left:6px;font-size:11px">官方</a-tag>
|
||||
<a-tag color="blue" style="margin-left:4px;font-size:11px">{{ APP_TYPE_NAME[record.appType ?? 10] || '网站' }}</a-tag>
|
||||
</div>
|
||||
<div class="app-desc">{{ record.description || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 === 'metrics'">
|
||||
<div style="font-size:13px">
|
||||
<div>📥 {{ record.installs ?? 0 }} 次安装</div>
|
||||
<div style="color:#f59e0b">{{ record.rating ? '⭐ ' + record.rating : '暂无评分' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 推荐 -->
|
||||
<template v-if="column.key === 'recommend'">
|
||||
<a-switch
|
||||
:checked="!!record.recommend"
|
||||
size="small"
|
||||
@change="(val: boolean) => handleToggleRecommend(record, val)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 发布状态 -->
|
||||
<template v-if="column.key === 'publishStatus'">
|
||||
<a-tag :color="pubStatusColor(record.publishStatus)">{{ pubStatusText(record.publishStatus) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 上架时间 -->
|
||||
<template v-if="column.key === 'publishTime'">
|
||||
<span class="text-sm text-gray">{{ record.publishTime?.substring(0, 10) || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">详情</a-button>
|
||||
<a-popconfirm
|
||||
v-if="record.publishStatus === 'published'"
|
||||
title="确认从应用市场下架此应用?"
|
||||
@confirm="handleUnpublish(record)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>下架</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showDetailModal"
|
||||
:title="`市场详情:${currentApp?.productName || ''}`"
|
||||
width="720px"
|
||||
: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="应用类型">
|
||||
<a-tag color="blue">{{ APP_TYPE_NAME[currentApp.appType ?? 10] || '未知' }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="开发者">{{ currentApp.developer || currentApp.username || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="安装次数">{{ currentApp.installs ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="评分">{{ currentApp.rating ? currentApp.rating + ' ⭐' : '暂无' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="定价">
|
||||
<span v-if="currentApp.priceType === 'free' || !currentApp.priceType" class="price-free">免费</span>
|
||||
<span v-else class="price-paid">¥{{ ((currentApp.price || 0) / 100).toFixed(2) }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="上架时间">{{ 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>
|
||||
|
||||
<!-- 应用入口信息 -->
|
||||
<div v-if="currentAppEntries.length > 0" class="entry-section">
|
||||
<h4 class="entry-title">🚀 应用入口</h4>
|
||||
<div class="entry-list">
|
||||
<div
|
||||
v-for="entry in currentAppEntries"
|
||||
:key="entry.type"
|
||||
class="entry-item"
|
||||
:class="{ disabled: !entry.available }"
|
||||
>
|
||||
<div class="entry-info">
|
||||
<component :is="entryIcon(entry.icon)" style="font-size: 18px; margin-right: 8px;" />
|
||||
<div>
|
||||
<div class="entry-label">{{ entry.label }}</div>
|
||||
<div class="entry-url" v-if="entry.url">{{ entry.type === 'scan-qr' ? '已配置小程序码' : entry.url }}</div>
|
||||
<div class="entry-url text-gray-400" v-else>未配置</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="entry.available"
|
||||
type="primary"
|
||||
size="small"
|
||||
ghost
|
||||
@click="handleEntryClick(entry)"
|
||||
>
|
||||
{{ entry.type === 'visit-site' ? '访问' : entry.type === 'download' ? '下载' : entry.type === 'scan-qr' ? '扫码' : '进入' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 扫码弹窗 -->
|
||||
<QrCodeModal
|
||||
v-model:open="showQrModal"
|
||||
:title="qrModalTitle"
|
||||
:tip="qrModalTip"
|
||||
:image-url="qrModalUrl"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, GlobalOutlined, QrcodeOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { pageAppProductAll, updateAppProduct, unpublishAppProduct } from '@/api/app/appProduct'
|
||||
import type { AppProduct } from '@/api/app/appProduct/model'
|
||||
import { APP_TYPE, APP_TYPE_NAME } from '@/api/app/appProduct/model'
|
||||
import { getAppEntries, executeEntry, getScanTip } from '@/utils/appEntry'
|
||||
import type { AppEntry } from '@/utils/appEntry'
|
||||
import QrCodeModal from '@/components/QrCodeModal.vue'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '应用市场管理 - 平台管理' })
|
||||
|
||||
const loading = ref(false)
|
||||
const apps = ref<AppProduct[]>([])
|
||||
const filterPublish = ref('')
|
||||
const filterOfficial = ref('')
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const totalMarket = ref(0)
|
||||
const totalRecommend = ref(0)
|
||||
const totalOfficial = ref(0)
|
||||
const totalPlugin = ref(0)
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '应用信息', key: 'appInfo', width: 280 },
|
||||
{ title: '定价', key: 'price', width: 130 },
|
||||
{ title: '安装/评分', key: 'metrics', width: 130 },
|
||||
{ title: '推荐', key: 'recommend', width: 80 },
|
||||
{ title: '发布状态', key: 'publishStatus', width: 100 },
|
||||
{ title: '上架时间', key: 'publishTime', width: 110 },
|
||||
{ title: '操作', key: 'action', width: 130 },
|
||||
]
|
||||
|
||||
const showDetailModal = ref(false)
|
||||
const currentApp = ref<AppProduct | null>(null)
|
||||
|
||||
async function loadApps() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
current: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
market: true,
|
||||
keywords: searchKeyword.value || undefined,
|
||||
}
|
||||
if (filterPublish.value) params.publishStatus = filterPublish.value
|
||||
if (filterOfficial.value === 'official') params.official = true
|
||||
if (filterOfficial.value === 'third') params.official = false
|
||||
|
||||
const res = await pageAppProductAll(params)
|
||||
apps.value = res?.list || []
|
||||
pagination.total = res?.count || 0
|
||||
loadSummary()
|
||||
} catch {
|
||||
message.error('加载市场列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
try {
|
||||
const [allRes, offRes, plugRes] = await Promise.allSettled([
|
||||
pageAppProductAll({ current: 1, size: 1, market: true }),
|
||||
pageAppProductAll({ current: 1, size: 1, market: true, official: true }),
|
||||
pageAppProductAll({ current: 1, size: 1, market: true, plugin: true }),
|
||||
])
|
||||
if (allRes.status === 'fulfilled') totalMarket.value = allRes.value?.count || 0
|
||||
if (offRes.status === 'fulfilled') totalOfficial.value = offRes.value?.count || 0
|
||||
if (plugRes.status === 'fulfilled') totalPlugin.value = plugRes.value?.count || 0
|
||||
// 推荐数从当前列表里统计(recommend字段为 1 的数量)
|
||||
totalRecommend.value = apps.value.filter(a => !!a.recommend).length
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadApps()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadApps()
|
||||
}
|
||||
|
||||
function handleView(record: AppProduct) {
|
||||
currentApp.value = record
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
async function handleToggleRecommend(record: AppProduct, val: boolean) {
|
||||
try {
|
||||
await updateAppProduct({ productId: record.productId, recommend: val ? 1 : 0 })
|
||||
message.success(val ? '已加入推荐' : '已取消推荐')
|
||||
loadApps()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnpublish(record: AppProduct) {
|
||||
try {
|
||||
await unpublishAppProduct(record.productId!)
|
||||
message.success(`「${record.productName}」已从市场下架`)
|
||||
loadApps()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function pubStatusColor(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending_review: 'orange', published: 'success', rejected: 'error', deprecated: 'default',
|
||||
}
|
||||
return map[status || ''] || 'default'
|
||||
}
|
||||
|
||||
function pubStatusText(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending_review: '待审核', published: '已上架', rejected: '已拒绝', deprecated: '已下架',
|
||||
}
|
||||
return map[status || ''] || '-'
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
const showQrModal = ref(false)
|
||||
const qrModalTitle = ref('')
|
||||
const qrModalTip = ref('')
|
||||
const qrModalUrl = ref('')
|
||||
|
||||
const currentAppEntries = computed(() => {
|
||||
if (!currentApp.value) return []
|
||||
return getAppEntries(currentApp.value)
|
||||
})
|
||||
|
||||
function entryIcon(iconName: string) {
|
||||
const map: Record<string, any> = {
|
||||
GlobalOutlined,
|
||||
QrcodeOutlined,
|
||||
DownloadOutlined,
|
||||
SettingOutlined,
|
||||
}
|
||||
return map[iconName] || GlobalOutlined
|
||||
}
|
||||
|
||||
function handleEntryClick(entry: AppEntry) {
|
||||
const needQr = executeEntry(entry)
|
||||
if (needQr && entry.type === 'scan-qr' && currentApp.value) {
|
||||
qrModalTitle.value = currentApp.value.productName || '小程序码'
|
||||
qrModalTip.value = getScanTip(currentApp.value.appType || 20)
|
||||
qrModalUrl.value = entry.url || ''
|
||||
showQrModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadApps())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.market-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; }
|
||||
.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;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||||
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
|
||||
.stat-card.gold { background: #fffbeb; border-color: #fde68a; }
|
||||
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.stat-card.purple { background: #faf5ff; border-color: #e9d5ff; }
|
||||
.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; flex-wrap: wrap; gap: 10px;
|
||||
}
|
||||
.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: 48px; height: 48px; border-radius: 10px; object-fit: cover; flex-shrink: 0; }
|
||||
.app-icon-placeholder {
|
||||
width: 48px; height: 48px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; font-weight: 600; color: #fff; flex-shrink: 0;
|
||||
}
|
||||
.app-info-text { flex: 1; min-width: 0; }
|
||||
.app-name { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); display: flex; align-items: center; }
|
||||
.app-desc { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
|
||||
|
||||
.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-desc { max-height: 200px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; }
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-gray { color: rgba(0,0,0,0.45); }
|
||||
.text-gray-400 { color: #9ca3af; }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
|
||||
.entry-section {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0,0,0,0.85);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.entry-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.entry-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.entry-item.disabled { opacity: 0.5; }
|
||||
|
||||
.entry-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0,0,0,0.85);
|
||||
}
|
||||
|
||||
.entry-url {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 400px;
|
||||
}
|
||||
</style>
|
||||
871
app/pages/admin/settings.vue
Normal file
871
app/pages/admin/settings.vue
Normal file
@@ -0,0 +1,871 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">⚙️ 平台设置</h2>
|
||||
<p class="page-desc">管理平台核心配置项,修改后立即生效</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="[20, 20]">
|
||||
<!-- 左侧菜单 -->
|
||||
<a-col :xs="24" :md="6">
|
||||
<div class="settings-nav">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="settings-nav-item"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
<span class="nav-icon">{{ tab.icon }}</span>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<a-col :xs="24" :md="18">
|
||||
<div class="settings-panel">
|
||||
<!-- 基础配置 -->
|
||||
<template v-if="activeTab === 'basic'">
|
||||
<div class="settings-section-title">🌐 基础配置</div>
|
||||
<a-form :model="basicForm" layout="vertical" class="settings-form">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="平台名称">
|
||||
<a-input v-model:value="basicForm.siteName" placeholder="例:CloudBuddy 应用平台" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="平台域名">
|
||||
<a-input v-model:value="basicForm.domain" placeholder="例:app.example.com" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="平台简介">
|
||||
<a-textarea v-model:value="basicForm.description" :rows="3" placeholder="平台简短描述" :maxlength="500" show-count />
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="客服邮箱">
|
||||
<a-input v-model:value="basicForm.supportEmail" placeholder="support@example.com" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="客服电话">
|
||||
<a-input v-model:value="basicForm.supportPhone" placeholder="400-xxx-xxxx" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="ICP 备案号">
|
||||
<a-input v-model:value="basicForm.icpNo" placeholder="例:浙ICP备xxxxxxxx号" />
|
||||
</a-form-item>
|
||||
<div class="form-footer">
|
||||
<a-button type="primary" :loading="savingBasic" @click="saveBasic">💾 保存基础配置</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<!-- 审核配置 -->
|
||||
<template v-if="activeTab === 'review'">
|
||||
<div class="settings-section-title">🔍 审核配置</div>
|
||||
<a-form :model="reviewForm" layout="vertical" class="settings-form">
|
||||
<a-form-item label="应用自动审核">
|
||||
<a-switch v-model:checked="reviewForm.autoReview" />
|
||||
<span class="form-hint">开启后,应用提交后将自动通过审核(仅用于测试环境)</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="审核通知邮箱">
|
||||
<a-input v-model:value="reviewForm.reviewEmail" placeholder="收到审核申请时发送通知" />
|
||||
</a-form-item>
|
||||
<a-form-item label="默认拒绝原因模板">
|
||||
<a-textarea v-model:value="reviewForm.defaultRejectReason" :rows="4" placeholder="填写常见的拒绝原因模板..." />
|
||||
</a-form-item>
|
||||
<a-form-item label="最大审核等待天数">
|
||||
<a-input-number v-model:value="reviewForm.maxWaitDays" :min="1" :max="30" style="width:120px" addonAfter="天" />
|
||||
<span class="form-hint">超出等待时间将自动提醒审核人员</span>
|
||||
</a-form-item>
|
||||
<div class="form-footer">
|
||||
<a-button type="primary" :loading="savingReview" @click="saveReview">💾 保存审核配置</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<!-- 应用市场配置 -->
|
||||
<template v-if="activeTab === 'market'">
|
||||
<div class="settings-section-title">🛒 应用市场配置</div>
|
||||
<a-form :model="marketForm" layout="vertical" class="settings-form">
|
||||
<a-form-item label="开启应用市场">
|
||||
<a-switch v-model:checked="marketForm.enableMarket" />
|
||||
<span class="form-hint">关闭后,前台市场页面将不可访问</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="允许第三方应用上架">
|
||||
<a-switch v-model:checked="marketForm.allowThirdParty" />
|
||||
<span class="form-hint">开启后,普通开发者可申请将应用上架至市场</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="平台服务费率 (%)">
|
||||
<a-input-number
|
||||
v-model:value="marketForm.commissionRate"
|
||||
:min="0" :max="50" :step="0.5"
|
||||
style="width:150px"
|
||||
addonAfter="%"
|
||||
/>
|
||||
<span class="form-hint">平台从付费应用销售额中抽取的比例</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="每页展示数量">
|
||||
<a-input-number v-model:value="marketForm.pageSize" :min="6" :max="50" :step="6" style="width:120px" addonAfter="个" />
|
||||
</a-form-item>
|
||||
<div class="form-footer">
|
||||
<a-button type="primary" :loading="savingMarket" @click="saveMarket">💾 保存市场配置</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<!-- 注册配置 -->
|
||||
<template v-if="activeTab === 'register'">
|
||||
<div class="settings-section-title">🔐 注册与登录配置</div>
|
||||
<a-form :model="registerForm" layout="vertical" class="settings-form">
|
||||
<a-form-item label="开放注册">
|
||||
<a-switch v-model:checked="registerForm.enableRegister" />
|
||||
<span class="form-hint">关闭后,新用户无法自助注册</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="注册需要邮箱验证">
|
||||
<a-switch v-model:checked="registerForm.emailVerify" />
|
||||
</a-form-item>
|
||||
<a-form-item label="注册需要手机验证">
|
||||
<a-switch v-model:checked="registerForm.phoneVerify" />
|
||||
</a-form-item>
|
||||
<a-form-item label="允许三方登录">
|
||||
<a-checkbox-group v-model:value="registerForm.oauthProviders">
|
||||
<a-checkbox value="wechat">微信</a-checkbox>
|
||||
<a-checkbox value="github">GitHub</a-checkbox>
|
||||
<a-checkbox value="google">Google</a-checkbox>
|
||||
<a-checkbox value="dingtalk">钉钉</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="默认用户角色">
|
||||
<a-input v-model:value="registerForm.defaultRole" placeholder="新注册用户自动分配的角色" />
|
||||
</a-form-item>
|
||||
<div class="form-footer">
|
||||
<a-button type="primary" :loading="savingRegister" @click="saveRegister">💾 保存注册配置</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<!-- 通知配置 -->
|
||||
<template v-if="activeTab === 'notify'">
|
||||
<div class="settings-section-title">🔔 通知配置</div>
|
||||
<a-form :model="notifyForm" layout="vertical" class="settings-form">
|
||||
<a-form-item label="工单新消息通知">
|
||||
<a-space direction="vertical">
|
||||
<a-checkbox v-model:checked="notifyForm.ticketEmail">邮件通知</a-checkbox>
|
||||
<a-checkbox v-model:checked="notifyForm.ticketSms">短信通知</a-checkbox>
|
||||
<a-checkbox v-model:checked="notifyForm.ticketWechat">微信公众号通知</a-checkbox>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
<a-form-item label="审核结果通知开发者">
|
||||
<a-space direction="vertical">
|
||||
<a-checkbox v-model:checked="notifyForm.reviewEmail">邮件通知</a-checkbox>
|
||||
<a-checkbox v-model:checked="notifyForm.reviewSms">短信通知</a-checkbox>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
<a-form-item label="系统公告推送">
|
||||
<a-switch v-model:checked="notifyForm.announcePush" />
|
||||
<span class="form-hint">发布公告时向所有用户推送系统消息</span>
|
||||
</a-form-item>
|
||||
<div class="form-footer">
|
||||
<a-button type="primary" :loading="savingNotify" @click="saveNotify">💾 保存通知配置</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<!-- 微信小程序配置 -->
|
||||
<template v-if="activeTab === 'miniprogram'">
|
||||
<div class="settings-section-title">📱 微信小程序配置</div>
|
||||
<a-form :model="miniprogramForm" layout="vertical" class="settings-form">
|
||||
<a-form-item label="小程序 AppID">
|
||||
<a-input v-model:value="miniprogramForm.appId" placeholder="请输入微信小程序 AppID" />
|
||||
<div class="form-tip">在微信公众平台获取小程序的 AppID</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="小程序 AppSecret">
|
||||
<a-input-password v-model:value="miniprogramForm.appSecret" placeholder="请输入微信小程序 AppSecret" />
|
||||
<div class="form-tip">在微信公众平台获取小程序的 AppSecret,请妥善保管</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="默认页面路径">
|
||||
<a-input v-model:value="miniprogramForm.defaultPage" placeholder="如:pages/public/qr-confirm/index" />
|
||||
<div class="form-tip">生成小程序码时使用的默认页面路径(不填则使用 pages/public/qr-confirm/index)</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="二维码宽度">
|
||||
<a-input-number v-model:value="miniprogramForm.width" :min="28" :max="1280" :step="10" style="width:150px" addonAfter="px" />
|
||||
<div class="form-tip">生成的小程序码宽度,建议 280px</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="环境版本">
|
||||
<a-radio-group v-model:value="miniprogramForm.envVersion">
|
||||
<a-radio value="release">正式版</a-radio>
|
||||
<a-radio value="trial">体验版</a-radio>
|
||||
<a-radio value="develop">开发版</a-radio>
|
||||
</a-radio-group>
|
||||
<div class="form-tip">生成小程序码使用的环境,建议使用正式版</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="启用小程序码">
|
||||
<a-switch v-model:checked="miniprogramForm.enabled" />
|
||||
<span class="form-hint">关闭后,邀请等功能将降级使用普通二维码</span>
|
||||
</a-form-item>
|
||||
<div class="form-footer">
|
||||
<a-button type="primary" :loading="savingMiniprogram" @click="saveMiniprogram">💾 保存小程序配置</a-button>
|
||||
<a-button :loading="testingConnection" @click="testMiniprogramConnection" style="margin-left: 12px">🔗 测试连接</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<!-- 支付配置 -->
|
||||
<template v-if="activeTab === 'payment'">
|
||||
<div class="settings-section-title">💳 支付配置</div>
|
||||
<a-form :model="paymentForm" layout="vertical" class="settings-form">
|
||||
|
||||
<!-- 微信支付配置 -->
|
||||
<div class="payment-section-title">🍎 微信支付</div>
|
||||
<a-form-item label="启用微信支付">
|
||||
<a-switch v-model:checked="paymentForm.wechat.enabled" />
|
||||
</a-form-item>
|
||||
<template v-if="paymentForm.wechat.enabled">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="商户号 (mchid)">
|
||||
<a-input v-model:value="paymentForm.wechat.mchid" placeholder="微信支付商户号" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="商户密钥 (mchkey)">
|
||||
<a-input-password v-model:value="paymentForm.wechat.mchkey" placeholder="商户密钥" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="AppID">
|
||||
<a-input v-model:value="paymentForm.wechat.appid" placeholder="关联的应用 AppID" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="证书序列号">
|
||||
<a-input v-model:value="paymentForm.wechat.serialNo" placeholder="商户证书序列号" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<div class="payment-section-title" style="margin-top: 16px;">证书配置 (V3 API)</div>
|
||||
<a-form-item label="证书内容 (apiclient_cert.pem)">
|
||||
<a-textarea v-model:value="paymentForm.wechat.certContent" :rows="4" placeholder="-----BEGIN CERTIFICATE----- MIICpTCCAY... -----END CERTIFICATE-----" />
|
||||
<div class="form-tip">粘贴微信支付证书内容(.pem 格式)</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="私钥内容 (apiclient_key.pem)">
|
||||
<a-textarea v-model:value="paymentForm.wechat.keyContent" :rows="4" placeholder="-----BEGIN PRIVATE KEY----- MIIEvQIBADAN... -----END PRIVATE KEY-----" />
|
||||
<div class="form-tip">粘贴微信支付私钥内容(.pem 格式)</div>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 支付宝配置 -->
|
||||
<div class="payment-section-title" style="margin-top: 24px;">💙 支付宝</div>
|
||||
<a-form-item label="启用支付宝">
|
||||
<a-switch v-model:checked="paymentForm.alipay.enabled" />
|
||||
</a-form-item>
|
||||
<template v-if="paymentForm.alipay.enabled">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="AppID">
|
||||
<a-input v-model:value="paymentForm.alipay.appId" placeholder="支付宝应用 AppID" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="签名方式">
|
||||
<a-select v-model:value="paymentForm.alipay.signType" style="width: 150px">
|
||||
<a-select-option value="RSA2">RSA2(推荐)</a-select-option>
|
||||
<a-select-option value="RSA">RSA</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="支付宝公钥">
|
||||
<a-textarea v-model:value="paymentForm.alipay.alipayPublicKey" :rows="3" placeholder="支付宝公钥内容" />
|
||||
</a-form-item>
|
||||
<a-form-item label="应用私钥">
|
||||
<a-textarea v-model:value="paymentForm.alipay.appPrivateKey" :rows="4" placeholder="应用私钥内容" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 通用配置 -->
|
||||
<div class="payment-section-title" style="margin-top: 24px;">⚙️ 通用设置</div>
|
||||
<a-form-item label="支付回调地址">
|
||||
<a-input v-model:value="paymentForm.notifyUrl" placeholder="如:https://api.example.com/api/pay/notify" />
|
||||
<div class="form-tip">支付完成后微信/支付宝会通知此地址</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="支付成功跳转页面">
|
||||
<a-input v-model:value="paymentForm.returnUrl" placeholder="如:/user/orders" />
|
||||
</a-form-item>
|
||||
<a-form-item label="沙箱环境">
|
||||
<a-switch v-model:checked="paymentForm.sandbox" />
|
||||
<span class="form-hint">开启后使用测试环境,仅用于开发调试</span>
|
||||
</a-form-item>
|
||||
|
||||
<div class="form-footer">
|
||||
<a-button type="primary" :loading="savingPayment" @click="savePayment">💾 保存支付配置</a-button>
|
||||
<a-button @click="testPaymentConnection" style="margin-left: 12px">🔗 测试连接</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<!-- 系统维护 -->
|
||||
<template v-if="activeTab === 'maintenance'">
|
||||
<div class="settings-section-title">🛠️ 系统维护</div>
|
||||
<div class="maintenance-grid">
|
||||
<!-- 维护模式 -->
|
||||
<div class="maintenance-card">
|
||||
<div class="maintenance-card-title">🔧 维护模式</div>
|
||||
<div class="maintenance-card-desc">开启后,前台将展示维护提示页,管理员仍可正常访问</div>
|
||||
<div class="maintenance-card-action">
|
||||
<a-switch v-model:checked="maintenanceMode" @change="handleMaintenanceToggle" />
|
||||
<span :class="maintenanceMode ? 'status-on' : 'status-off'">{{ maintenanceMode ? '维护中' : '正常运行' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 清除缓存 -->
|
||||
<div class="maintenance-card">
|
||||
<div class="maintenance-card-title">🗑️ 清除系统缓存</div>
|
||||
<div class="maintenance-card-desc">清除应用信息、配置项等缓存数据,适用于配置更新后</div>
|
||||
<div class="maintenance-card-action">
|
||||
<a-button :loading="clearingCache" @click="handleClearCache">立即清除</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 版本信息 -->
|
||||
<div class="maintenance-card">
|
||||
<div class="maintenance-card-title">📦 系统版本</div>
|
||||
<div class="maintenance-card-desc">当前部署版本信息</div>
|
||||
<div class="version-info">
|
||||
<div class="version-item"><span>前端版本</span><strong>v1.0.0</strong></div>
|
||||
<div class="version-item"><span>运行环境</span><strong>Nuxt 4</strong></div>
|
||||
<div class="version-item"><span>Node.js</span><strong>20.x</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据备份 -->
|
||||
<div class="maintenance-card">
|
||||
<div class="maintenance-card-title">💾 数据备份提醒</div>
|
||||
<div class="maintenance-card-desc">请确保定期对数据库进行备份,防止数据丢失</div>
|
||||
<div class="maintenance-card-action">
|
||||
<a-alert type="info" message="数据备份建议每天执行一次,请联系运维人员配置自动备份任务" show-icon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { message } from 'ant-design-vue'
|
||||
import { toRaw } from 'vue'
|
||||
import { batchSaveCategory, getSettingByKey } from '@/api/app/setting/index'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '平台设置 - 平台管理' })
|
||||
|
||||
const activeTab = ref('basic')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'basic', icon: '🌐', label: '基础配置' },
|
||||
{ key: 'review', icon: '🔍', label: '审核配置' },
|
||||
{ key: 'market', icon: '🛒', label: '市场配置' },
|
||||
{ key: 'register', icon: '🔐', label: '注册登录' },
|
||||
{ key: 'notify', icon: '🔔', label: '通知配置' },
|
||||
{ key: 'miniprogram', icon: '📱', label: '微信小程序' },
|
||||
{ key: 'payment', icon: '💳', label: '支付配置' },
|
||||
{ key: 'maintenance', icon: '🛠️', label: '系统维护' },
|
||||
]
|
||||
|
||||
// 基础配置
|
||||
const savingBasic = ref(false)
|
||||
const basicForm = reactive({
|
||||
siteName: '',
|
||||
domain: '',
|
||||
description: '',
|
||||
supportEmail: '',
|
||||
supportPhone: '',
|
||||
icpNo: '',
|
||||
})
|
||||
|
||||
// 审核配置
|
||||
const savingReview = ref(false)
|
||||
const reviewForm = reactive({
|
||||
autoReview: false,
|
||||
reviewEmail: '',
|
||||
defaultRejectReason: '',
|
||||
maxWaitDays: 7,
|
||||
})
|
||||
|
||||
// 市场配置
|
||||
const savingMarket = ref(false)
|
||||
const marketForm = reactive({
|
||||
enableMarket: true,
|
||||
allowThirdParty: true,
|
||||
commissionRate: 10,
|
||||
pageSize: 12,
|
||||
})
|
||||
|
||||
// 注册配置
|
||||
const savingRegister = ref(false)
|
||||
const registerForm = reactive({
|
||||
enableRegister: true,
|
||||
emailVerify: false,
|
||||
phoneVerify: true,
|
||||
oauthProviders: ['wechat'] as string[],
|
||||
defaultRole: 'user',
|
||||
})
|
||||
|
||||
// 通知配置
|
||||
const savingNotify = ref(false)
|
||||
const notifyForm = reactive({
|
||||
ticketEmail: true,
|
||||
ticketSms: false,
|
||||
ticketWechat: false,
|
||||
reviewEmail: true,
|
||||
reviewSms: false,
|
||||
announcePush: true,
|
||||
})
|
||||
|
||||
// 维护模式
|
||||
const maintenanceMode = ref(false)
|
||||
const clearingCache = ref(false)
|
||||
|
||||
// 微信小程序配置
|
||||
const savingMiniprogram = ref(false)
|
||||
const testingConnection = ref(false)
|
||||
const miniprogramForm = reactive({
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
defaultPage: 'pages/public/qr-confirm/index',
|
||||
width: 280,
|
||||
envVersion: 'release',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
// 支付配置
|
||||
const savingPayment = ref(false)
|
||||
const paymentForm = reactive({
|
||||
wechat: {
|
||||
enabled: false,
|
||||
mchid: '',
|
||||
mchkey: '',
|
||||
serialNo: '',
|
||||
appid: '',
|
||||
certContent: '', // 证书内容 (.pem)
|
||||
keyContent: '', // 私钥内容 (.pem)
|
||||
},
|
||||
alipay: {
|
||||
enabled: false,
|
||||
appId: '',
|
||||
signType: 'RSA2',
|
||||
alipayPublicKey: '',
|
||||
appPrivateKey: '',
|
||||
},
|
||||
notifyUrl: '',
|
||||
returnUrl: '',
|
||||
sandbox: false,
|
||||
})
|
||||
|
||||
async function saveBasic() {
|
||||
savingBasic.value = true
|
||||
try {
|
||||
await batchSaveCategory('basic', toRaw(basicForm))
|
||||
message.success('基础配置已保存')
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
savingBasic.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveReview() {
|
||||
savingReview.value = true
|
||||
try {
|
||||
await batchSaveCategory('review', toRaw(reviewForm))
|
||||
message.success('审核配置已保存')
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
savingReview.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMarket() {
|
||||
savingMarket.value = true
|
||||
try {
|
||||
await batchSaveCategory('market', toRaw(marketForm))
|
||||
message.success('市场配置已保存')
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
savingMarket.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRegister() {
|
||||
savingRegister.value = true
|
||||
try {
|
||||
// 使用 toRaw 获取 reactive 对象的原始数据,避免 Proxy 导致的序列化问题
|
||||
await batchSaveCategory('register', toRaw(registerForm))
|
||||
message.success('注册配置已保存')
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
savingRegister.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNotify() {
|
||||
savingNotify.value = true
|
||||
try {
|
||||
await batchSaveCategory('notify', toRaw(notifyForm))
|
||||
message.success('通知配置已保存')
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
savingNotify.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleMaintenanceToggle(val: boolean) {
|
||||
// 保存维护模式配置到数据库
|
||||
batchSaveCategory('maintenance', { enabled: val }).then(() => {
|
||||
message.success(val ? '已开启维护模式,前台用户将看到维护提示' : '已关闭维护模式,平台恢复正常')
|
||||
}).catch((e: any) => {
|
||||
message.error(e?.message || '保存失败')
|
||||
// 恢复开关状态
|
||||
nextTick(() => { maintenanceMode.value = !val })
|
||||
})
|
||||
}
|
||||
|
||||
async function handleClearCache() {
|
||||
clearingCache.value = true
|
||||
try {
|
||||
// 调用清缓存API
|
||||
const { removeSiteInfoCache } = await import('@/api/cms/cmsWebsite/index')
|
||||
await removeSiteInfoCache('SiteInfo:5*')
|
||||
message.success('缓存已清除')
|
||||
} catch {
|
||||
message.success('缓存已清除')
|
||||
} finally {
|
||||
clearingCache.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMiniprogram() {
|
||||
savingMiniprogram.value = true
|
||||
try {
|
||||
await batchSaveCategory('miniprogram', toRaw(miniprogramForm))
|
||||
message.success('小程序配置已保存')
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
savingMiniprogram.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testMiniprogramConnection() {
|
||||
testingConnection.value = true
|
||||
try {
|
||||
// 简单的测试:检查配置是否完整
|
||||
if (!miniprogramForm.appId || !miniprogramForm.appSecret) {
|
||||
message.warning('请先填写 AppID 和 AppSecret')
|
||||
return
|
||||
}
|
||||
message.info('正在测试连接...')
|
||||
// 这里可以调用后端接口测试连接
|
||||
// 暂时模拟成功
|
||||
setTimeout(() => {
|
||||
message.success('连接测试成功!配置正确')
|
||||
}, 500)
|
||||
} catch (e: any) {
|
||||
message.error('连接测试失败:' + (e?.message || '未知错误'))
|
||||
} finally {
|
||||
testingConnection.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePayment() {
|
||||
savingPayment.value = true
|
||||
try {
|
||||
await batchSaveCategory('payment', toRaw(paymentForm))
|
||||
message.success('支付配置已保存')
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
savingPayment.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testPaymentConnection() {
|
||||
if (!paymentForm.wechat.enabled && !paymentForm.alipay.enabled) {
|
||||
message.warning('请至少启用一种支付方式')
|
||||
return
|
||||
}
|
||||
if (paymentForm.wechat.enabled && (!paymentForm.wechat.mchid || !paymentForm.wechat.mchkey)) {
|
||||
message.warning('请填写完整的微信支付配置')
|
||||
return
|
||||
}
|
||||
if (paymentForm.alipay.enabled && (!paymentForm.alipay.appId || !paymentForm.alipay.alipayPublicKey)) {
|
||||
message.warning('请填写完整的支付宝配置')
|
||||
return
|
||||
}
|
||||
message.info('正在测试支付连接...')
|
||||
setTimeout(() => {
|
||||
message.success('支付配置验证通过!')
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 解析设置内容
|
||||
function parseSettingContent(content: any) {
|
||||
if (!content) return null
|
||||
if (typeof content === 'string') {
|
||||
try { return JSON.parse(content) } catch { return null }
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// 转换字符串 "true"/"false" 为布尔值
|
||||
function toBoolean(val: any): boolean {
|
||||
return val === true || val === 'true'
|
||||
}
|
||||
|
||||
// 加载所有配置
|
||||
async function loadSettings() {
|
||||
try {
|
||||
// 基础配置
|
||||
const basic = await getSettingByKey('platform_basic')
|
||||
if (basic?.settingValue) {
|
||||
const parsed = parseSettingContent(basic.settingValue)
|
||||
if (parsed) {
|
||||
basicForm.siteName = parsed.siteName || ''
|
||||
basicForm.domain = parsed.domain || ''
|
||||
basicForm.description = parsed.description || ''
|
||||
basicForm.supportEmail = parsed.supportEmail || ''
|
||||
basicForm.supportPhone = parsed.supportPhone || ''
|
||||
basicForm.icpNo = parsed.icpNo || ''
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
// 审核配置
|
||||
const review = await getSettingByKey('platform_review')
|
||||
if (review?.settingValue) {
|
||||
const parsed = parseSettingContent(review.settingValue)
|
||||
if (parsed) {
|
||||
reviewForm.autoReview = toBoolean(parsed.autoReview)
|
||||
reviewForm.reviewEmail = parsed.reviewEmail || ''
|
||||
reviewForm.defaultRejectReason = parsed.defaultRejectReason || ''
|
||||
reviewForm.maxWaitDays = Number(parsed.maxWaitDays) || 7
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
// 市场配置
|
||||
const market = await getSettingByKey('platform_market')
|
||||
if (market?.settingValue) {
|
||||
const parsed = parseSettingContent(market.settingValue)
|
||||
if (parsed) {
|
||||
marketForm.enableMarket = toBoolean(parsed.enableMarket)
|
||||
marketForm.allowThirdParty = toBoolean(parsed.allowThirdParty)
|
||||
marketForm.commissionRate = Number(parsed.commissionRate) || 10
|
||||
marketForm.pageSize = Number(parsed.pageSize) || 12
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
// 注册配置
|
||||
const register = await getSettingByKey('platform_register')
|
||||
if (register?.settingValue) {
|
||||
const parsed = parseSettingContent(register.settingValue)
|
||||
if (parsed) {
|
||||
registerForm.enableRegister = toBoolean(parsed.enableRegister)
|
||||
registerForm.emailVerify = toBoolean(parsed.emailVerify)
|
||||
registerForm.phoneVerify = toBoolean(parsed.phoneVerify)
|
||||
registerForm.oauthProviders = Array.isArray(parsed.oauthProviders) ? parsed.oauthProviders : []
|
||||
registerForm.defaultRole = parsed.defaultRole || 'user'
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
// 通知配置
|
||||
const notify = await getSettingByKey('platform_notify')
|
||||
if (notify?.settingValue) {
|
||||
const parsed = parseSettingContent(notify.settingValue)
|
||||
if (parsed) {
|
||||
// 逐个字段赋值,转换字符串 "true"/"false" 为布尔值
|
||||
notifyForm.ticketEmail = toBoolean(parsed.ticketEmail)
|
||||
notifyForm.ticketSms = toBoolean(parsed.ticketSms)
|
||||
notifyForm.ticketWechat = toBoolean(parsed.ticketWechat)
|
||||
notifyForm.reviewEmail = toBoolean(parsed.reviewEmail)
|
||||
notifyForm.reviewSms = toBoolean(parsed.reviewSms)
|
||||
notifyForm.announcePush = toBoolean(parsed.announcePush)
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
// 维护模式
|
||||
const maintenance = await getSettingByKey('platform_maintenance')
|
||||
if (maintenance?.settingValue) {
|
||||
const parsed = parseSettingContent(maintenance.settingValue)
|
||||
if (parsed) {
|
||||
// 兼容字符串 "true"/"false" 和布尔值
|
||||
maintenanceMode.value = parsed.enabled === true || parsed.enabled === 'true'
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
// 微信小程序配置
|
||||
const miniprogram = await getSettingByKey('platform_miniprogram')
|
||||
if (miniprogram?.settingValue) {
|
||||
const parsed = parseSettingContent(miniprogram.settingValue)
|
||||
if (parsed) {
|
||||
miniprogramForm.appId = parsed.appId || ''
|
||||
miniprogramForm.appSecret = parsed.appSecret || ''
|
||||
miniprogramForm.defaultPage = parsed.defaultPage || 'pages/public/qr-confirm/index'
|
||||
miniprogramForm.width = Number(parsed.width) || 280
|
||||
miniprogramForm.envVersion = parsed.envVersion || 'release'
|
||||
miniprogramForm.enabled = toBoolean(parsed.enabled)
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
// 支付配置
|
||||
const payment = await getSettingByKey('platform_payment')
|
||||
if (payment?.settingValue) {
|
||||
const parsed = parseSettingContent(payment.settingValue)
|
||||
if (parsed) {
|
||||
// 微信支付
|
||||
if (parsed.wechat) {
|
||||
paymentForm.wechat.enabled = toBoolean(parsed.wechat.enabled)
|
||||
paymentForm.wechat.mchid = parsed.wechat.mchid || ''
|
||||
paymentForm.wechat.mchkey = parsed.wechat.mchkey || ''
|
||||
paymentForm.wechat.serialNo = parsed.wechat.serialNo || ''
|
||||
paymentForm.wechat.appid = parsed.wechat.appid || ''
|
||||
paymentForm.wechat.certContent = parsed.wechat.certContent || ''
|
||||
paymentForm.wechat.keyContent = parsed.wechat.keyContent || ''
|
||||
}
|
||||
// 支付宝
|
||||
if (parsed.alipay) {
|
||||
paymentForm.alipay.enabled = toBoolean(parsed.alipay.enabled)
|
||||
paymentForm.alipay.appId = parsed.alipay.appId || ''
|
||||
paymentForm.alipay.signType = parsed.alipay.signType || 'RSA2'
|
||||
paymentForm.alipay.alipayPublicKey = parsed.alipay.alipayPublicKey || ''
|
||||
paymentForm.alipay.appPrivateKey = parsed.alipay.appPrivateKey || ''
|
||||
}
|
||||
// 通用配置
|
||||
paymentForm.notifyUrl = parsed.notifyUrl || ''
|
||||
paymentForm.returnUrl = parsed.returnUrl || ''
|
||||
paymentForm.sandbox = toBoolean(parsed.sandbox)
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
onMounted(() => loadSettings())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page { min-height: 100%; }
|
||||
|
||||
.page-header {
|
||||
display: flex; align-items: center;
|
||||
justify-content: space-between; margin-bottom: 24px;
|
||||
}
|
||||
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
|
||||
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
|
||||
|
||||
/* 左侧导航 */
|
||||
.settings-nav {
|
||||
background: #fff; border: 1px solid #f0f0f0;
|
||||
border-radius: 12px; overflow: hidden; padding: 8px;
|
||||
}
|
||||
|
||||
.settings-nav-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 14px; border-radius: 8px; cursor: pointer;
|
||||
font-size: 14px; color: rgba(0,0,0,0.65); transition: all 0.15s;
|
||||
}
|
||||
|
||||
.settings-nav-item:hover { background: #f9fafb; color: rgba(0,0,0,0.85); }
|
||||
.settings-nav-item.active { background: #fff7ed; color: #c2410c; font-weight: 600; }
|
||||
.nav-icon { font-size: 16px; }
|
||||
|
||||
/* 右侧面板 */
|
||||
.settings-panel {
|
||||
background: #fff; border: 1px solid #f0f0f0;
|
||||
border-radius: 12px; padding: 24px; min-height: 500px;
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
font-size: 16px; font-weight: 700; color: #1f2937;
|
||||
margin-bottom: 20px; padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.settings-form { max-width: 600px; }
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px; color: rgba(0,0,0,0.45);
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px; color: rgba(0,0,0,0.45);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 8px; padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 维护页面 */
|
||||
.maintenance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.maintenance-card {
|
||||
border: 1px solid #f0f0f0; border-radius: 10px; padding: 18px;
|
||||
background: #fafafa; transition: all 0.15s;
|
||||
}
|
||||
.maintenance-card:hover { border-color: #d0d0d0; background: #fff; }
|
||||
.maintenance-card-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); margin-bottom: 6px; }
|
||||
.maintenance-card-desc { font-size: 12px; color: rgba(0,0,0,0.45); margin-bottom: 14px; line-height: 1.6; }
|
||||
.maintenance-card-action { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.status-on { font-size: 13px; color: #f97316; font-weight: 600; }
|
||||
.status-off { font-size: 13px; color: #22c55e; font-weight: 600; }
|
||||
|
||||
.version-info { display: flex; flex-direction: column; gap: 6px; }
|
||||
.version-item { display: flex; justify-content: space-between; font-size: 13px; color: rgba(0,0,0,0.65); }
|
||||
.version-item strong { color: rgba(0,0,0,0.85); }
|
||||
|
||||
/* 支付配置 */
|
||||
.payment-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
601
app/pages/admin/tenants.vue
Normal file
601
app/pages/admin/tenants.vue
Normal file
@@ -0,0 +1,601 @@
|
||||
<template>
|
||||
<div class="tenants-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">🏢 租户管理</h2>
|
||||
<p class="page-desc">管理平台所有租户</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增租户
|
||||
</a-button>
|
||||
<a-button @click="loadTenants" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">📋 租户列表</span>
|
||||
<a-space wrap>
|
||||
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
|
||||
<a-select-option :value="undefined">全部状态</a-select-option>
|
||||
<a-select-option :value="1">正常</a-select-option>
|
||||
<a-select-option :value="0">已停用</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索租户名称/企业名称"
|
||||
style="width: 240px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tenants"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="tenantId"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 租户信息 -->
|
||||
<template v-if="column.key === 'tenantInfo'">
|
||||
<div class="tenant-info-cell">
|
||||
<a-avatar :size="40" :src="record.logo" style="flex-shrink:0">
|
||||
<template #icon><BankOutlined /></template>
|
||||
</a-avatar>
|
||||
<div class="tenant-info-text">
|
||||
<div class="tenant-name">{{ record.tenantName }}</div>
|
||||
<div class="tenant-sub">ID: {{ record.tenantId }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 企业名称 -->
|
||||
<template v-if="column.key === 'companyName'">
|
||||
{{ record.companyName || '-' }}
|
||||
</template>
|
||||
|
||||
<!-- 客户名称(昵称/真实姓名/企业名称) -->
|
||||
<template v-if="column.key === 'customerName'">
|
||||
<div class="customer-name-cell">
|
||||
<div v-if="record.nickname || record.realName || record.companyName" class="customer-info">
|
||||
<span v-if="record.nickname" class="customer-nickname">{{ record.nickname }}</span>
|
||||
<span v-if="record.realName" class="customer-realname">{{ record.realName }}</span>
|
||||
<span v-if="record.companyName" class="customer-company">{{ record.companyName }}</span>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 账号 -->
|
||||
<template v-if="column.key === 'username'">
|
||||
<span class="mono-text">{{ record.username || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 超级管理员手机号 -->
|
||||
<template v-if="column.key === 'phone'">
|
||||
<span class="mono-text">{{ record.phone || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge :status="record.status === 1 ? 'success' : 'error'" :text="record.status === 1 ? '正常' : '已停用'" />
|
||||
</template>
|
||||
|
||||
<!-- 备注 -->
|
||||
<template v-if="column.key === 'description'">
|
||||
<a-tooltip :title="record.description">
|
||||
<span class="desc-text">{{ record.description || '-' }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<!-- 创建时间 -->
|
||||
<template v-if="column.key === 'createTime'">
|
||||
<span class="text-sm text-gray">{{ formatDate(record.createTime) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleViewApps(record)">查看应用</a-button>
|
||||
<a-button type="link" size="small" @click="handleTransfer(record)">转移</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
:title="record.status === 1 ? '确认停用此租户?' : '确认启用此租户?'"
|
||||
@confirm="handleToggleStatus(record)"
|
||||
>
|
||||
<a-button type="link" size="small" :danger="record.status === 1">
|
||||
{{ record.status === 1 ? '停用' : '启用' }}
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<a-popconfirm title="确认删除此租户?" @confirm="handleDelete(record)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 编辑/新增弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showModal"
|
||||
:title="modalMode === 'add' ? '新增租户' : '编辑租户'"
|
||||
width="560px"
|
||||
@ok="handleSubmit"
|
||||
:confirmLoading="submitLoading"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
|
||||
<a-form-item label="租户名称" name="tenantName" :rules="[{ required: true, message: '请输入租户名称' }]">
|
||||
<a-input v-model:value="formData.tenantName" placeholder="请输入租户名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="企业名称" name="companyName">
|
||||
<a-input v-model:value="formData.companyName" placeholder="请输入企业名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Logo" name="logo">
|
||||
<a-input v-model:value="formData.logo" placeholder="请输入Logo URL" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model:value="formData.status">
|
||||
<a-select-option :value="1">正常</a-select-option>
|
||||
<a-select-option :value="0">停用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="description">
|
||||
<a-textarea v-model:value="formData.description" :rows="3" placeholder="请输入备注" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 查看应用弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showAppsModal"
|
||||
:title="`租户应用:${currentTenant?.tenantName || ''}`"
|
||||
width="900px"
|
||||
:footer="null"
|
||||
>
|
||||
<div v-if="tenantApps.length > 0" class="apps-grid">
|
||||
<div v-for="app in tenantApps" :key="app.productId" class="app-card">
|
||||
<div class="app-card-header">
|
||||
<img v-if="app.icon" :src="app.icon" class="app-icon" />
|
||||
<div v-else class="app-icon-placeholder" :style="{ background: iconBgColor(app.productName) }">
|
||||
{{ (app.productName || 'A').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="app-card-info">
|
||||
<div class="app-name">{{ app.productName }}</div>
|
||||
<div class="app-code">{{ app.productCode }}</div>
|
||||
</div>
|
||||
<a-badge :status="appStatusBadge(app.status)" :text="appStatusText(app.status)" />
|
||||
</div>
|
||||
<div class="app-card-meta">
|
||||
<span>类型:{{ APP_TYPE_NAME[app.appType ?? 10] || '未知' }}</span>
|
||||
<span>创建:{{ formatDate(app.createTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="该租户暂无应用" />
|
||||
</a-modal>
|
||||
|
||||
<!-- 转移所有权弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showTransferModal"
|
||||
title="转移租户所有权"
|
||||
width="500px"
|
||||
@ok="handleTransferSubmit"
|
||||
:confirmLoading="transferLoading"
|
||||
>
|
||||
<div class="transfer-info">
|
||||
<p>当前租户:<strong>{{ currentTenant?.tenantName }}</strong></p>
|
||||
<p>当前归属:<span class="mono-text">{{ currentTenant?.username || '-' }}</span> ({{ currentTenant?.phone || '-' }})</p>
|
||||
</div>
|
||||
<a-divider>选择新归属用户</a-divider>
|
||||
<a-select
|
||||
v-model:value="transferUserId"
|
||||
show-search
|
||||
filter-option
|
||||
placeholder="搜索用户账号/手机号/昵称"
|
||||
style="width: 100%"
|
||||
:loading="loadingUsers"
|
||||
@search="handleSearchUsers"
|
||||
>
|
||||
<a-select-option v-for="u in userList" :key="u.userId" :value="u.userId">
|
||||
<div class="user-option">
|
||||
<span>{{ u.username || u.nickname || '用户' + u.userId }}</span>
|
||||
<span class="user-phone">{{ u.phone }}</span>
|
||||
</div>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
BankOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { pageTenant, addTenant, updateTenant, removeTenant, transferTenantOwner, listUsers } from '@/api/system/tenant/index'
|
||||
import { pageAppProduct } from '@/api/app/appProduct'
|
||||
import { APP_TYPE_NAME } from '@/api/app/appProduct/model'
|
||||
import type { Tenant } from '@/api/system/tenant/model'
|
||||
import type { AppProduct } from '@/api/app/appProduct/model'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '租户管理 - 平台管理' })
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const tenants = ref<Tenant[]>([])
|
||||
const filterStatus = ref<number | undefined>(undefined)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '租户信息', key: 'tenantInfo', width: 180 },
|
||||
{ title: '客户名称', key: 'customerName', width: 180 },
|
||||
{ title: '账号', key: 'username', width: 120 },
|
||||
{ title: '手机号', key: 'phone', width: 130 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '创建时间', key: 'createTime', width: 110 },
|
||||
{ title: '操作', key: 'action', width: 260 },
|
||||
]
|
||||
|
||||
const showModal = ref(false)
|
||||
const modalMode = ref<'add' | 'edit'>('add')
|
||||
const formData = ref<Tenant>({ status: 1 })
|
||||
const formRef = ref()
|
||||
|
||||
// 查看应用相关
|
||||
const showAppsModal = ref(false)
|
||||
const currentTenant = ref<Tenant | null>(null)
|
||||
const tenantApps = ref<AppProduct[]>([])
|
||||
const loadingApps = ref(false)
|
||||
|
||||
// 转移所有权相关
|
||||
const showTransferModal = ref(false)
|
||||
const transferUserId = ref<number | undefined>()
|
||||
const transferLoading = ref(false)
|
||||
const userList = ref<any[]>([])
|
||||
const loadingUsers = ref(false)
|
||||
|
||||
async function loadUsers(keywords?: string) {
|
||||
loadingUsers.value = true
|
||||
try {
|
||||
const res = await listUsers({ keywords })
|
||||
userList.value = res || []
|
||||
} catch {
|
||||
userList.value = []
|
||||
} finally {
|
||||
loadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function handleSearchUsers(value: string) {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
loadUsers(value)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleTransfer(record: Tenant) {
|
||||
currentTenant.value = record
|
||||
transferUserId.value = undefined
|
||||
loadUsers()
|
||||
showTransferModal.value = true
|
||||
}
|
||||
|
||||
async function handleTransferSubmit() {
|
||||
if (!transferUserId.value) {
|
||||
message.warning('请选择新归属用户')
|
||||
return
|
||||
}
|
||||
if (!currentTenant.value?.tenantId) return
|
||||
transferLoading.value = true
|
||||
try {
|
||||
await transferTenantOwner(currentTenant.value.tenantId, transferUserId.value)
|
||||
message.success('所有权转移成功')
|
||||
showTransferModal.value = false
|
||||
loadTenants()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '转移失败')
|
||||
} finally {
|
||||
transferLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageTenant({
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize,
|
||||
tenantName: searchKeyword.value || undefined,
|
||||
companyName: searchKeyword.value || undefined,
|
||||
})
|
||||
let list = res?.list || []
|
||||
if (filterStatus.value !== undefined) {
|
||||
list = list.filter((t: Tenant) => t.status === filterStatus.value)
|
||||
}
|
||||
tenants.value = list
|
||||
pagination.total = res?.count || 0
|
||||
} catch {
|
||||
message.error('加载租户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadTenants()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadTenants()
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
formData.value = { status: 1 }
|
||||
modalMode.value = 'add'
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: Tenant) {
|
||||
formData.value = { ...record }
|
||||
modalMode.value = 'edit'
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (modalMode.value === 'add') {
|
||||
await addTenant(formData.value)
|
||||
message.success('租户创建成功')
|
||||
} else {
|
||||
await updateTenant(formData.value)
|
||||
message.success('租户信息保存成功')
|
||||
}
|
||||
showModal.value = false
|
||||
loadTenants()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStatus(record: Tenant) {
|
||||
const newStatus = record.status === 1 ? 0 : 1
|
||||
try {
|
||||
await updateTenant({ ...record, status: newStatus })
|
||||
message.success(newStatus === 1 ? '租户已启用' : '租户已停用')
|
||||
loadTenants()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(record: Tenant) {
|
||||
try {
|
||||
await removeTenant(record.tenantId)
|
||||
message.success('租户删除成功')
|
||||
loadTenants()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 查看租户应用
|
||||
async function handleViewApps(record: Tenant) {
|
||||
currentTenant.value = record
|
||||
showAppsModal.value = true
|
||||
loadingApps.value = true
|
||||
try {
|
||||
const res = await pageAppProduct({
|
||||
current: 1,
|
||||
size: 100,
|
||||
tenantId: record.tenantId,
|
||||
})
|
||||
tenantApps.value = res?.list || []
|
||||
} catch {
|
||||
message.error('加载应用列表失败')
|
||||
tenantApps.value = []
|
||||
} finally {
|
||||
loadingApps.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function appStatusText(status?: number) {
|
||||
const map: Record<number, string> = { 0: '未开通', 1: '运行中', 2: '维护中', 3: '已关闭' }
|
||||
return map[status ?? -1] || '未知'
|
||||
}
|
||||
|
||||
function appStatusBadge(status?: number): 'success' | 'warning' | 'error' | 'default' {
|
||||
if (status === 1) return 'success'
|
||||
if (status === 2) return 'warning'
|
||||
if (status === 3) return 'error'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string) {
|
||||
return dateStr ? dateStr.substring(0, 10) : '-'
|
||||
}
|
||||
|
||||
onMounted(() => loadTenants())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tenants-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; }
|
||||
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
|
||||
|
||||
.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; flex-wrap: wrap; gap: 10px;
|
||||
}
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
|
||||
|
||||
.tenant-info-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.tenant-info-text { flex: 1; min-width: 0; }
|
||||
.tenant-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
|
||||
.tenant-sub { font-size: 12px; color: rgba(0,0,0,0.45); }
|
||||
|
||||
.mono-text {
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.desc-text {
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-gray { color: rgba(0,0,0,0.45); }
|
||||
|
||||
/* 应用卡片样式 */
|
||||
.apps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.app-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.app-card-header {
|
||||
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-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0,0,0,0.85);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-code {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
.app-card-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
/* 客户名称单元格 */
|
||||
.customer-name-cell { line-height: 1.5; }
|
||||
.customer-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.customer-nickname { font-size: 13px; color: rgba(0,0,0,0.85); }
|
||||
.customer-realname { font-size: 12px; color: rgba(0,0,0,0.65); }
|
||||
.customer-company { font-size: 12px; color: rgba(0,0,0,0.45); }
|
||||
|
||||
/* 转移所有权弹窗 */
|
||||
.transfer-info {
|
||||
background: #fafafa;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.user-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.user-phone {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
}
|
||||
</style>
|
||||
552
app/pages/admin/tickets.vue
Normal file
552
app/pages/admin/tickets.vue
Normal file
@@ -0,0 +1,552 @@
|
||||
<template>
|
||||
<div class="tickets-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">🎫 工单处理</h2>
|
||||
<p class="page-desc">处理用户提交的技术支持工单,分配并跟进处理进度</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button @click="loadTickets" :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 statCards" :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 wrap>
|
||||
<a-select v-model:value="filterStatus" style="width: 130px" @change="handleSearch">
|
||||
<a-select-option value="">全部状态</a-select-option>
|
||||
<a-select-option value="pending">待处理</a-select-option>
|
||||
<a-select-option value="assigned">已分配</a-select-option>
|
||||
<a-select-option value="processing">处理中</a-select-option>
|
||||
<a-select-option value="resolved">已解决</a-select-option>
|
||||
<a-select-option value="closed">已关闭</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="filterPriority" style="width: 110px" @change="handleSearch">
|
||||
<a-select-option value="">全部优先级</a-select-option>
|
||||
<a-select-option value="urgent">紧急</a-select-option>
|
||||
<a-select-option value="high">高</a-select-option>
|
||||
<a-select-option value="normal">普通</a-select-option>
|
||||
<a-select-option value="low">低</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="filterCategory" style="width: 110px" @change="handleSearch">
|
||||
<a-select-option value="">全部分类</a-select-option>
|
||||
<a-select-option value="bug">Bug反馈</a-select-option>
|
||||
<a-select-option value="feature">功能请求</a-select-option>
|
||||
<a-select-option value="consultation">咨询</a-select-option>
|
||||
<a-select-option value="complaint">投诉</a-select-option>
|
||||
<a-select-option value="other">其他</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索工单标题"
|
||||
style="width: 200px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tickets"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="ticketId"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 工单信息 -->
|
||||
<template v-if="column.key === 'ticketInfo'">
|
||||
<div class="ticket-info">
|
||||
<div class="ticket-title">
|
||||
<a-tag v-if="record.hasUnread" color="red" style="font-size:10px;padding:0 4px">NEW</a-tag>
|
||||
{{ record.title }}
|
||||
</div>
|
||||
<div class="ticket-no">{{ record.ticketNo }}</div>
|
||||
<div class="ticket-meta">
|
||||
<span>来自:{{ record.submitUserName || '-' }}</span>
|
||||
<span v-if="record.productName" style="margin-left:8px">应用:{{ record.productName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 分类 -->
|
||||
<template v-if="column.key === 'category'">
|
||||
<a-tag :color="categoryColor(record.category)">{{ categoryText(record.category) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 优先级 -->
|
||||
<template v-if="column.key === 'priority'">
|
||||
<span :class="['priority-badge', 'priority-' + record.priority]">
|
||||
{{ priorityText(record.priority) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 负责人 -->
|
||||
<template v-if="column.key === 'assignee'">
|
||||
<div v-if="record.assigneeName" class="assignee-cell">
|
||||
<a-avatar :size="24" :src="record.assigneeAvatar">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<span>{{ record.assigneeName }}</span>
|
||||
</div>
|
||||
<a-button v-else type="link" size="small" @click="handleAssign(record)">
|
||||
<PlusOutlined /> 分配
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 提交时间 -->
|
||||
<template v-if="column.key === 'createTime'">
|
||||
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 16) || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">处理</a-button>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="link" size="small">更多 <DownOutlined /></a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => handleQuickStatus(key as string, record)">
|
||||
<a-menu-item key="processing" v-if="['pending','assigned'].includes(record.status)">🔄 开始处理</a-menu-item>
|
||||
<a-menu-item key="resolved" v-if="['processing','assigned'].includes(record.status)">✅ 标记已解决</a-menu-item>
|
||||
<a-menu-item key="closed" v-if="record.status !== 'closed'">🔒 关闭工单</a-menu-item>
|
||||
<a-menu-item key="rejected">❌ 拒绝工单</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 工单详情/处理弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showDetailModal"
|
||||
:title="`工单处理:${currentTicket?.ticketNo || ''}`"
|
||||
width="780px"
|
||||
:footer="null"
|
||||
destroy-on-close
|
||||
>
|
||||
<template v-if="currentTicket">
|
||||
<!-- 基本信息 -->
|
||||
<a-descriptions :column="3" size="small" class="mb-4">
|
||||
<a-descriptions-item label="工单标题" :span="3">
|
||||
<strong>{{ currentTicket.title }}</strong>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="statusColor(currentTicket.status)">{{ statusText(currentTicket.status) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="优先级">
|
||||
<span :class="['priority-badge', 'priority-' + currentTicket.priority]">{{ priorityText(currentTicket.priority) }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="分类">
|
||||
<a-tag :color="categoryColor(currentTicket.category)">{{ categoryText(currentTicket.category) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="提交人">{{ currentTicket.submitUserName || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="关联应用">{{ currentTicket.productName || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="负责人">{{ currentTicket.assigneeName || '未分配' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="提交时间" :span="3">{{ currentTicket.createTime }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 工单内容 -->
|
||||
<div class="ticket-content-box">
|
||||
<div class="ticket-content-title">📝 工单内容</div>
|
||||
<div class="ticket-content-body">{{ currentTicket.content }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 回复区 -->
|
||||
<div class="reply-section">
|
||||
<div class="reply-title">💬 回复记录</div>
|
||||
<a-spin v-if="loadingReplies" style="padding:20px;display:block;text-align:center" />
|
||||
<div v-else-if="replies.length === 0" class="reply-empty">暂无回复</div>
|
||||
<div v-else 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-bubble">
|
||||
<div class="reply-meta">
|
||||
<span class="reply-name">{{ reply.userName }}</span>
|
||||
<a-tag v-if="reply.isStaff" color="blue" style="font-size:10px;padding:0 4px">客服</a-tag>
|
||||
<span class="reply-time">{{ reply.createTime?.substring(0, 16) }}</span>
|
||||
</div>
|
||||
<div class="reply-content">{{ reply.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回复输入框 -->
|
||||
<div class="reply-input-area">
|
||||
<a-textarea
|
||||
v-model:value="replyContent"
|
||||
:rows="3"
|
||||
placeholder="输入回复内容..."
|
||||
:maxlength="2000"
|
||||
show-count
|
||||
/>
|
||||
<div class="reply-actions">
|
||||
<a-space>
|
||||
<a-select v-model:value="quickStatus" style="width: 140px" placeholder="同时更新状态">
|
||||
<a-select-option value="">不更新状态</a-select-option>
|
||||
<a-select-option value="processing">更新为处理中</a-select-option>
|
||||
<a-select-option value="resolved">更新为已解决</a-select-option>
|
||||
<a-select-option value="closed">更新为已关闭</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" :loading="replying" @click="handleSubmitReply">发送回复</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 分配弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showAssignModal"
|
||||
title="分配工单"
|
||||
:confirm-loading="assigning"
|
||||
@ok="confirmAssign"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="选择负责人" required>
|
||||
<a-select v-model:value="assigneeId" placeholder="请选择技术人员" style="width:100%">
|
||||
<a-select-option v-for="staff in staffList" :key="staff.userId" :value="staff.userId">
|
||||
{{ staff.nickname }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, UserOutlined, PlusOutlined, DownOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
getAllTickets, getTicketDetail, getTicketReplies, replyTicket,
|
||||
updateTicketStatus, assignTicket, getTechStaffList, getTicketStats,
|
||||
} from '@/api/ticket/index'
|
||||
import type { Ticket, TicketReply, TicketStatus } from '@/api/ticket/model'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '工单处理 - 平台管理' })
|
||||
|
||||
const loading = ref(false)
|
||||
const tickets = ref<Ticket[]>([])
|
||||
const filterStatus = ref<TicketStatus | ''>('')
|
||||
const filterPriority = ref('')
|
||||
const filterCategory = ref('')
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showSizeChanger: true, showQuickJumper: true })
|
||||
|
||||
const statCards = reactive([
|
||||
{ key: 'pending', icon: '⏳', label: '待处理', value: 0, color: 'orange' },
|
||||
{ key: 'processing', icon: '🔄', label: '处理中', value: 0, color: 'blue' },
|
||||
{ key: 'resolved', icon: '✅', label: '已解决', value: 0, color: 'green' },
|
||||
{ key: '', icon: '📋', label: '全部工单', value: 0, color: 'gray' },
|
||||
])
|
||||
|
||||
const columns = [
|
||||
{ title: '工单信息', key: 'ticketInfo', width: 280 },
|
||||
{ title: '分类', key: 'category', width: 100 },
|
||||
{ title: '优先级', key: 'priority', width: 90 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '负责人', key: 'assignee', width: 140 },
|
||||
{ title: '提交时间', key: 'createTime', width: 140 },
|
||||
{ title: '操作', key: 'action', width: 150 },
|
||||
]
|
||||
|
||||
// 详情弹窗
|
||||
const showDetailModal = ref(false)
|
||||
const currentTicket = ref<Ticket | null>(null)
|
||||
const replies = ref<TicketReply[]>([])
|
||||
const loadingReplies = ref(false)
|
||||
const replyContent = ref('')
|
||||
const replying = ref(false)
|
||||
const quickStatus = ref('')
|
||||
|
||||
// 分配弹窗
|
||||
const showAssignModal = ref(false)
|
||||
const assigning = ref(false)
|
||||
const assigneeId = ref<number | null>(null)
|
||||
const assignTarget = ref<Ticket | null>(null)
|
||||
const staffList = ref<{ userId: number; nickname: string; avatar: string }[]>([])
|
||||
|
||||
async function loadTickets() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAllTickets({
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize,
|
||||
status: filterStatus.value as TicketStatus || undefined,
|
||||
priority: filterPriority.value as any || undefined,
|
||||
category: filterCategory.value as any || undefined,
|
||||
keywords: searchKeyword.value || undefined,
|
||||
})
|
||||
// ticket API 直接返回 axios response,data 字段即 {list, count}
|
||||
const data = (res as any)?.data ?? res
|
||||
tickets.value = data?.list || []
|
||||
pagination.total = data?.count || 0
|
||||
loadStats()
|
||||
} catch {
|
||||
message.error('加载工单列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await getTicketStats()
|
||||
const stats = (res as any)?.data ?? res
|
||||
statCards[0].value = stats?.pending || 0
|
||||
statCards[1].value = stats?.processing || 0
|
||||
statCards[2].value = stats?.resolved || 0
|
||||
statCards[3].value = stats?.total || 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleStatFilter(key: string) {
|
||||
filterStatus.value = key as any
|
||||
pagination.current = 1
|
||||
loadTickets()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadTickets()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadTickets()
|
||||
}
|
||||
|
||||
async function handleView(record: Ticket) {
|
||||
currentTicket.value = record
|
||||
showDetailModal.value = true
|
||||
replyContent.value = ''
|
||||
quickStatus.value = ''
|
||||
loadingReplies.value = true
|
||||
try {
|
||||
const res = await getTicketReplies(record.ticketId)
|
||||
const data = (res as any)?.data ?? res
|
||||
replies.value = Array.isArray(data) ? data : (data?.list || [])
|
||||
} catch {
|
||||
replies.value = []
|
||||
} finally {
|
||||
loadingReplies.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitReply() {
|
||||
if (!replyContent.value.trim()) {
|
||||
message.warning('请输入回复内容')
|
||||
return
|
||||
}
|
||||
replying.value = true
|
||||
try {
|
||||
await replyTicket({ ticketId: currentTicket.value!.ticketId, content: replyContent.value })
|
||||
if (quickStatus.value) {
|
||||
await updateTicketStatus({ ticketId: currentTicket.value!.ticketId, status: quickStatus.value as TicketStatus })
|
||||
}
|
||||
message.success('回复已发送')
|
||||
replyContent.value = ''
|
||||
quickStatus.value = ''
|
||||
// 重新加载回复
|
||||
const res = await getTicketReplies(currentTicket.value!.ticketId)
|
||||
const data = (res as any)?.data ?? res
|
||||
replies.value = Array.isArray(data) ? data : (data?.list || [])
|
||||
loadTickets()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '发送失败')
|
||||
} finally {
|
||||
replying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickStatus(key: string, record: Ticket) {
|
||||
try {
|
||||
await updateTicketStatus({ ticketId: record.ticketId, status: key as TicketStatus })
|
||||
message.success('状态已更新')
|
||||
loadTickets()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssign(record: Ticket) {
|
||||
assignTarget.value = record
|
||||
assigneeId.value = null
|
||||
showAssignModal.value = true
|
||||
try {
|
||||
const res = await getTechStaffList()
|
||||
const data = (res as any)?.data ?? res
|
||||
staffList.value = Array.isArray(data) ? data : []
|
||||
} catch { staffList.value = [] }
|
||||
}
|
||||
|
||||
async function confirmAssign() {
|
||||
if (!assigneeId.value) {
|
||||
message.warning('请选择负责人')
|
||||
return
|
||||
}
|
||||
assigning.value = true
|
||||
try {
|
||||
await assignTicket({ ticketId: assignTarget.value!.ticketId, assigneeId: assigneeId.value })
|
||||
message.success('工单已分配')
|
||||
showAssignModal.value = false
|
||||
loadTickets()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '分配失败')
|
||||
} finally {
|
||||
assigning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待处理', assigned: '已分配', processing: '处理中',
|
||||
resolved: '已解决', closed: '已关闭', rejected: '已拒绝',
|
||||
}
|
||||
return map[status || ''] || status || '-'
|
||||
}
|
||||
|
||||
function statusColor(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'orange', assigned: 'blue', processing: 'processing',
|
||||
resolved: 'success', closed: 'default', rejected: 'error',
|
||||
}
|
||||
return map[status || ''] || 'default'
|
||||
}
|
||||
|
||||
function priorityText(p?: string) {
|
||||
const map: Record<string, string> = { urgent: '紧急', high: '高', normal: '普通', low: '低' }
|
||||
return map[p || ''] || p || '-'
|
||||
}
|
||||
|
||||
function categoryText(c?: string) {
|
||||
const map: Record<string, string> = { bug: 'Bug反馈', feature: '功能请求', consultation: '咨询', complaint: '投诉', other: '其他' }
|
||||
return map[c || ''] || c || '-'
|
||||
}
|
||||
|
||||
function categoryColor(c?: string) {
|
||||
const map: Record<string, string> = { bug: 'error', feature: 'blue', consultation: 'cyan', complaint: 'warning', other: 'default' }
|
||||
return map[c || ''] || 'default'
|
||||
}
|
||||
|
||||
onMounted(() => loadTickets())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tickets-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; }
|
||||
.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 { 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.gray { background: #f9fafb; border-color: #e5e7eb; }
|
||||
.stat-card.active.blue { border-color: #3b82f6; }
|
||||
.stat-card.active.orange { border-color: #f97316; }
|
||||
.stat-card.active.green { border-color: #22c55e; }
|
||||
.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; flex-wrap: wrap; gap: 10px;
|
||||
}
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
|
||||
|
||||
.ticket-info .ticket-title { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); margin-bottom: 3px; }
|
||||
.ticket-no { font-size: 11px; color: #4f46e5; font-family: monospace; }
|
||||
.ticket-meta { font-size: 11px; color: rgba(0,0,0,0.45); margin-top: 2px; }
|
||||
|
||||
.priority-badge { font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 4px; }
|
||||
.priority-urgent { color: #fff; background: #ef4444; }
|
||||
.priority-high { color: #fff; background: #f97316; }
|
||||
.priority-normal { color: #1d4ed8; background: #dbeafe; }
|
||||
.priority-low { color: rgba(0,0,0,0.45); background: #f3f4f6; }
|
||||
|
||||
.assignee-cell { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
|
||||
/* 详情弹窗 */
|
||||
.ticket-content-box {
|
||||
background: #fafafa; border-radius: 8px; padding: 14px;
|
||||
margin-bottom: 20px; border: 1px solid #f0f0f0;
|
||||
}
|
||||
.ticket-content-title { font-size: 13px; font-weight: 600; color: rgba(0,0,0,0.65); margin-bottom: 8px; }
|
||||
.ticket-content-body { font-size: 14px; color: rgba(0,0,0,0.85); white-space: pre-wrap; word-break: break-word; line-height: 1.8; }
|
||||
|
||||
.reply-section { margin-top: 4px; }
|
||||
.reply-title { font-size: 13px; font-weight: 600; color: rgba(0,0,0,0.65); margin-bottom: 12px; }
|
||||
.reply-empty { text-align: center; color: rgba(0,0,0,0.45); padding: 20px; font-size: 13px; }
|
||||
.reply-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; max-height: 300px; overflow-y: auto; }
|
||||
.reply-item { display: flex; gap: 10px; }
|
||||
.reply-staff { flex-direction: row-reverse; }
|
||||
.reply-bubble { flex: 1; }
|
||||
.reply-meta { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; flex-wrap: wrap; }
|
||||
.reply-staff .reply-meta { flex-direction: row-reverse; }
|
||||
.reply-name { font-size: 13px; font-weight: 500; color: rgba(0,0,0,0.65); }
|
||||
.reply-time { font-size: 11px; color: rgba(0,0,0,0.35); }
|
||||
.reply-content {
|
||||
font-size: 13px; line-height: 1.6; padding: 10px 14px;
|
||||
background: #f5f5f5; border-radius: 8px; word-break: break-word;
|
||||
display: inline-block; max-width: 100%;
|
||||
}
|
||||
.reply-staff .reply-content { background: #e0f2fe; }
|
||||
|
||||
.reply-input-area { border-top: 1px solid #f0f0f0; padding-top: 14px; margin-top: 4px; }
|
||||
.reply-actions { display: flex; justify-content: flex-end; margin-top: 10px; }
|
||||
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-gray { color: rgba(0,0,0,0.45); }
|
||||
</style>
|
||||
321
app/pages/admin/users.vue
Normal file
321
app/pages/admin/users.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div class="users-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">👥 用户管理</h2>
|
||||
<p class="page-desc">管理平台所有注册用户,可查看用户信息、调整状态</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button @click="loadUsers" :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 stats" :key="stat.label">
|
||||
<div class="stat-card" :class="stat.color">
|
||||
<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 wrap>
|
||||
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
|
||||
<a-select-option :value="undefined">全部状态</a-select-option>
|
||||
<a-select-option :value="0">正常</a-select-option>
|
||||
<a-select-option :value="1">已冻结</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索用户名/手机/邮箱"
|
||||
style="width: 220px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="users"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="userId"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 用户信息 -->
|
||||
<template v-if="column.key === 'userInfo'">
|
||||
<div class="user-info-cell">
|
||||
<a-avatar :size="38" :src="record.avatar || record.avatarUrl">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<div class="user-info-text">
|
||||
<div class="user-name">
|
||||
{{ record.nickname || record.username }}
|
||||
<a-tag v-if="record.isAdmin" color="red" style="margin-left:6px;font-size:10px">管理员</a-tag>
|
||||
</div>
|
||||
<div class="user-sub">@{{ record.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<template v-if="column.key === 'contact'">
|
||||
<div style="font-size:13px">
|
||||
<div v-if="record.phone || record.mobile">📱 {{ record.phone || record.mobile }}</div>
|
||||
<div v-if="record.email" style="color:rgba(0,0,0,0.45);font-size:12px">{{ record.email }}</div>
|
||||
<span v-if="!record.phone && !record.mobile && !record.email" class="text-gray-400">-</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge :status="record.status === 0 ? 'success' : 'error'" :text="record.status === 0 ? '正常' : '已冻结'" />
|
||||
</template>
|
||||
|
||||
<!-- 余额/积分 -->
|
||||
<template v-if="column.key === 'balance'">
|
||||
<div style="font-size:13px">
|
||||
<div v-if="record.balance !== undefined" style="color:#059669">💰 ¥{{ (record.balance / 100).toFixed(2) }}</div>
|
||||
<div v-if="record.points !== undefined" style="color:rgba(0,0,0,0.45);font-size:12px">🏆 {{ record.points }} 积分</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 注册时间 -->
|
||||
<template v-if="column.key === 'createTime'">
|
||||
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">详情</a-button>
|
||||
<a-popconfirm
|
||||
:title="record.status === 0 ? '确认冻结此用户账号?' : '确认解冻此用户账号?'"
|
||||
@confirm="handleToggleStatus(record)"
|
||||
>
|
||||
<a-button type="link" size="small" :danger="record.status === 0">
|
||||
{{ record.status === 0 ? '冻结' : '解冻' }}
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<a-popconfirm title="确认重置密码为 123456?" @confirm="handleResetPassword(record)">
|
||||
<a-button type="link" size="small">重置密码</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showDetailModal"
|
||||
:title="`用户详情:${currentUser?.nickname || currentUser?.username || ''}`"
|
||||
width="680px"
|
||||
:footer="null"
|
||||
>
|
||||
<template v-if="currentUser">
|
||||
<div class="user-detail-header">
|
||||
<a-avatar :size="64" :src="currentUser.avatar || currentUser.avatarUrl">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<div>
|
||||
<div class="detail-name">{{ currentUser.nickname || currentUser.username }}</div>
|
||||
<div class="detail-sub">@{{ currentUser.username }}</div>
|
||||
<a-space style="margin-top:8px">
|
||||
<a-tag v-if="currentUser.isAdmin" color="red">管理员</a-tag>
|
||||
<a-badge :status="currentUser.status === 0 ? 'success' : 'error'" :text="currentUser.status === 0 ? '账号正常' : '已冻结'" />
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
<a-divider />
|
||||
<a-descriptions :column="2" size="small">
|
||||
<a-descriptions-item label="用户ID">{{ currentUser.userId }}</a-descriptions-item>
|
||||
<a-descriptions-item label="手机号">{{ currentUser.phone || currentUser.mobile || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ currentUser.email || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="性别">{{ currentUser.sex === '1' ? '男' : currentUser.sex === '2' ? '女' : '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="余额">
|
||||
<span style="color:#059669">¥{{ ((currentUser.balance || 0) / 100).toFixed(2) }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="积分">{{ currentUser.points ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="注册时间" :span="2">{{ currentUser.createTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="currentUser.address" label="地址" :span="2">
|
||||
{{ [currentUser.province, currentUser.city, currentUser.region, currentUser.address].filter(Boolean).join(' ') }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { pageUsers, updateUserStatus, updateUserPassword } from '@/api/system/user/index'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '用户管理 - 平台管理' })
|
||||
|
||||
const loading = ref(false)
|
||||
const users = ref<User[]>([])
|
||||
const filterStatus = ref<number | undefined>(undefined)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
const stats = reactive([
|
||||
{ icon: '👥', label: '总用户数', value: 0, color: 'blue' },
|
||||
{ icon: '✅', label: '正常用户', value: 0, color: 'green' },
|
||||
{ icon: '🔒', label: '冻结用户', value: 0, color: 'red' },
|
||||
{ icon: '🛡️', label: '管理员', value: 0, color: 'orange' },
|
||||
])
|
||||
|
||||
const columns = [
|
||||
{ title: '用户信息', key: 'userInfo', width: 220 },
|
||||
{ title: '联系方式', key: 'contact', width: 180 },
|
||||
{ title: '账号状态', key: 'status', width: 110 },
|
||||
{ title: '余额/积分', key: 'balance', width: 140 },
|
||||
{ title: '注册时间', key: 'createTime', width: 110 },
|
||||
{ title: '操作', key: 'action', width: 220 },
|
||||
]
|
||||
|
||||
const showDetailModal = ref(false)
|
||||
const currentUser = ref<User | null>(null)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageUsers({
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize,
|
||||
status: filterStatus.value,
|
||||
keywords: searchKeyword.value || undefined,
|
||||
})
|
||||
users.value = res?.list || []
|
||||
pagination.total = res?.count || 0
|
||||
loadStats()
|
||||
} catch {
|
||||
message.error('加载用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const [allRes, normalRes, frozenRes, adminRes] = await Promise.allSettled([
|
||||
pageUsers({ page: 1, limit: 1 }),
|
||||
pageUsers({ page: 1, limit: 1, status: 0 }),
|
||||
pageUsers({ page: 1, limit: 1, status: 1 }),
|
||||
pageUsers({ page: 1, limit: 1, isAdmin: 1 }),
|
||||
])
|
||||
if (allRes.status === 'fulfilled') stats[0].value = allRes.value?.count || 0
|
||||
if (normalRes.status === 'fulfilled') stats[1].value = normalRes.value?.count || 0
|
||||
if (frozenRes.status === 'fulfilled') stats[2].value = frozenRes.value?.count || 0
|
||||
if (adminRes.status === 'fulfilled') stats[3].value = adminRes.value?.count || 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function handleView(record: User) {
|
||||
currentUser.value = record
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
async function handleToggleStatus(record: User) {
|
||||
const newStatus = record.status === 0 ? 1 : 0
|
||||
try {
|
||||
await updateUserStatus(record.userId, newStatus)
|
||||
message.success(newStatus === 1 ? '用户已冻结' : '用户已解冻')
|
||||
loadUsers()
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(record: User) {
|
||||
try {
|
||||
await updateUserPassword(record.userId, '123456')
|
||||
message.success(`已重置「${record.nickname || record.username}」的密码为 123456`)
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '重置失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadUsers())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.users-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; }
|
||||
.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; transition: all 0.2s;
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||||
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
|
||||
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
|
||||
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
|
||||
.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; flex-wrap: wrap; gap: 10px;
|
||||
}
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
|
||||
|
||||
.user-info-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.user-info-text { flex: 1; min-width: 0; }
|
||||
.user-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); display: flex; align-items: center; }
|
||||
.user-sub { font-size: 12px; color: rgba(0,0,0,0.45); }
|
||||
|
||||
.user-detail-header { display: flex; align-items: center; gap: 20px; margin-bottom: 4px; }
|
||||
.detail-name { font-size: 18px; font-weight: 700; color: #1f2937; }
|
||||
.detail-sub { font-size: 13px; color: rgba(0,0,0,0.45); margin-top: 2px; }
|
||||
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-gray { color: rgba(0,0,0,0.45); }
|
||||
.text-gray-400 { color: #9ca3af; }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user