新版官网模板

This commit is contained in:
2026-04-29 01:33:33 +08:00
commit 0d82386f8f
341 changed files with 64526 additions and 0 deletions

View 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
}

View 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 视角,不应出现在用户控制台导航

View 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`
- 工单APIbase 路径 `/api/app/app//ticket`,返回结构 `{ list, count }`**不经过 ApiResult 包装**,取值用 `(res as any)?.data ?? res`
- 设置APIkey-value存储key格式 `platform_*`
## 设计规范
- stat-card 统计卡片4色系blue/green/orange/red可点击筛选
- panel 面板:白底 + f0f0f0 边框 + 12px border-radius
- 分页统一:`current/pageSize/total/showSizeChanger/showQuickJumper`

View 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 :loading="loading" @click="loadAnnouncements">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :md="8" :xs="12">
<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 :md="8" :xs="12">
<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 :md="8" :xs="12">
<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"
size="middle"
@change="handleTableChange"
>
<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 size="small" type="link" @click="handleView(record)">预览</a-button>
<a-button size="small" type="link" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm title="确认删除此公告?" @confirm="handleDelete(record)">
<a-button danger size="small" type="link">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="showFormModal"
:confirm-loading="saving"
:title="editing?.articleId ? '编辑公告' : '发布公告'"
width="760px"
@cancel="showFormModal = false"
@ok="handleSave"
>
<a-form :model="formData" layout="vertical">
<a-form-item label="公告标题" required>
<a-input
v-model:value="formData.title"
:maxlength="200"
placeholder="请输入公告标题"
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 danger size="small" @click="handleRemoveCover">移除</a-button>
</div>
</div>
<a-upload
:before-upload="beforeImageUpload"
:custom-request="handleCoverUpload"
:show-upload-list="false"
accept="image/*"
>
<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"
:maxlength="300"
:rows="2"
placeholder="简短描述公告内容"
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"
:footer="null"
:title="previewData?.title || '公告预览'"
width="760px"
>
<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 v-if="previewData.overview" class="preview-summary">{{ 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" :footer="null" title="封面预览" width="640px">
<img v-if="previewImageUrl" :src="previewImageUrl" class="image-preview-modal" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
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>

View File

@@ -0,0 +1,220 @@
<template>
<div class="admin-applications-expert">
<!-- 复用专家审核这里是专家申请管理入口 -->
<div class="page-header">
<h3>专家申请管理</h3>
<a-space>
<a-button type="primary" @click="navigateTo('/admin/experts/review')">前往审核</a-button>
<a-button @click="loadData">刷新</a-button>
</a-space>
</div>
<div class="stats-row">
<div class="stat-item blue">
<div class="stat-num">{{ stats.total }}</div>
<div class="stat-label">总申请</div>
</div>
<div class="stat-item orange">
<div class="stat-num">{{ stats.pending }}</div>
<div class="stat-label">待审核</div>
</div>
<div class="stat-item green">
<div class="stat-num">{{ stats.approved }}</div>
<div class="stat-label">已通过</div>
</div>
<div class="stat-item red">
<div class="stat-num">{{ stats.rejected }}</div>
<div class="stat-label">已拒绝</div>
</div>
</div>
<!-- 申请材料模板下载 -->
<div class="template-card">
<h4>申请材料模板</h4>
<p>以下为专家申请所需材料的模板文件请申请人按要求填写并提交</p>
<div class="template-list">
<div class="template-item">
<span class="template-icon">📄</span>
<span class="template-name">专家申请表个人签字</span>
<a-button size="small" type="primary">下载模板</a-button>
</div>
<div class="template-item">
<span class="template-icon">📋</span>
<span class="template-name">专家申请说明文件</span>
<a-button size="small" type="primary">下载模板</a-button>
</div>
</div>
</div>
<!-- 近期申请列表 -->
<div class="table-card">
<div class="table-header">
<span class="table-title">近期申请记录</span>
<a-button size="small" @click="navigateTo('/admin/experts/review')">查看全部并审核 </a-button>
</div>
<a-table
:columns="columns"
:data-source="recentApplications"
:loading="loading"
:pagination="false"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-button size="small" @click="navigateTo('/admin/experts/review')">审核</a-button>
</template>
</template>
</a-table>
</div>
</div>
</template>
<script lang="ts" setup>
definePageMeta({ layout: 'admin' })
useHead({ title: '专家申请管理' })
const loading = ref(false)
const stats = reactive({ total: 12, pending: 3, approved: 8, rejected: 1 })
const columns = [
{ title: '申请人', dataIndex: 'name', key: 'name' },
{ title: '单位', dataIndex: 'organization', key: 'organization' },
{ title: '研究领域', dataIndex: 'researchArea', key: 'researchArea' },
{ title: '申请时间', dataIndex: 'applyTime', key: 'applyTime' },
{ title: '状态', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 80 },
]
const recentApplications = ref([
{ id: 1, name: '张某某', organization: '广西大学', researchArea: '区域经济', applyTime: '2024-12-18', status: 'pending' },
{ id: 2, name: '李某某', organization: '广西社科院', researchArea: '产业政策', applyTime: '2024-12-17', status: 'pending' },
{ id: 3, name: '王某某', organization: '广西师范大学', researchArea: '金融经济', applyTime: '2024-12-15', status: 'approved' },
])
function getStatusColor(status: string) {
const map: Record<string, string> = { pending: 'orange', approved: 'green', rejected: 'red' }
return map[status] || 'default'
}
function getStatusText(status: string) {
const map: Record<string, string> = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
return map[status] || status
}
async function loadData() {
// TODO: 接入API
}
</script>
<style scoped>
.admin-applications-expert { display: flex; flex-direction: column; gap: 16px; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.page-header h3 {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.stat-item {
background: #fff;
border-radius: 12px;
padding: 20px;
text-align: center;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.stat-item.blue { border-top: 3px solid #3b82f6; }
.stat-item.orange { border-top: 3px solid #f97316; }
.stat-item.green { border-top: 3px solid #22c55e; }
.stat-item.red { border-top: 3px solid #ef4444; }
.stat-num {
font-size: 32px;
font-weight: 800;
color: #1f2937;
}
.stat-label {
font-size: 13px;
color: #9ca3af;
margin-top: 4px;
}
.template-card, .table-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.template-card h4, .table-header {
font-size: 15px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px;
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.table-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.template-card p {
font-size: 13px;
color: #6b7280;
margin: 0 0 12px;
}
.template-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.template-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #f9fafb;
border-radius: 8px;
}
.template-icon { font-size: 16px; }
.template-name {
flex: 1;
font-size: 14px;
color: #374151;
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<div class="admin-applications-member">
<div class="page-header">
<h3>会员申请管理</h3>
<a-space>
<a-button type="primary" @click="navigateTo('/admin/members/review')">前往审核</a-button>
</a-space>
</div>
<div class="stats-row">
<div class="stat-item blue">
<div class="stat-num">{{ stats.total }}</div>
<div class="stat-label">总申请</div>
</div>
<div class="stat-item orange">
<div class="stat-num">{{ stats.pending }}</div>
<div class="stat-label">待审核</div>
</div>
<div class="stat-item green">
<div class="stat-num">{{ stats.approved }}</div>
<div class="stat-label">已通过</div>
</div>
<div class="stat-item purple">
<div class="stat-num">{{ stats.enterprise }}</div>
<div class="stat-label">企业会员</div>
</div>
<div class="stat-item teal">
<div class="stat-num">{{ stats.personal }}</div>
<div class="stat-label">个人会员</div>
</div>
</div>
<!-- 材料模板 -->
<div class="template-card">
<h4>申请材料模板</h4>
<a-tabs>
<a-tab-pane key="enterprise" tab="企业会员模板">
<div class="template-list">
<div class="template-item">
<span class="template-icon">📄</span>
<span class="template-name">企业会员入会申请表盖章</span>
<a-button size="small" type="primary">下载模板</a-button>
</div>
<div class="template-desc">所需材料营业执照副本法人身份证单位简介</div>
</div>
</a-tab-pane>
<a-tab-pane key="personal" tab="个人会员模板">
<div class="template-list">
<div class="template-item">
<span class="template-icon">📄</span>
<span class="template-name">个人会员入会申请表签字</span>
<a-button size="small" type="primary">下载模板</a-button>
</div>
<div class="template-desc">所需材料个人简介职称证书/学历证书身份证研究成果或获奖证明</div>
</div>
</a-tab-pane>
</a-tabs>
</div>
<div class="table-card">
<div class="table-header">
<span class="table-title">近期申请记录</span>
<a-button size="small" @click="navigateTo('/admin/members/review')">查看全部并审核 </a-button>
</div>
<a-table
:columns="columns"
:data-source="recentApplications"
:pagination="false"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="record.type === 'enterprise' ? 'blue' : 'green'">
{{ record.type === 'enterprise' ? '企业' : '个人' }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'pending' ? 'orange' : record.status === 'approved' ? 'green' : 'red'">
{{ record.status === 'pending' ? '待审核' : record.status === 'approved' ? '已通过' : '已拒绝' }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-button size="small" @click="navigateTo('/admin/members/review')">审核</a-button>
</template>
</template>
</a-table>
</div>
</div>
</template>
<script lang="ts" setup>
definePageMeta({ layout: 'admin' })
useHead({ title: '会员申请管理' })
const stats = reactive({ total: 20, pending: 5, approved: 14, enterprise: 8, personal: 12 })
const columns = [
{ title: '申请人', dataIndex: 'name', key: 'name' },
{ title: '类型', key: 'type', width: 90 },
{ title: '联系方式', dataIndex: 'contact', key: 'contact' },
{ title: '申请时间', dataIndex: 'applyTime', key: 'applyTime' },
{ title: '状态', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 80 },
]
const recentApplications = ref([
{ id: 1, name: '广西某科技公司', type: 'enterprise', contact: '139****0001', applyTime: '2024-12-19', status: 'pending' },
{ id: 2, name: '张某某', type: 'personal', contact: '138****0002', applyTime: '2024-12-18', status: 'pending' },
{ id: 3, name: '南宁某咨询机构', type: 'enterprise', contact: '137****0003', applyTime: '2024-12-15', status: 'approved' },
])
</script>
<style scoped>
.admin-applications-member { display: flex; flex-direction: column; gap: 16px; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.page-header h3 { font-size: 16px; font-weight: 700; color: #1f2937; margin: 0; }
.stats-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
}
.stat-item {
background: #fff;
border-radius: 12px;
padding: 20px;
text-align: center;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.stat-item.blue { border-top: 3px solid #3b82f6; }
.stat-item.orange { border-top: 3px solid #f97316; }
.stat-item.green { border-top: 3px solid #22c55e; }
.stat-item.purple { border-top: 3px solid #8b5cf6; }
.stat-item.teal { border-top: 3px solid #14b8a6; }
.stat-num { font-size: 28px; font-weight: 800; color: #1f2937; }
.stat-label { font-size: 12px; color: #9ca3af; margin-top: 4px; }
.template-card, .table-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.template-card h4 { font-size: 15px; font-weight: 600; color: #1f2937; margin: 0 0 12px; }
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.table-title { font-size: 15px; font-weight: 600; color: #1f2937; }
.template-list { display: flex; flex-direction: column; gap: 8px; }
.template-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #f9fafb;
border-radius: 8px;
}
.template-icon { font-size: 16px; }
.template-name { flex: 1; font-size: 14px; color: #374151; }
.template-desc {
font-size: 12px;
color: #9ca3af;
padding: 4px 14px;
}
</style>

View 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 :loading="loading" @click="loadCategories">
<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 :md="8" :xs="12">
<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 :md="8" :xs="12">
<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 :md="8" :xs="12">
<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"
placeholder="搜索分类名称 / 标识 / 路径"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedCategories"
:loading="loading"
:pagination="tablePagination"
row-key="categoryId"
size="middle"
@change="handleTableChange"
>
<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 size="small" type="link" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm title="确认删除此分类?" @confirm="handleDelete(record)">
<a-button danger size="small" type="link">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="showFormModal"
:confirm-loading="saving"
:title="editingCategory?.categoryId ? '编辑分类' : '新增分类'"
width="720px"
@cancel="showFormModal = false"
@ok="handleSave"
>
<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"
:maxlength="80"
placeholder="请输入分类名称"
show-count
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="分类标识">
<a-input
v-model:value="formData.categoryCode"
:maxlength="60"
placeholder="例如 news / tutorial"
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"
:options="parentOptions"
allow-clear
option-filter-prop="label"
placeholder="无上级则留空"
show-search
/>
</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 lang="ts" setup>
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>

View 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 :loading="loading" @click="loadArticles">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in statCards" :key="stat.key" :md="6" :xs="12">
<div
:class="[
stat.color,
{
active:
(filterStatus === undefined && stat.key === -1) ||
filterStatus === stat.key,
},
]"
class="stat-card"
@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"
size="middle"
@change="handleTableChange"
>
<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 size="small" type="link" @click="handleView(record)">预览</a-button>
<a-button size="small" type="link" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm title="确认删除此文章?" @confirm="handleDelete(record)">
<a-button danger size="small" type="link">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="showFormModal"
:confirm-loading="saving"
:title="editingArticle?.articleId ? '编辑文章' : '新增文章'"
width="760px"
@cancel="showFormModal = false"
@ok="handleSave"
>
<a-form :model="formData" layout="vertical">
<a-form-item label="文章标题" required>
<a-input
v-model:value="formData.title"
:maxlength="200"
placeholder="请输入文章标题"
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"
:options="categorySelectOptions"
allow-clear
option-filter-prop="label"
placeholder="请选择文章分类"
show-search
/>
</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 danger size="small" @click="handleRemoveCover">移除</a-button>
</div>
</div>
<a-upload
:before-upload="beforeImageUpload"
:custom-request="handleCoverUpload"
:show-upload-list="false"
accept="image/*"
>
<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"
:maxlength="500"
:rows="3"
placeholder="文章简短描述"
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"
:show-preview="false"
min-height="320px"
placeholder="请输入 Markdown 内容,支持 # 标题、**加粗**、*斜体*、[链接](url)、![图片](url)、代码块等语法"
/>
</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"
:footer="null"
:title="previewData?.title || '文章预览'"
width="760px"
>
<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 v-if="previewData.overview" class="preview-summary">{{ 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" :footer="null" title="封面预览" width="640px">
<img v-if="previewImageUrl" :src="previewImageUrl" class="image-preview-modal" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
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>

View File

@@ -0,0 +1,334 @@
<template>
<div class="admin-categories">
<!-- 操作栏 -->
<div class="toolbar">
<div class="toolbar-left">
<h3 class="page-title">栏目管理</h3>
<span class="total-count"> {{ total }} 个栏目</span>
</div>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增栏目
</a-button>
</div>
<!-- 栏目树形表格 -->
<div class="table-card">
<a-table
:columns="columns"
:data-source="dataSource"
:expand-row-by-click="true"
:loading="loading"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<span class="category-name">{{ record.name }}</span>
</template>
<template v-if="column.key === 'type'">
<a-tag :color="record.isSystem ? 'blue' : 'default'">
{{ record.isSystem ? '系统栏目' : '自定义' }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-switch
:checked="record.status === 1"
checked-children="显示"
un-checked-children="隐藏"
@change="(val: boolean) => handleStatusChange(record, val)"
/>
</template>
<template v-if="column.key === 'articleCount'">
<a-badge :count="record.articleCount" :overflow-count="999">
<a-button size="small" @click="goArticles(record)">查看文章</a-button>
</a-badge>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" @click="handleEdit(record)">编辑</a-button>
<a-button size="small" type="dashed" @click="handleAddSub(record)">添加子栏目</a-button>
<a-popconfirm
:title="`确定删除栏目「${record.name}」吗?此操作不可恢复!`"
cancel-text="取消"
ok-text="确定"
@confirm="handleDelete(record)"
>
<a-button :disabled="record.isSystem" danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:confirm-loading="saving"
:title="editingRecord ? '编辑栏目' : '新增栏目'"
width="600px"
@ok="handleSave"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-form-item label="上级栏目" name="parentId">
<a-tree-select
v-model:value="formData.parentId"
:field-names="{ label: 'name', value: 'id', children: 'children' }"
:tree-data="treeSelectData"
allow-clear
placeholder="选择上级栏目(不选则为一级)"
tree-default-expand-all
/>
</a-form-item>
<a-form-item label="栏目名称" name="name">
<a-input v-model:value="formData.name" :maxlength="50" placeholder="请输入栏目名称" show-count />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="栏目标识(英文)" name="slug">
<a-input v-model:value="formData.slug" placeholder="如 news / policy" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序权重" name="sort">
<a-input-number v-model:value="formData.sort" :max="9999" :min="0" style="width:100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="栏目描述" name="description">
<a-textarea v-model:value="formData.description" :rows="3" placeholder="请输入栏目描述" />
</a-form-item>
<a-form-item label="封面图" name="cover">
<a-input v-model:value="formData.cover" placeholder="封面图URL" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">显示</a-radio>
<a-radio :value="0">隐藏</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="是否需要会员权限" name="memberOnly">
<a-switch v-model:checked="formData.memberOnly" checked-children="需要" un-checked-children="不需要" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '栏目管理' })
const loading = ref(false)
const saving = ref(false)
const modalVisible = ref(false)
const editingRecord = ref<any>(null)
const formRef = ref()
const total = ref(0)
const formData = reactive({
parentId: undefined as number | undefined,
name: '',
slug: '',
sort: 0,
description: '',
cover: '',
status: 1,
memberOnly: false,
})
const rules = {
name: [{ required: true, message: '请输入栏目名称' }],
slug: [{ required: true, message: '请输入栏目标识' }],
}
const columns = [
{ title: '栏目名称', key: 'name', dataIndex: 'name' },
{ title: '标识', dataIndex: 'slug', key: 'slug' },
{ title: '类型', key: 'type' },
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
{ title: '状态', key: 'status', width: 100 },
{ title: '文章数', key: 'articleCount', width: 120 },
{ title: '操作', key: 'action', width: 220 },
]
const dataSource = ref<any[]>([
{
id: 1, name: '政策要闻', slug: 'news', sort: 1, status: 1, articleCount: 128, isSystem: true,
children: [
{ id: 11, name: '党中央国务院信息', slug: 'news-central', sort: 1, status: 1, articleCount: 42, isSystem: true },
{ id: 12, name: '自治区党委政府信息', slug: 'news-region', sort: 2, status: 1, articleCount: 35, isSystem: true },
{ id: 13, name: '其他厅委办信息', slug: 'news-department', sort: 3, status: 1, articleCount: 29, isSystem: true },
{ id: 14, name: '最新发布', slug: 'news-latest', sort: 4, status: 1, articleCount: 22, isSystem: true },
]
},
{
id: 2, name: '决策咨询', slug: 'consultation', sort: 2, status: 1, articleCount: 96, isSystem: true,
children: [
{ id: 21, name: '市县决策', slug: 'consult-city', sort: 1, status: 1, articleCount: 18, isSystem: true },
{ id: 22, name: '前沿观察', slug: 'consult-frontier', sort: 2, status: 1, articleCount: 15, isSystem: true },
{ id: 23, name: '行业资讯', slug: 'consult-industry', sort: 3, status: 1, articleCount: 20, isSystem: true },
{ id: 24, name: '企业动态', slug: 'consult-enterprise', sort: 4, status: 1, articleCount: 12, isSystem: true },
{ id: 25, name: '研究热点', slug: 'consult-research', sort: 5, status: 1, articleCount: 14, isSystem: true },
{ id: 26, name: '学术活动', slug: 'consult-academic', sort: 6, status: 1, articleCount: 10, isSystem: true },
{ id: 27, name: '其他汇编', slug: 'consult-other', sort: 7, status: 1, articleCount: 7, isSystem: true },
]
},
{
id: 3, name: '决策参考', slug: 'reference', sort: 3, status: 1, articleCount: 75, isSystem: true,
children: [
{ id: 31, name: '政策原文', slug: 'ref-policy', sort: 1, status: 1, articleCount: 20, isSystem: true },
{ id: 32, name: '深度解读', slug: 'ref-analysis', sort: 2, status: 1, articleCount: 15, isSystem: true },
{ id: 33, name: '研究成果', slug: 'ref-research', sort: 3, status: 1, articleCount: 18, isSystem: true },
{ id: 34, name: '专题研究', slug: 'ref-special', sort: 4, status: 1, articleCount: 12, isSystem: true },
{ id: 35, name: '东盟研究', slug: 'ref-asean', sort: 5, status: 1, articleCount: 8, isSystem: true },
{ id: 36, name: '数据服务', slug: 'ref-data', sort: 6, status: 1, articleCount: 2, isSystem: true },
]
},
{ id: 4, name: '专家资讯', slug: 'expert', sort: 4, status: 1, articleCount: 52, isSystem: true },
{
id: 5, name: '智库观察', slug: 'think-tank', sort: 5, status: 1, articleCount: 38, isSystem: true,
children: [
{ id: 51, name: '智库介绍', slug: 'thinktank-intro', sort: 1, status: 1, articleCount: 16, isSystem: true },
{ id: 52, name: '智库视角', slug: 'thinktank-view', sort: 2, status: 1, articleCount: 22, isSystem: true },
]
},
{ id: 6, name: '建言献策', slug: 'suggestions', sort: 6, status: 1, articleCount: 0, isSystem: true },
{ id: 7, name: '翰墨文谈', slug: 'hanmo', sort: 7, status: 1, articleCount: 24, isSystem: true },
{ id: 8, name: '关于我们', slug: 'about', sort: 8, status: 1, articleCount: 5, isSystem: true },
])
const treeSelectData = computed(() => dataSource.value.map(item => ({
id: item.id,
name: item.name,
children: item.children,
})))
function handleAdd() {
editingRecord.value = null
Object.assign(formData, { parentId: undefined, name: '', slug: '', sort: 0, description: '', cover: '', status: 1, memberOnly: false })
modalVisible.value = true
}
function handleAddSub(record: any) {
editingRecord.value = null
Object.assign(formData, { parentId: record.id, name: '', slug: '', sort: 0, description: '', cover: '', status: 1, memberOnly: false })
modalVisible.value = true
}
function handleEdit(record: any) {
editingRecord.value = record
Object.assign(formData, {
parentId: record.parentId,
name: record.name,
slug: record.slug,
sort: record.sort,
description: record.description || '',
cover: record.cover || '',
status: record.status,
memberOnly: record.memberOnly || false,
})
modalVisible.value = true
}
async function handleSave() {
try {
await formRef.value?.validate()
saving.value = true
// TODO: 调用API
message.success(editingRecord.value ? '栏目更新成功' : '栏目创建成功')
modalVisible.value = false
} catch (e: any) {
if (e?.errorFields) return
message.error(e?.message || '操作失败')
} finally {
saving.value = false
}
}
async function handleDelete(record: any) {
try {
// TODO: 调用API
message.success('栏目已删除')
loadData()
} catch (e: any) {
message.error(e?.message || '删除失败')
}
}
function handleStatusChange(record: any, val: boolean) {
record.status = val ? 1 : 0
message.success(`栏目"${record.name}"已${val ? '显示' : '隐藏'}`)
// TODO: 调用API
}
function goArticles(record: any) {
navigateTo(`/admin/articles?categoryId=${record.id}`)
}
async function loadData() {
loading.value = true
try {
// TODO: 接入实际API
} finally {
loading.value = false
}
}
onMounted(() => {
total.value = 8
// loadData()
})
</script>
<style scoped>
.admin-categories {
display: flex;
flex-direction: column;
gap: 16px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.total-count {
font-size: 13px;
color: #9ca3af;
}
.table-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.category-name {
font-weight: 500;
color: #1f2937;
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<div class="admin-downloads">
<div class="toolbar">
<div class="toolbar-left">
<h3 class="page-title">资料下载管理</h3>
<a-tag> {{ total }} 个文件</a-tag>
</div>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
上传文件
</a-button>
</div>
<!-- 分类筛选 -->
<div class="filter-bar">
<a-radio-group v-model:value="activeCategory" button-style="solid" @change="loadData">
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="form">申请表格</a-radio-button>
<a-radio-button value="report">研究报告</a-radio-button>
<a-radio-button value="policy">政策文件</a-radio-button>
<a-radio-button value="other">其他</a-radio-button>
</a-radio-group>
</div>
<div class="table-card">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="{ total, pageSize: 15, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fileName'">
<div class="file-info">
<span class="file-icon">{{ getFileIcon(record.fileType) }}</span>
<span class="file-name">{{ record.fileName }}</span>
</div>
</template>
<template v-if="column.key === 'category'">
<a-tag>{{ getCategoryLabel(record.category) }}</a-tag>
</template>
<template v-if="column.key === 'memberOnly'">
<a-tag :color="record.memberOnly ? 'blue' : 'default'">
{{ record.memberOnly ? '会员专享' : '公开' }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" @click="handleEdit(record)">编辑</a-button>
<a-button size="small" @click="previewFile(record)">预览</a-button>
<a-popconfirm title="确定删除此文件?" @confirm="handleDelete(record)">
<a-button danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 上传/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:confirm-loading="saving"
:title="editingRecord ? '编辑文件信息' : '上传文件'"
width="560px"
@ok="handleSave"
>
<a-form :model="formData" layout="vertical">
<a-form-item v-if="!editingRecord" label="文件">
<a-upload
v-model:file-list="fileList"
:before-upload="() => false"
:max-count="1"
>
<a-button><template #icon>📎</template>选择文件</a-button>
</a-upload>
</a-form-item>
<a-form-item label="显示名称" required>
<a-input v-model:value="formData.fileName" placeholder="请输入文件显示名称" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="文件分类">
<a-select v-model:value="formData.category">
<a-select-option value="form">申请表格</a-select-option>
<a-select-option value="report">研究报告</a-select-option>
<a-select-option value="policy">政策文件</a-select-option>
<a-select-option value="other">其他</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="访问权限">
<a-radio-group v-model:value="formData.memberOnly">
<a-radio :value="false">公开</a-radio>
<a-radio :value="true">会员专享</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="文件描述">
<a-textarea v-model:value="formData.description" :rows="3" placeholder="请输入文件描述" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '资料下载管理' })
const loading = ref(false)
const saving = ref(false)
const modalVisible = ref(false)
const editingRecord = ref<any>(null)
const fileList = ref<any[]>([])
const activeCategory = ref('')
const total = ref(0)
const formData = reactive({
fileName: '',
category: 'form',
memberOnly: false,
description: '',
})
const columns = [
{ title: '文件名称', key: 'fileName' },
{ title: '分类', key: 'category', width: 110 },
{ title: '文件大小', dataIndex: 'fileSize', key: 'fileSize', width: 100 },
{ title: '下载次数', dataIndex: 'downloadCount', key: 'downloadCount', width: 100 },
{ title: '权限', key: 'memberOnly', width: 100 },
{ title: '上传时间', dataIndex: 'uploadTime', key: 'uploadTime', width: 150 },
{ title: '操作', key: 'action', width: 200 },
]
const dataSource = ref([
{ id: 1, fileName: '企业会员入会申请表.docx', fileType: 'docx', category: 'form', fileSize: '35KB', downloadCount: 128, memberOnly: false, uploadTime: '2024-11-01' },
{ id: 2, fileName: '个人会员入会申请表.docx', fileType: 'docx', category: 'form', fileSize: '32KB', downloadCount: 96, memberOnly: false, uploadTime: '2024-11-01' },
{ id: 3, fileName: '专家申请表.docx', fileType: 'docx', category: 'form', fileSize: '40KB', downloadCount: 65, memberOnly: false, uploadTime: '2024-11-01' },
{ id: 4, fileName: '广西经济社会发展研究报告2024.pdf', fileType: 'pdf', category: 'report', fileSize: '2.8MB', downloadCount: 342, memberOnly: true, uploadTime: '2024-12-01' },
{ id: 5, fileName: '广西数字经济政策汇编.pdf', fileType: 'pdf', category: 'policy', fileSize: '1.2MB', downloadCount: 215, memberOnly: false, uploadTime: '2024-11-15' },
])
function getFileIcon(type: string) {
const iconMap: Record<string, string> = { pdf: '📕', docx: '📘', doc: '📘', xlsx: '📗', pptx: '📙', zip: '📦' }
return iconMap[type] || '📄'
}
function getCategoryLabel(cat: string) {
const map: Record<string, string> = { form: '申请表格', report: '研究报告', policy: '政策文件', other: '其他' }
return map[cat] || cat
}
function handleAdd() {
editingRecord.value = null
Object.assign(formData, { fileName: '', category: 'form', memberOnly: false, description: '' })
fileList.value = []
modalVisible.value = true
}
function handleEdit(record: any) {
editingRecord.value = record
Object.assign(formData, { fileName: record.fileName, category: record.category, memberOnly: record.memberOnly, description: record.description || '' })
modalVisible.value = true
}
async function handleSave() {
saving.value = true
try {
message.success(editingRecord.value ? '文件信息已更新' : '文件上传成功')
modalVisible.value = false
loadData()
} finally {
saving.value = false
}
}
async function handleDelete(record: any) {
// TODO: 调用API
message.success('文件已删除')
}
function previewFile(record: any) {
message.info(`预览:${record.fileName}`)
}
async function loadData() {
total.value = dataSource.value.length
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.admin-downloads { display: flex; flex-direction: column; gap: 16px; }
.toolbar, .filter-bar, .table-card {
background: #fff;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
}
.file-icon { font-size: 18px; }
.file-name {
font-size: 14px;
color: #1f2937;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,376 @@
<template>
<div class="experts-page">
<div class="page-header">
<div>
<h2 class="page-title">🎓 专家管理</h2>
<p class="page-desc">管理平台认证专家信息支持专家审核与状态管理</p>
</div>
<a-space>
<a-button :loading="loading" @click="loadExperts">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in statCards" :key="stat.key" :sm="6" :xs="12">
<div
:class="[stat.color, { active: filterStatus === stat.key }]"
class="stat-card"
@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>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索姓名 / 单位 / 研究领域"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedExperts"
:loading="loading"
:pagination="tablePagination"
row-key="id"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="expert-info-cell">
<div class="expert-avatar">{{ record.name?.charAt(0) || '?' }}</div>
<div class="expert-info-text">
<div class="expert-name">{{ record.name }}</div>
<div class="expert-meta">
<span v-if="record.title">🏷 {{ record.title }}</span>
<span v-if="record.organization" class="meta-item">🏛 {{ record.organization }}</span>
</div>
</div>
</div>
</template>
<template v-if="column.key === 'contact'">
<div class="contact-cell">
<div v-if="record.email">📧 {{ record.email }}</div>
<div v-if="record.phone">📱 {{ record.phone }}</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 === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleView(record)">查看</a-button>
<a-button v-if="record.status === 0" size="small" type="link" @click="handleReview(record)">审核</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:footer="null"
title="专家详情"
width="700px"
>
<template v-if="currentExpert">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="姓名">{{ currentExpert.name }}</a-descriptions-item>
<a-descriptions-item label="职称">{{ currentExpert.title || '-' }}</a-descriptions-item>
<a-descriptions-item label="单位">{{ currentExpert.organization || '-' }}</a-descriptions-item>
<a-descriptions-item label="研究领域">{{ currentExpert.researchArea || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ currentExpert.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="电话">{{ currentExpert.phone || '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(currentExpert.status)">{{ statusText(currentExpert.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ currentExpert.createTime?.substring(0, 10) || '-' }}</a-descriptions-item>
<a-descriptions-item :span="2" label="个人简介">{{ currentExpert.bio || '-' }}</a-descriptions-item>
<a-descriptions-item :span="2" label="研究成果">{{ currentExpert.achievements || '-' }}</a-descriptions-item>
</a-descriptions>
<div v-if="currentExpert.attachments?.length" class="attachments-section">
<h4>附件材料</h4>
<div class="attachment-list">
<a v-for="(file, idx) in currentExpert.attachments" :key="idx" :href="file.url" target="_blank">
📎 {{ file.name }}
</a>
</div>
</div>
<div v-if="currentExpert.status === 0" class="review-actions">
<a-divider />
<a-space>
<a-button type="primary" @click="handleApprove(currentExpert)">通过审核</a-button>
<a-button danger @click="handleReject(currentExpert)">拒绝</a-button>
</a-space>
</div>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '专家管理 - 后台管理' })
interface Expert {
id?: number
name?: string
title?: string
organization?: string
researchArea?: string
email?: string
phone?: string
bio?: string
achievements?: string
status?: number
createTime?: string
attachments?: { name: string; url: string }[]
}
const loading = ref(false)
const experts = ref<Expert[]>([])
const filterStatus = 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: 'orange' },
{ key: 1, icon: '✅', label: '已认证', value: 0, color: 'green' },
{ key: 2, icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: -1, icon: '👥', label: '全部专家', value: 0, color: 'blue' },
])
const columns = [
{ title: '专家信息', key: 'info', width: 280 },
{ title: '联系方式', key: 'contact', width: 200 },
{ title: '状态', key: 'status', width: 100 },
{ title: '申请时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 120 },
]
const showDetailModal = ref(false)
const currentExpert = ref<Expert | null>(null)
const filteredExperts = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return experts.value
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => {
if (!keyword) return true
return [item.name, item.organization, item.researchArea]
.some(val => String(val || '').toLowerCase().includes(keyword))
})
.sort((a, b) => (b.id || 0) - (a.id || 0))
})
const pagedExperts = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredExperts.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredExperts.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
function updateStats() {
statCards[0].value = experts.value.filter(i => i.status === 0).length
statCards[1].value = experts.value.filter(i => i.status === 1).length
statCards[2].value = experts.value.filter(i => i.status === 2).length
statCards[3].value = experts.value.length
}
async function loadExperts() {
loading.value = true
try {
// TODO: 接入实际API
// const res = await listExperts()
// experts.value = res || []
updateStats()
} catch (e: any) {
message.error(e?.message || '加载专家列表失败')
} finally {
loading.value = false
}
}
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
}
function handleView(record: Expert) {
currentExpert.value = record
showDetailModal.value = true
}
function handleReview(record: Expert) {
currentExpert.value = record
showDetailModal.value = true
}
async function handleApprove(expert: Expert) {
try {
// TODO: 接入实际API
// await approveExpert(expert.id)
message.success('已通过审核')
showDetailModal.value = false
await loadExperts()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleReject(expert: Expert) {
try {
// TODO: 接入实际API
// await rejectExpert(expert.id)
message.success('已拒绝')
showDetailModal.value = false
await loadExperts()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function statusText(status?: number) {
const map: Record<number, string> = { 0: '待审核', 1: '已认证', 2: '已拒绝' }
return map[status ?? -1] || '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = { 0: 'orange', 1: 'success', 2: 'error' }
return map[status ?? -1] || 'default'
}
onMounted(() => {
loadExperts()
})
</script>
<style scoped>
.experts-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); }
.expert-info-cell { display: flex; align-items: flex-start; gap: 12px; }
.expert-avatar {
width: 48px; height: 48px; border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; font-size: 20px; font-weight: 700;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.expert-info-text { flex: 1; min-width: 0; }
.expert-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.expert-meta { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 4px; }
.meta-item { margin-left: 8px; }
.contact-cell { font-size: 12px; color: rgba(0,0,0,0.65); line-height: 1.7; }
.attachments-section { margin-top: 16px; }
.attachments-section h4 { font-size: 14px; margin-bottom: 8px; }
.attachment-list { display: flex; flex-direction: column; gap: 8px; }
.attachment-list a { color: #1890ff; }
.review-actions { text-align: right; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
.mb-6 { margin-bottom: 24px; }
</style>

View File

@@ -0,0 +1,337 @@
<template>
<div class="admin-experts-review">
<div class="page-header">
<h3>专家审核</h3>
<span class="pending-count">待审核{{ pendingCount }} </span>
</div>
<!-- 搜索过滤 -->
<div class="filter-bar">
<a-space wrap>
<a-input v-model:value="filters.keyword" allow-clear placeholder="搜索专家姓名/单位" style="width: 200px" @press-enter="loadData" />
<a-select v-model:value="filters.status" style="width: 130px" @change="loadData">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待审核</a-select-option>
<a-select-option value="approved">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
</a-select>
<a-button type="primary" @click="loadData">搜索</a-button>
</a-space>
</div>
<!-- 审核列表 -->
<div class="table-card">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="{ total, pageSize, current: currentPage, onChange: handlePageChange, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'applicant'">
<div class="applicant-info">
<a-avatar :size="36" :src="record.avatar">{{ record.name?.charAt(0) }}</a-avatar>
<div class="applicant-detail">
<div class="applicant-name">{{ record.name }}</div>
<div class="applicant-org">{{ record.organization }}</div>
</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'materials'">
<a-space>
<a-button size="small" @click="previewFile(record, 'resume')">简历</a-button>
<a-button size="small" @click="previewFile(record, 'id')">身份证</a-button>
<a-button size="small" @click="previewFile(record, 'cert')">证书</a-button>
</a-space>
</template>
<template v-if="column.key === 'action'">
<a-space v-if="record.status === 'pending'">
<a-button size="small" type="primary" @click="handleApprove(record)">通过</a-button>
<a-button danger size="small" @click="handleReject(record)">拒绝</a-button>
<a-button size="small" @click="viewDetail(record)">详情</a-button>
</a-space>
<a-space v-else>
<a-button size="small" @click="viewDetail(record)">详情</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 拒绝原因弹窗 -->
<a-modal
v-model:open="rejectModal"
:confirm-loading="saving"
title="填写拒绝原因"
@ok="confirmReject"
>
<a-form layout="vertical">
<a-form-item label="拒绝原因" required>
<a-textarea v-model:value="rejectReason" :rows="4" placeholder="请说明拒绝原因(将通知申请人)" />
</a-form-item>
</a-form>
</a-modal>
<!-- 详情弹窗 -->
<a-modal
v-model:open="detailModal"
:footer="null"
:title="`${currentRecord?.name} - 申请详情`"
width="700px"
>
<div v-if="currentRecord" class="detail-content">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="姓名">{{ currentRecord.name }}</a-descriptions-item>
<a-descriptions-item label="职称">{{ currentRecord.title }}</a-descriptions-item>
<a-descriptions-item label="所在单位">{{ currentRecord.organization }}</a-descriptions-item>
<a-descriptions-item label="研究领域">{{ currentRecord.researchArea }}</a-descriptions-item>
<a-descriptions-item label="学历">{{ currentRecord.education }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ currentRecord.phone }}</a-descriptions-item>
<a-descriptions-item :span="2" label="电子邮箱">{{ currentRecord.email }}</a-descriptions-item>
<a-descriptions-item :span="2" label="个人简介">{{ currentRecord.intro }}</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ currentRecord.applyTime }}</a-descriptions-item>
<a-descriptions-item label="审核状态">
<a-tag :color="getStatusColor(currentRecord.status)">{{ getStatusText(currentRecord.status) }}</a-tag>
</a-descriptions-item>
</a-descriptions>
<div class="materials-section" style="margin-top:16px">
<h4 style="margin-bottom:12px">申请材料</h4>
<a-space wrap>
<a-button icon="📄" @click="previewFile(currentRecord, 'resume')">查看简历/研究成果</a-button>
<a-button icon="🪪" @click="previewFile(currentRecord, 'id')">查看身份证</a-button>
<a-button icon="🏆" @click="previewFile(currentRecord, 'cert')">查看职称证书/学历证书</a-button>
</a-space>
</div>
<div v-if="currentRecord.status === 'pending'" class="action-area" style="margin-top:16px">
<a-space>
<a-button type="primary" @click="handleApprove(currentRecord); detailModal = false">通过申请</a-button>
<a-button danger @click="handleReject(currentRecord); detailModal = false">拒绝申请</a-button>
</a-space>
</div>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '专家审核' })
const loading = ref(false)
const saving = ref(false)
const rejectModal = ref(false)
const detailModal = ref(false)
const rejectReason = ref('')
const currentRecord = ref<any>(null)
const pendingCount = ref(3)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(15)
const filters = reactive({
keyword: '',
status: '',
})
const columns = [
{ title: '申请人', key: 'applicant', width: 200 },
{ title: '职称', dataIndex: 'title', key: 'title' },
{ title: '研究领域', dataIndex: 'researchArea', key: 'researchArea' },
{ title: '申请时间', dataIndex: 'applyTime', key: 'applyTime', width: 150 },
{ title: '状态', key: 'status', width: 100 },
{ title: '材料', key: 'materials', width: 160 },
{ title: '操作', key: 'action', width: 160 },
]
const dataSource = ref<any[]>([
{
id: 1,
name: '张某某',
avatar: '',
organization: '广西大学',
title: '教授',
researchArea: '区域经济',
education: '博士',
phone: '138****0001',
email: 'zhang@gxu.edu.cn',
intro: '长期从事区域经济研究...',
applyTime: '2024-12-18 10:30',
status: 'pending',
},
{
id: 2,
name: '李某某',
avatar: '',
organization: '广西社科院',
title: '研究员',
researchArea: '产业政策',
education: '博士',
phone: '139****0002',
email: 'li@gxss.org',
intro: '专注产业政策研究...',
applyTime: '2024-12-17 15:00',
status: 'pending',
},
{
id: 3,
name: '王某某',
avatar: '',
organization: '广西师范大学',
title: '副教授',
researchArea: '金融经济',
education: '博士',
phone: '137****0003',
email: 'wang@gxnu.edu.cn',
intro: '从事金融经济研究...',
applyTime: '2024-12-15 09:00',
status: 'approved',
},
])
function getStatusColor(status: string) {
const map: Record<string, string> = { pending: 'orange', approved: 'green', rejected: 'red' }
return map[status] || 'default'
}
function getStatusText(status: string) {
const map: Record<string, string> = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
return map[status] || status
}
async function handleApprove(record: any) {
try {
// TODO: 调用API
record.status = 'approved'
pendingCount.value = Math.max(0, pendingCount.value - 1)
message.success(`已通过 ${record.name} 的专家申请`)
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function handleReject(record: any) {
currentRecord.value = record
rejectReason.value = ''
rejectModal.value = true
}
async function confirmReject() {
if (!rejectReason.value.trim()) {
message.warning('请填写拒绝原因')
return
}
saving.value = true
try {
// TODO: 调用API
currentRecord.value.status = 'rejected'
pendingCount.value = Math.max(0, pendingCount.value - 1)
message.success('已拒绝申请并通知申请人')
rejectModal.value = false
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
saving.value = false
}
}
function viewDetail(record: any) {
currentRecord.value = record
detailModal.value = true
}
function previewFile(record: any, type: string) {
message.info(`预览 ${record.name}${type === 'resume' ? '简历' : type === 'id' ? '身份证' : '证书'}材料`)
// TODO: 打开文件预览
}
function handlePageChange(page: number) {
currentPage.value = page
loadData()
}
async function loadData() {
loading.value = true
try {
// TODO: 接入实际API
} finally {
loading.value = false
}
}
onMounted(() => {
total.value = dataSource.value.length
})
</script>
<style scoped>
.admin-experts-review {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.page-header h3 {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.pending-count {
padding: 4px 12px;
background: #fef3c7;
color: #b45309;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
.filter-bar {
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.table-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.applicant-info {
display: flex;
align-items: center;
gap: 10px;
}
.applicant-name {
font-weight: 600;
font-size: 14px;
color: #1f2937;
}
.applicant-org {
font-size: 12px;
color: #9ca3af;
}
</style>

239
app/pages/admin/index.vue Normal file
View File

@@ -0,0 +1,239 @@
<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 :loading="loadingStats" size="small" @click="loadStats">
<template #icon><ReloadOutlined /></template>
刷新数据
</a-button>
</a-space>
</div>
</div>
<!-- 核心数据统计 -->
<a-row :gutter="[16, 16]">
<a-col v-for="stat in coreStats" :key="stat.label" :md="6" :sm="12" :xs="12">
<div :class="stat.color" class="stat-block">
<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 :md="12" :xs="24">
<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-urgent': todo.urgent }"
class="todo-item"
@click="navigateTo(todo.to)"
>
<div :class="todo.dotColor" class="todo-dot"></div>
<div class="todo-content">
<span class="todo-label">{{ todo.label }}</span>
<a-tag :color="todo.tagColor">
<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 :md="12" :xs="24">
<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 :style="{ background: item.bg }" class="quick-icon">{{ item.icon }}</div>
<div class="quick-label">{{ item.label }}</div>
</div>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { ReloadOutlined, RightOutlined } from '@ant-design/icons-vue'
import { getUserInfo } from '@/api/layout'
import { getToken } from '@/utils/token-util'
definePageMeta({ layout: 'admin' })
useHead({ title: '管理后台首页' })
const adminName = ref('管理员')
const loadingStats = ref(false)
const coreStats = reactive([
{ icon: '📝', label: '文章总数', value: 0, desc: '全部文章', color: 'blue' },
{ icon: '👥', label: '用户总数', value: 0, desc: '注册用户', color: 'green' },
{ icon: '🎓', label: '专家总数', value: 0, desc: '认证专家', color: 'purple' },
{ icon: '💼', label: '会员总数', value: 0, desc: '企业/个人会员', color: 'orange' },
])
const todoItems = reactive([
{ label: '待审核专家', value: 0, to: '/admin/experts/review', tagColor: 'orange', dotColor: 'dot-orange', urgent: false },
{ label: '待审核会员', value: 0, to: '/admin/members/review', tagColor: 'cyan', dotColor: 'dot-cyan', urgent: false },
{ label: '待处理建言', value: 0, to: '/admin/suggestions', tagColor: 'blue', dotColor: 'dot-blue', urgent: false },
{ label: '待审核文章', value: 0, to: '/admin/articles', tagColor: 'red', dotColor: 'dot-red', urgent: false },
])
const quickLinks = [
{ to: '/admin/articles', icon: '📝', label: '文章管理', bg: '#fff7ed' },
{ to: '/admin/categories', icon: '🗂️', label: '栏目管理', bg: '#eff6ff' },
{ to: '/admin/experts', icon: '🎓', label: '专家管理', bg: '#faf5ff' },
{ to: '/admin/members', icon: '💼', label: '会员管理', bg: '#f0fdf4' },
{ to: '/admin/suggestions', icon: '💬', label: '建言管理', bg: '#fdf4ff' },
{ to: '/admin/users', icon: '👥', label: '用户管理', bg: '#f0f9ff' },
{ to: '/admin/announcements', icon: '📢', label: '公告管理', bg: '#fff1f2' },
{ to: '/admin/settings', icon: '⚙️', label: '系统设置', bg: '#f9fafb' },
]
async function loadStats() {
loadingStats.value = true
try {
// TODO: 接入实际API获取统计数据
// 暂时使用模拟数据
todoItems[0].value = 0
todoItems[1].value = 0
todoItems[2].value = 0
todoItems[3].value = 0
} 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(4, 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>

View File

@@ -0,0 +1,392 @@
<template>
<div class="members-page">
<div class="page-header">
<div>
<h2 class="page-title">💼 会员管理</h2>
<p class="page-desc">管理企业会员和个人会员支持入会申请审核</p>
</div>
<a-space>
<a-button :loading="loading" @click="loadMembers">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in statCards" :key="stat.key" :sm="6" :xs="12">
<div
:class="[stat.color, { active: filterStatus === stat.key }]"
class="stat-card"
@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>
<!-- 会员类型切换 -->
<a-radio-group v-model:value="memberType" button-style="solid" class="mb-4">
<a-radio-button value="all">全部</a-radio-button>
<a-radio-button value="enterprise">企业会员</a-radio-button>
<a-radio-button value="personal">个人会员</a-radio-button>
</a-radio-group>
<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>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索姓名 / 企业名称"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedMembers"
:loading="loading"
:pagination="tablePagination"
row-key="id"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="member-info-cell">
<div :class="record.type === 1 ? 'enterprise' : 'personal'" class="member-avatar">
{{ record.type === 1 ? '🏢' : '👤' }}
</div>
<div class="member-info-text">
<div class="member-name">{{ record.name }}</div>
<div class="member-meta">
<a-tag :color="record.type === 1 ? 'blue' : 'green'" size="small">
{{ record.type === 1 ? '企业会员' : '个人会员' }}
</a-tag>
</div>
</div>
</div>
</template>
<template v-if="column.key === 'contact'">
<div class="contact-cell">
<div v-if="record.contact">📞 {{ record.contact }}</div>
<div v-if="record.phone">📱 {{ record.phone }}</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 === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleView(record)">查看</a-button>
<a-button v-if="record.status === 0" size="small" type="link" @click="handleReview(record)">审核</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:footer="null"
title="会员详情"
width="700px"
>
<template v-if="currentMember">
<a-tag :color="currentMember.type === 1 ? 'blue' : 'green'" style="margin-bottom: 16px">
{{ currentMember.type === 1 ? '企业会员' : '个人会员' }}
</a-tag>
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="姓名/企业名">{{ currentMember.name }}</a-descriptions-item>
<a-descriptions-item label="联系人">{{ currentMember.contact || '-' }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ currentMember.phone || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ currentMember.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(currentMember.status)">{{ statusText(currentMember.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ currentMember.createTime?.substring(0, 10) || '-' }}</a-descriptions-item>
<a-descriptions-item :span="2" label="简介">{{ currentMember.bio || '-' }}</a-descriptions-item>
</a-descriptions>
<div v-if="currentMember.attachments?.length" class="attachments-section">
<h4>附件材料</h4>
<div class="attachment-list">
<a v-for="(file, idx) in currentMember.attachments" :key="idx" :href="file.url" target="_blank">
📎 {{ file.name }}
</a>
</div>
</div>
<div v-if="currentMember.status === 0" class="review-actions">
<a-divider />
<a-space>
<a-button type="primary" @click="handleApprove(currentMember)">通过审核</a-button>
<a-button danger @click="handleReject(currentMember)">拒绝</a-button>
</a-space>
</div>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '会员管理 - 后台管理' })
interface Member {
id?: number
name?: string
type?: number // 1: 企业, 2: 个人
contact?: string
phone?: string
email?: string
bio?: string
status?: number
createTime?: string
attachments?: { name: string; url: string }[]
}
const loading = ref(false)
const members = ref<Member[]>([])
const memberType = ref('all')
const filterStatus = 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: 'orange' },
{ key: 1, icon: '✅', label: '已通过', value: 0, color: 'green' },
{ key: 2, icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: -1, icon: '👥', label: '全部会员', value: 0, color: 'blue' },
])
const columns = [
{ title: '会员信息', key: 'info', width: 260 },
{ title: '联系方式', key: 'contact', width: 180 },
{ title: '状态', key: 'status', width: 100 },
{ title: '申请时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 120 },
]
const showDetailModal = ref(false)
const currentMember = ref<Member | null>(null)
const filteredMembers = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return members.value
.filter(item => {
if (memberType.value === 'enterprise') return item.type === 1
if (memberType.value === 'personal') return item.type === 2
return true
})
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => {
if (!keyword) return true
return [item.name, item.contact]
.some(val => String(val || '').toLowerCase().includes(keyword))
})
.sort((a, b) => (b.id || 0) - (a.id || 0))
})
const pagedMembers = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredMembers.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredMembers.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
function updateStats() {
const filtered = members.value.filter(item => {
if (memberType.value === 'enterprise') return item.type === 1
if (memberType.value === 'personal') return item.type === 2
return true
})
statCards[0].value = filtered.filter(i => i.status === 0).length
statCards[1].value = filtered.filter(i => i.status === 1).length
statCards[2].value = filtered.filter(i => i.status === 2).length
statCards[3].value = filtered.length
}
async function loadMembers() {
loading.value = true
try {
// TODO: 接入实际API
updateStats()
} catch (e: any) {
message.error(e?.message || '加载会员列表失败')
} finally {
loading.value = false
}
}
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
}
function handleView(record: Member) {
currentMember.value = record
showDetailModal.value = true
}
function handleReview(record: Member) {
currentMember.value = record
showDetailModal.value = true
}
async function handleApprove(member: Member) {
try {
// TODO: 接入实际API
message.success('已通过审核')
showDetailModal.value = false
await loadMembers()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleReject(member: Member) {
try {
// TODO: 接入实际API
message.success('已拒绝')
showDetailModal.value = false
await loadMembers()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function statusText(status?: number) {
const map: Record<number, string> = { 0: '待审核', 1: '已通过', 2: '已拒绝' }
return map[status ?? -1] || '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = { 0: 'orange', 1: 'success', 2: 'error' }
return map[status ?? -1] || 'default'
}
watch(memberType, () => {
updateStats()
})
onMounted(() => {
loadMembers()
})
</script>
<style scoped>
.members-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-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); }
.member-info-cell { display: flex; align-items: flex-start; gap: 12px; }
.member-avatar {
width: 48px; height: 48px; border-radius: 12px;
font-size: 24px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.member-avatar.enterprise { background: #eff6ff; }
.member-avatar.personal { background: #f0fdf4; }
.member-info-text { flex: 1; min-width: 0; }
.member-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.member-meta { margin-top: 4px; }
.contact-cell { font-size: 12px; color: rgba(0,0,0,0.65); line-height: 1.7; }
.attachments-section { margin-top: 16px; }
.attachments-section h4 { font-size: 14px; margin-bottom: 8px; }
.attachment-list { display: flex; flex-direction: column; gap: 8px; }
.attachment-list a { color: #1890ff; }
.review-actions { text-align: right; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
.mb-6 { margin-bottom: 24px; }
.mb-4 { margin-bottom: 16px; }
</style>

View File

@@ -0,0 +1,324 @@
<template>
<div class="admin-members-review">
<div class="page-header">
<h3>会员审核</h3>
<span class="pending-count">待审核{{ pendingCount }} </span>
</div>
<!-- 搜索过滤 -->
<div class="filter-bar">
<a-space wrap>
<a-input v-model:value="filters.keyword" allow-clear placeholder="搜索申请人姓名/单位" style="width: 200px" @press-enter="loadData" />
<a-select v-model:value="filters.type" style="width: 130px" @change="loadData">
<a-select-option value="">全部类型</a-select-option>
<a-select-option value="enterprise">企业会员</a-select-option>
<a-select-option value="personal">个人会员</a-select-option>
</a-select>
<a-select v-model:value="filters.status" style="width: 130px" @change="loadData">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待审核</a-select-option>
<a-select-option value="approved">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
</a-select>
<a-button type="primary" @click="loadData">搜索</a-button>
</a-space>
</div>
<div class="table-card">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="{ total, pageSize, current: currentPage, onChange: handlePageChange, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="record.memberType === 'enterprise' ? 'blue' : 'green'">
{{ record.memberType === 'enterprise' ? '企业会员' : '个人会员' }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'materials'">
<a-button size="small" @click="viewMaterials(record)">查看材料</a-button>
</template>
<template v-if="column.key === 'action'">
<a-space v-if="record.status === 'pending'">
<a-button size="small" type="primary" @click="handleApprove(record)">通过</a-button>
<a-button danger size="small" @click="handleReject(record)">拒绝</a-button>
<a-button size="small" @click="viewDetail(record)">详情</a-button>
</a-space>
<a-button v-else size="small" @click="viewDetail(record)">详情</a-button>
</template>
</template>
</a-table>
</div>
<!-- 材料预览弹窗 -->
<a-modal v-model:open="materialsModal" :footer="null" :title="`${currentRecord?.applicantName} 的申请材料`" width="700px">
<div v-if="currentRecord">
<!-- 企业会员材料 -->
<div v-if="currentRecord.memberType === 'enterprise'">
<h4>企业会员申请材料</h4>
<div class="materials-list">
<div class="material-item">
<span class="material-icon">📄</span>
<span class="material-name">入会申请表盖章</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">🏢</span>
<span class="material-name">营业执照副本</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">🪪</span>
<span class="material-name">法人身份证</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">📝</span>
<span class="material-name">单位简介</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
</div>
</div>
<!-- 个人会员材料 -->
<div v-else>
<h4>个人会员申请材料</h4>
<div class="materials-list">
<div class="material-item">
<span class="material-icon">📄</span>
<span class="material-name">入会申请表签字</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">📖</span>
<span class="material-name">个人简介</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">🎓</span>
<span class="material-name">职称证书/学历证书</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">🪪</span>
<span class="material-name">身份证复印件</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">🏆</span>
<span class="material-name">研究成果/获奖证明</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
</div>
</div>
<div v-if="currentRecord.status === 'pending'" class="action-area">
<a-divider />
<a-space>
<a-button type="primary" @click="handleApprove(currentRecord); materialsModal = false">通过申请</a-button>
<a-button danger @click="handleReject(currentRecord); materialsModal = false">拒绝申请</a-button>
</a-space>
</div>
</div>
</a-modal>
<!-- 详情弹窗 -->
<a-modal v-model:open="detailModal" :footer="null" :title="`会员申请详情`" width="700px">
<a-descriptions v-if="currentRecord" :column="2" bordered>
<a-descriptions-item label="申请人">{{ currentRecord.applicantName }}</a-descriptions-item>
<a-descriptions-item label="会员类型">
<a-tag :color="currentRecord.memberType === 'enterprise' ? 'blue' : 'green'">
{{ currentRecord.memberType === 'enterprise' ? '企业会员' : '个人会员' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="currentRecord.memberType === 'enterprise'" label="单位/组织">{{ currentRecord.organization }}</a-descriptions-item>
<a-descriptions-item label="联系方式">{{ currentRecord.phone }}</a-descriptions-item>
<a-descriptions-item :span="2" label="电子邮箱">{{ currentRecord.email }}</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ currentRecord.applyTime }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getStatusColor(currentRecord.status)">{{ getStatusText(currentRecord.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="currentRecord.rejectReason" :span="2" label="拒绝原因">{{ currentRecord.rejectReason }}</a-descriptions-item>
</a-descriptions>
</a-modal>
<!-- 拒绝弹窗 -->
<a-modal v-model:open="rejectModal" :confirm-loading="saving" title="填写拒绝原因" @ok="confirmReject">
<a-textarea v-model:value="rejectReason" :rows="4" placeholder="请说明拒绝原因" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '会员审核' })
const loading = ref(false)
const saving = ref(false)
const materialsModal = ref(false)
const detailModal = ref(false)
const rejectModal = ref(false)
const rejectReason = ref('')
const currentRecord = ref<any>(null)
const pendingCount = ref(5)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(15)
const filters = reactive({ keyword: '', type: '', status: '' })
const columns = [
{ title: '申请人', dataIndex: 'applicantName', key: 'applicantName' },
{ title: '会员类型', key: 'type', width: 110 },
{ title: '单位/联系方式', dataIndex: 'orgOrContact', key: 'orgOrContact' },
{ title: '申请时间', dataIndex: 'applyTime', key: 'applyTime', width: 150 },
{ title: '状态', key: 'status', width: 100 },
{ title: '材料', key: 'materials', width: 100 },
{ title: '操作', key: 'action', width: 180 },
]
const dataSource = ref([
{ id: 1, applicantName: '广西某科技公司', memberType: 'enterprise', orgOrContact: '王总 139****0001', phone: '139****0001', email: 'enterprise@xx.com', organization: '广西某科技有限公司', applyTime: '2024-12-19 10:00', status: 'pending' },
{ id: 2, applicantName: '张某某', memberType: 'personal', orgOrContact: '广西大学', phone: '138****0001', email: 'zhang@gxu.edu.cn', applyTime: '2024-12-18 14:30', status: 'pending' },
{ id: 3, applicantName: '南宁某咨询机构', memberType: 'enterprise', orgOrContact: '李经理 137****0002', phone: '137****0002', email: 'nn@xx.com', organization: '南宁某咨询有限公司', applyTime: '2024-12-15 09:20', status: 'approved' },
])
function getStatusColor(status: string) {
const map: Record<string, string> = { pending: 'orange', approved: 'green', rejected: 'red' }
return map[status] || 'default'
}
function getStatusText(status: string) {
const map: Record<string, string> = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
return map[status] || status
}
function viewMaterials(record: any) {
currentRecord.value = record
materialsModal.value = true
}
function viewDetail(record: any) {
currentRecord.value = record
detailModal.value = true
}
async function handleApprove(record: any) {
// TODO: 调用API
record.status = 'approved'
pendingCount.value = Math.max(0, pendingCount.value - 1)
message.success(`已通过 ${record.applicantName} 的会员申请`)
}
function handleReject(record: any) {
currentRecord.value = record
rejectReason.value = ''
rejectModal.value = true
}
async function confirmReject() {
if (!rejectReason.value.trim()) { message.warning('请填写拒绝原因'); return }
saving.value = true
try {
// TODO: 调用API
currentRecord.value.status = 'rejected'
currentRecord.value.rejectReason = rejectReason.value
pendingCount.value = Math.max(0, pendingCount.value - 1)
message.success('已拒绝申请并通知申请人')
rejectModal.value = false
} finally {
saving.value = false
}
}
function handlePageChange(page: number) {
currentPage.value = page
loadData()
}
async function loadData() {
// TODO: 接入实际API
}
onMounted(() => {
total.value = dataSource.value.length
})
</script>
<style scoped>
.admin-members-review {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.page-header h3 {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.pending-count {
padding: 4px 12px;
background: #fef3c7;
color: #b45309;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
.filter-bar, .table-card {
background: #fff;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.materials-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.material-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #f9fafb;
border-radius: 8px;
}
.material-icon {
font-size: 18px;
}
.material-name {
flex: 1;
font-size: 14px;
color: #374151;
}
.action-area {
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,826 @@
<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 :md="5" :xs="24">
<div class="settings-nav">
<div
v-for="tab in tabs"
:key="tab.key"
:class="{ active: activeTab === tab.key }"
class="settings-nav-item"
@click="activeTab = tab.key"
>
<span class="nav-icon">{{ tab.icon }}</span>
{{ tab.label }}
</div>
</div>
</a-col>
<!-- 右侧内容 -->
<a-col :md="19" :xs="24">
<div class="settings-panel">
<!-- 🌐 基础配置 -->
<template v-if="activeTab === 'basic'">
<div class="settings-section-title">🌐 基础配置</div>
<a-form :model="basicForm" class="settings-form" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="网站名称">
<a-input v-model:value="basicForm.siteName" placeholder="广西决策咨询网" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="网站简称">
<a-input v-model:value="basicForm.shortName" placeholder="决策咨询网" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="网站描述">
<a-textarea v-model:value="basicForm.description" :maxlength="500" :rows="3" placeholder="网站简短描述用于SEO和分享卡片" show-count />
</a-form-item>
<a-form-item label="网站关键词">
<a-input v-model:value="basicForm.keywords" placeholder="用逗号分隔,如:决策咨询,政策研究,专家智库" />
<div class="form-tip">用于搜索引擎优化多个关键词用中文逗号分隔</div>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="联系电话">
<a-input v-model:value="basicForm.contactPhone" placeholder="0771-5386339" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系邮箱">
<a-input v-model:value="basicForm.contactEmail" placeholder="gxjzxzx@126.com" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="联系地址">
<a-input v-model:value="basicForm.contactAddress" placeholder="广西·南宁·良庆区五象大道401号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="ICP备案号">
<a-input v-model:value="basicForm.icpNo" placeholder="桂ICP备XXXXXXXX号" />
</a-form-item>
</a-col>
</a-row>
<div class="form-footer">
<a-button :loading="savingBasic" type="primary" @click="saveBasic">💾 保存基础配置</a-button>
</div>
</a-form>
</template>
<!-- 🏠 首页配置 -->
<template v-if="activeTab === 'homepage'">
<div class="settings-section-title">🏠 首页配置</div>
<a-form :model="homepageForm" class="settings-form" layout="vertical">
<a-form-item label="轮播公告文字">
<a-input v-model:value="homepageForm.noticeText" placeholder="欢迎访问广西决策咨询网!" />
<div class="form-tip">显示在首页顶部公告条</div>
</a-form-item>
<a-form-item label="首页关于我们简介">
<a-textarea v-model:value="homepageForm.aboutIntro" :maxlength="1000" :rows="4" placeholder="学会/机构简介,用于首页展示..." show-count />
</a-form-item>
<a-divider>统计数据首页展示</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="专家数量">
<a-input-number v-model:value="homepageForm.expertCount" :max="99999" :min="0" style="width:100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="会员数量">
<a-input-number v-model:value="homepageForm.memberCount" :max="99999" :min="0" style="width:100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="建言数量">
<a-input-number v-model:value="homepageForm.suggestionCount" :max="99999" :min="0" style="width:100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="数据更新时间">
<a-input v-model:value="homepageForm.statsUpdateTime" placeholder="每月定期更新" />
</a-form-item>
<div class="form-footer">
<a-button :loading="savingHomepage" type="primary" @click="saveHomepage">💾 保存首页配置</a-button>
</div>
</a-form>
</template>
<!-- 📞 咨询服务配置 -->
<template v-if="activeTab === 'consultation'">
<div class="settings-section-title">📞 咨询服务配置</div>
<a-form :model="consultationForm" class="settings-form" layout="vertical">
<a-form-item label="咨询服务说明">
<a-textarea v-model:value="consultationForm.serviceDesc" :maxlength="1000" :rows="4" placeholder="咨询服务范围、内容、流程的详细说明..." show-count />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="服务热线">
<a-input v-model:value="consultationForm.servicePhone" placeholder="0771-5386339" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="服务时间">
<a-input v-model:value="consultationForm.serviceHours" placeholder="周一至周五 9:00-17:00" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="咨询邮箱">
<a-input v-model:value="consultationForm.serviceEmail" placeholder="gxjzxzx@126.com" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="邮政编码">
<a-input v-model:value="consultationForm.postalCode" placeholder="530200" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="邮寄地址">
<a-input v-model:value="consultationForm.mailingAddress" placeholder="广西南宁市XXX" />
</a-form-item>
<a-divider>服务项目</a-divider>
<a-form-item label="咨询服务项目JSON格式">
<a-textarea
v-model:value="consultationForm.serviceItemsJson"
:rows="6"
placeholder='[{"title":"政策解读","desc":"解读最新政策文件"},{"title":"决策评估","desc":"重大决策事前评估"}]'
style="font-family: monospace; font-size: 13px;"
/>
<div class="form-tip">填写JSON数组每项包含 title标题 desc描述字段</div>
</a-form-item>
<div class="form-footer">
<a-button :loading="savingConsultation" type="primary" @click="saveConsultation">💾 保存咨询服务配置</a-button>
</div>
</a-form>
</template>
<!-- 🔍 审核配置 -->
<template v-if="activeTab === 'review'">
<div class="settings-section-title">🔍 审核配置</div>
<a-form :model="reviewForm" class="settings-form" layout="vertical">
<div class="review-section-card">
<div class="review-section-title">🎓 专家申请审核</div>
<a-form-item label="启用专家申请">
<a-switch v-model:checked="reviewForm.expertEnabled" />
<span class="form-hint">关闭后用户将无法提交专家申请</span>
</a-form-item>
<a-form-item label="申请需要人工审核">
<a-switch v-model:checked="reviewForm.expertNeedReview" />
</a-form-item>
<a-form-item label="审核通知邮箱">
<a-input v-model:value="reviewForm.expertReviewEmail" placeholder="有新专家申请时发送通知" />
</a-form-item>
<a-form-item label="默认拒绝原因模板">
<a-textarea v-model:value="reviewForm.expertRejectTemplate" :rows="3" placeholder="填写常见的专家申请拒绝原因..." />
</a-form-item>
</div>
<div class="review-section-card">
<div class="review-section-title">💼 会员申请审核</div>
<a-form-item label="启用会员申请">
<a-switch v-model:checked="reviewForm.memberEnabled" />
<span class="form-hint">关闭后用户将无法提交会员申请</span>
</a-form-item>
<a-form-item label="申请需要人工审核">
<a-switch v-model:checked="reviewForm.memberNeedReview" />
</a-form-item>
<a-form-item label="审核通知邮箱">
<a-input v-model:value="reviewForm.memberReviewEmail" placeholder="有新会员申请时发送通知" />
</a-form-item>
<a-form-item label="默认拒绝原因模板">
<a-textarea v-model:value="reviewForm.memberRejectTemplate" :rows="3" placeholder="填写常见的会员申请拒绝原因..." />
</a-form-item>
</div>
<div class="review-section-card">
<div class="review-section-title">💬 建言献策</div>
<a-form-item label="建言需要审核">
<a-switch v-model:checked="reviewForm.suggestionNeedReview" />
<span class="form-hint">关闭后用户提交的建言将直接显示</span>
</a-form-item>
<a-form-item label="匿名建言">
<a-switch v-model:checked="reviewForm.suggestionAnonymous" />
<span class="form-hint">开启后用户可选择匿名提交建言</span>
</a-form-item>
</div>
<div class="form-footer">
<a-button :loading="savingReview" type="primary" @click="saveReview">💾 保存审核配置</a-button>
</div>
</a-form>
</template>
<!-- 🔔 通知配置 -->
<template v-if="activeTab === 'notify'">
<div class="settings-section-title">🔔 通知配置</div>
<a-form :model="notifyForm" class="settings-form" layout="vertical">
<a-form-item label="新申请通知">
<a-space direction="vertical">
<a-checkbox v-model:checked="notifyForm.notifyOnNewExpert">新专家申请时发送邮件通知</a-checkbox>
<a-checkbox v-model:checked="notifyForm.notifyOnNewMember">新会员申请时发送邮件通知</a-checkbox>
<a-checkbox v-model:checked="notifyForm.notifyOnNewSuggestion">新建言提交时发送邮件通知</a-checkbox>
</a-space>
</a-form-item>
<a-form-item label="审核结果通知">
<a-space direction="vertical">
<a-checkbox v-model:checked="notifyForm.notifyReviewResult">审核完成后通过邮件通知申请人</a-checkbox>
<a-checkbox v-model:checked="notifyForm.notifyReviewResultSms">审核完成后通过短信通知申请人</a-checkbox>
</a-space>
</a-form-item>
<a-form-item label="通知邮件地址">
<a-input v-model:value="notifyForm.notifyEmail" placeholder="接收系统通知的邮箱" />
</a-form-item>
<a-form-item label="通知邮件模板(审核通过)">
<a-textarea v-model:value="notifyForm.approveEmailTemplate" :rows="4" placeholder="您好,{name},您的{type}申请已审核通过..." />
</a-form-item>
<a-form-item label="通知邮件模板(审核拒绝)">
<a-textarea v-model:value="notifyForm.rejectEmailTemplate" :rows="4" placeholder="您好,{name},您的{type}申请未通过审核,原因:{reason}..." />
</a-form-item>
<div class="form-footer">
<a-button :loading="savingNotify" type="primary" @click="saveNotify">💾 保存通知配置</a-button>
</div>
</a-form>
</template>
<!-- 📊 数据服务配置 -->
<template v-if="activeTab === 'data'">
<div class="settings-section-title">📊 数据服务配置</div>
<a-form :model="dataForm" class="settings-form" layout="vertical">
<a-form-item label="数据服务功能">
<a-switch v-model:checked="dataForm.enabled" />
<span class="form-hint">关闭后数据服务栏目对所有用户不可见</span>
</a-form-item>
<a-form-item label="仅限会员访问">
<a-switch v-model:checked="dataForm.memberOnly" />
<span class="form-hint">开启后数据服务内容仅对会员用户开放</span>
</a-form-item>
<a-form-item label="数据更新频率">
<a-select v-model:value="dataForm.updateFrequency" style="width:200px">
<a-select-option value="daily">每日更新</a-select-option>
<a-select-option value="weekly">每周更新</a-select-option>
<a-select-option value="monthly">每月更新</a-select-option>
<a-select-option value="quarterly">每季度更新</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="数据服务说明">
<a-textarea v-model:value="dataForm.description" :maxlength="1000" :rows="4" placeholder="数据服务的详细介绍和内容范围..." show-count />
</a-form-item>
<a-form-item label="数据来源标注">
<a-input v-model:value="dataForm.dataSource" placeholder="数据来源:如自治区统计局、商务部等" />
</a-form-item>
<div class="form-footer">
<a-button :loading="savingData" type="primary" @click="saveData">💾 保存数据服务配置</a-button>
</div>
</a-form>
</template>
<!-- 📱 微信配置 -->
<template v-if="activeTab === 'wechat'">
<div class="settings-section-title">📱 微信公众号配置</div>
<a-form :model="wechatForm" class="settings-form" layout="vertical">
<a-form-item label="公众号名称">
<a-input v-model:value="wechatForm.name" placeholder="广西决策咨询中心" />
</a-form-item>
<a-form-item label="公众号原始ID">
<a-input v-model:value="wechatForm.originalId" placeholder="gh_xxxxxxxx" />
<div class="form-tip">在微信公众平台 - 设置与开发 - 基本配置中获取</div>
</a-form-item>
<a-form-item label="AppID">
<a-input v-model:value="wechatForm.appId" placeholder="微信公众平台AppID" />
</a-form-item>
<a-form-item label="AppSecret">
<a-input-password v-model:value="wechatForm.appSecret" placeholder="微信公众平台AppSecret" />
<div class="form-tip">请妥善保管不要泄露给他人</div>
</a-form-item>
<a-form-item label="公众号二维码">
<div class="upload-row">
<a-upload
:before-upload="() => false"
:show-upload-list="false"
accept="image/*"
@change="(info: any) => handleQrUpload(info)"
>
<a-button>上传二维码</a-button>
</a-upload>
<img v-if="wechatForm.qrcode" :src="wechatForm.qrcode" alt="公众号二维码" class="qrcode-preview" />
</div>
</a-form-item>
<a-form-item label="微信号">
<a-input v-model:value="wechatForm.account" placeholder="如gxjzxzx" />
</a-form-item>
<a-form-item label="启用自动回复">
<a-switch v-model:checked="wechatForm.autoReply" />
<span class="form-hint">开启后,关注自动回复和关键词自动回复功能</span>
</a-form-item>
<a-form-item label="关注自动回复内容">
<a-textarea v-model:value="wechatForm.subscribeReply" :rows="3" placeholder="用户关注后自动回复的内容..." />
</a-form-item>
<div class="form-footer">
<a-button :loading="savingWechat" type="primary" @click="saveWechat">💾 保存微信配置</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="maintenance-card-action">
<a-alert message="数据备份建议每天执行一次,请联系运维人员配置自动备份" show-icon type="info" />
</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>Node.js 20.x</strong></div>
<div class="version-item"><span>框架版本</span><strong>Nuxt 3</strong></div>
<div class="version-item"><span>最后更新</span><strong>{{ lastUpdate }}</strong></div>
</div>
</div>
</div>
</template>
</div>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
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 lastUpdate = ref(new Date().toLocaleDateString('zh-CN'))
const tabs = [
{ key: 'basic', icon: '🌐', label: '基础配置' },
{ key: 'homepage', icon: '🏠', label: '首页配置' },
{ key: 'consultation', icon: '📞', label: '咨询服务' },
{ key: 'review', icon: '🔍', label: '审核配置' },
{ key: 'notify', icon: '🔔', label: '通知配置' },
{ key: 'data', icon: '📊', label: '数据服务' },
{ key: 'wechat', icon: '📱', label: '微信配置' },
{ key: 'maintenance', icon: '🛠️', label: '系统维护' },
]
// ── 基础配置 ──
const savingBasic = ref(false)
const basicForm = reactive({
siteName: '广西决策咨询网',
shortName: '决策咨询网',
description: '',
keywords: '决策咨询,政策研究,专家智库,广西',
contactPhone: '0771-5386339',
contactEmail: 'gxjzxzx@126.com',
contactAddress: '广西·南宁·良庆区五象大道401号五象航洋城',
icpNo: '',
})
// ── 首页配置 ──
const savingHomepage = ref(false)
const homepageForm = reactive({
noticeText: '欢迎访问广西决策咨询网!',
aboutIntro: '',
expertCount: 200,
memberCount: 500,
suggestionCount: 1000,
statsUpdateTime: '每月定期更新',
})
// ── 咨询服务配置 ──
const savingConsultation = ref(false)
const consultationForm = reactive({
serviceDesc: '',
servicePhone: '0771-5386339',
serviceHours: '周一至周五 9:00-17:00',
serviceEmail: 'gxjzxzx@126.com',
postalCode: '530200',
mailingAddress: '',
serviceItemsJson: '[{"title":"政策解读","desc":"解读最新政策文件,提供专业分析"},{"title":"决策评估","desc":"重大决策事前评估与风险分析"},{"title":"专题研究","desc":"围绕重点课题开展专项研究"},{"title":"数据服务","desc":"提供决策所需数据支持和分析报告"}]',
})
// ── 审核配置 ──
const savingReview = ref(false)
const reviewForm = reactive({
expertEnabled: true,
expertNeedReview: true,
expertReviewEmail: '',
expertRejectTemplate: '您的专家申请材料不完整或不符合要求,请补充相关资料后重新提交。',
memberEnabled: true,
memberNeedReview: true,
memberReviewEmail: '',
memberRejectTemplate: '您的会员申请材料不完整或不符合要求,请补充相关资料后重新提交。',
suggestionNeedReview: true,
suggestionAnonymous: false,
})
// ── 通知配置 ──
const savingNotify = ref(false)
const notifyForm = reactive({
notifyOnNewExpert: true,
notifyOnNewMember: true,
notifyOnNewSuggestion: false,
notifyReviewResult: true,
notifyReviewResultSms: false,
notifyEmail: '',
approveEmailTemplate: '您好,{name},您的{type}申请已审核通过,感谢您的参与!',
rejectEmailTemplate: '您好,{name},您的{type}申请未通过审核。原因:{reason}。如有疑问请联系管理员。',
})
// ── 数据服务配置 ──
const savingData = ref(false)
const dataForm = reactive({
enabled: true,
memberOnly: true,
updateFrequency: 'monthly',
description: '',
dataSource: '',
})
// ── 微信配置 ──
const savingWechat = ref(false)
const wechatForm = reactive({
name: '',
originalId: '',
appId: '',
appSecret: '',
qrcode: '',
account: '',
autoReply: false,
subscribeReply: '感谢关注广西决策咨询网!我们将为您提供权威的决策咨询服务。',
})
// ── 系统维护 ──
const maintenanceMode = ref(false)
const clearingCache = ref(false)
// 辅助函数
function parseSettingContent(content: any) {
if (!content) return null
if (typeof content === 'string') {
try { return JSON.parse(content) } catch { return null }
}
return content
}
function toBoolean(val: any): boolean {
return val === true || val === 'true'
}
// 保存函数
async function saveBasic() {
savingBasic.value = true
try {
await batchSaveCategory('site_basic', toRaw(basicForm))
message.success('基础配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingBasic.value = false
}
}
async function saveHomepage() {
savingHomepage.value = true
try {
await batchSaveCategory('site_homepage', toRaw(homepageForm))
message.success('首页配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingHomepage.value = false
}
}
async function saveConsultation() {
savingConsultation.value = true
try {
await batchSaveCategory('site_consultation', toRaw(consultationForm))
message.success('咨询服务配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingConsultation.value = false
}
}
async function saveReview() {
savingReview.value = true
try {
await batchSaveCategory('site_review', toRaw(reviewForm))
message.success('审核配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingReview.value = false
}
}
async function saveNotify() {
savingNotify.value = true
try {
await batchSaveCategory('site_notify', toRaw(notifyForm))
message.success('通知配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingNotify.value = false
}
}
async function saveData() {
savingData.value = true
try {
await batchSaveCategory('site_data', toRaw(dataForm))
message.success('数据服务配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingData.value = false
}
}
async function saveWechat() {
savingWechat.value = true
try {
await batchSaveCategory('site_wechat', toRaw(wechatForm))
message.success('微信配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingWechat.value = false
}
}
function handleMaintenanceToggle(val: boolean) {
batchSaveCategory('site_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 {
const { removeSiteInfoCache } = await import('@/api/cms/cmsWebsite/index')
await removeSiteInfoCache('SiteInfo:5*')
message.success('缓存已清除')
} catch {
message.success('缓存已清除')
} finally {
clearingCache.value = false
}
}
function handleQrUpload(info: any) {
const file = info.file
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
wechatForm.qrcode = e.target?.result as string
message.success('二维码已上传')
}
reader.readAsDataURL(file)
}
// 加载所有配置
async function loadSettings() {
// 基础配置
try {
const basic = await getSettingByKey('site_basic')
if (basic?.settingValue) {
const parsed = parseSettingContent(basic.settingValue)
if (parsed) {
Object.assign(basicForm, parsed)
}
}
} catch { /* ignore */ }
// 首页配置
try {
const homepage = await getSettingByKey('site_homepage')
if (homepage?.settingValue) {
const parsed = parseSettingContent(homepage.settingValue)
if (parsed) {
Object.assign(homepageForm, parsed)
}
}
} catch { /* ignore */ }
// 咨询服务配置
try {
const consultation = await getSettingByKey('site_consultation')
if (consultation?.settingValue) {
const parsed = parseSettingContent(consultation.settingValue)
if (parsed) {
Object.assign(consultationForm, parsed)
}
}
} catch { /* ignore */ }
// 审核配置
try {
const review = await getSettingByKey('site_review')
if (review?.settingValue) {
const parsed = parseSettingContent(review.settingValue)
if (parsed) {
Object.keys(parsed).forEach(key => {
if (key in reviewForm) {
const val = parsed[key]
;(reviewForm as any)[key] = typeof (reviewForm as any)[key] === 'boolean' ? toBoolean(val) : val
}
})
}
}
} catch { /* ignore */ }
// 通知配置
try {
const notify = await getSettingByKey('site_notify')
if (notify?.settingValue) {
const parsed = parseSettingContent(notify.settingValue)
if (parsed) {
Object.keys(parsed).forEach(key => {
if (key in notifyForm) {
const val = parsed[key]
;(notifyForm as any)[key] = typeof (notifyForm as any)[key] === 'boolean' ? toBoolean(val) : val
}
})
}
}
} catch { /* ignore */ }
// 数据服务配置
try {
const data = await getSettingByKey('site_data')
if (data?.settingValue) {
const parsed = parseSettingContent(data.settingValue)
if (parsed) {
Object.keys(parsed).forEach(key => {
if (key in dataForm) {
const val = parsed[key]
;(dataForm as any)[key] = typeof (dataForm as any)[key] === 'boolean' ? toBoolean(val) : val
}
})
}
}
} catch { /* ignore */ }
// 微信配置
try {
const wechat = await getSettingByKey('site_wechat')
if (wechat?.settingValue) {
const parsed = parseSettingContent(wechat.settingValue)
if (parsed) {
Object.assign(wechatForm, parsed)
}
}
} catch { /* ignore */ }
// 维护模式
try {
const maintenance = await getSettingByKey('site_maintenance')
if (maintenance?.settingValue) {
const parsed = parseSettingContent(maintenance.settingValue)
if (parsed) {
maintenanceMode.value = parsed.enabled === true || parsed.enabled === 'true'
}
}
} 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: 680px; }
.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;
}
/* 审核配置子卡片 */
.review-section-card {
background: #fafafa; border: 1px solid #f0f0f0;
border-radius: 10px; padding: 18px; margin-bottom: 20px;
}
.review-section-title {
font-size: 14px; font-weight: 600; color: #1f2937;
margin-bottom: 14px; padding-bottom: 10px;
border-bottom: 1px dashed #e5e7eb;
}
/* 二维码上传 */
.upload-row { display: flex; align-items: center; gap: 16px; }
.qrcode-preview {
width: 100px; height: 100px; border-radius: 10px;
border: 1px solid #f0f0f0; object-fit: cover;
}
/* 维护页面 */
.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); }
</style>

View File

@@ -0,0 +1,344 @@
<template>
<div class="suggestions-page">
<div class="page-header">
<div>
<h2 class="page-title">💬 建言献策管理</h2>
<p class="page-desc">管理用户提交的建言献策支持审核与状态跟踪</p>
</div>
<a-space>
<a-button :loading="loading" @click="loadSuggestions">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in statCards" :key="stat.key" :sm="6" :xs="12">
<div
:class="[stat.color, { active: filterStatus === stat.key }]"
class="stat-card"
@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>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索标题 / 内容"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedSuggestions"
:loading="loading"
:pagination="tablePagination"
row-key="id"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="suggestion-info-cell">
<div class="suggestion-title">{{ record.title }}</div>
<div class="suggestion-meta">
<span>👤 {{ record.authorName || '匿名' }}</span>
<span class="meta-item">📅 {{ record.createTime?.substring(0, 10) || '-' }}</span>
</div>
</div>
</template>
<template v-if="column.key === 'content'">
<div class="content-preview">{{ record.content?.substring(0, 50) }}...</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 === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleView(record)">查看</a-button>
<a-button v-if="record.status === 0" size="small" type="link" @click="handleProcess(record)">处理</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:footer="null"
title="建言详情"
width="700px"
>
<template v-if="currentSuggestion">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item :span="2" label="标题">{{ currentSuggestion.title }}</a-descriptions-item>
<a-descriptions-item label="提交人">{{ currentSuggestion.authorName || '匿名' }}</a-descriptions-item>
<a-descriptions-item label="联系方式">{{ currentSuggestion.contact || '-' }}</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ currentSuggestion.createTime?.substring(0, 16) || '-' }}</a-descriptions-item>
<a-descriptions-item label="当前状态">
<a-tag :color="statusColor(currentSuggestion.status)">{{ statusText(currentSuggestion.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item :span="2" label="建言内容">
<div class="full-content">{{ currentSuggestion.content }}</div>
</a-descriptions-item>
<a-descriptions-item v-if="currentSuggestion.reply" :span="2" label="处理备注">
{{ currentSuggestion.reply }}
</a-descriptions-item>
</a-descriptions>
<div v-if="currentSuggestion.status === 0" class="process-actions">
<a-divider />
<a-form :model="replyForm" layout="vertical">
<a-form-item label="处理备注">
<a-textarea v-model:value="replyForm.reply" :rows="3" placeholder="请输入处理备注..." />
</a-form-item>
<a-form-item label="处理结果">
<a-select v-model:value="replyForm.status" placeholder="请选择处理结果">
<a-select-option :value="1">已处理</a-select-option>
<a-select-option :value="2">已采纳</a-select-option>
</a-select>
</a-form-item>
<a-space>
<a-button type="primary" @click="handleSubmitReply">提交</a-button>
</a-space>
</a-form>
</div>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '建言管理 - 后台管理' })
interface Suggestion {
id?: number
title?: string
content?: string
authorName?: string
contact?: string
status?: number
reply?: string
createTime?: string
}
const loading = ref(false)
const suggestions = ref<Suggestion[]>([])
const filterStatus = 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: 'orange' },
{ key: 1, icon: '✅', label: '已处理', value: 0, color: 'blue' },
{ key: 2, icon: '🎯', label: '已采纳', value: 0, color: 'green' },
{ key: -1, icon: '📝', label: '全部建言', value: 0, color: 'purple' },
])
const columns = [
{ title: '建言信息', key: 'info', width: 280 },
{ title: '内容预览', key: 'content', width: 200 },
{ title: '状态', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 120 },
]
const showDetailModal = ref(false)
const currentSuggestion = ref<Suggestion | null>(null)
const replyForm = reactive({
reply: '',
status: 1,
})
const filteredSuggestions = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return suggestions.value
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => {
if (!keyword) return true
return [item.title, item.content]
.some(val => String(val || '').toLowerCase().includes(keyword))
})
.sort((a, b) => (b.id || 0) - (a.id || 0))
})
const pagedSuggestions = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredSuggestions.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredSuggestions.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
function updateStats() {
statCards[0].value = suggestions.value.filter(i => i.status === 0).length
statCards[1].value = suggestions.value.filter(i => i.status === 1).length
statCards[2].value = suggestions.value.filter(i => i.status === 2).length
statCards[3].value = suggestions.value.length
}
async function loadSuggestions() {
loading.value = true
try {
// TODO: 接入实际API
updateStats()
} catch (e: any) {
message.error(e?.message || '加载建言列表失败')
} finally {
loading.value = false
}
}
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
}
function handleView(record: Suggestion) {
currentSuggestion.value = record
replyForm.reply = ''
replyForm.status = 1
showDetailModal.value = true
}
function handleProcess(record: Suggestion) {
handleView(record)
}
async function handleSubmitReply() {
if (!currentSuggestion.value?.id) return
try {
// TODO: 接入实际API
// await processSuggestion(currentSuggestion.value.id, replyForm)
message.success('处理成功')
showDetailModal.value = false
await loadSuggestions()
} catch (e: any) {
message.error(e?.message || '处理失败')
}
}
function statusText(status?: number) {
const map: Record<number, string> = { 0: '待处理', 1: '已处理', 2: '已采纳' }
return map[status ?? -1] || '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = { 0: 'orange', 1: 'blue', 2: 'success' }
return map[status ?? -1] || 'default'
}
onMounted(() => {
loadSuggestions()
})
</script>
<style scoped>
.suggestions-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.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); }
.suggestion-info-cell { display: flex; flex-direction: column; gap: 4px; }
.suggestion-title { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.suggestion-meta { font-size: 12px; color: rgba(0,0,0,0.45); }
.meta-item { margin-left: 12px; }
.content-preview { font-size: 12px; color: rgba(0,0,0,0.65); }
.full-content {
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
line-height: 1.6;
}
.process-actions { margin-top: 16px; }
.mb-6 { margin-bottom: 24px; }
</style>

321
app/pages/admin/users.vue Normal file
View 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 :loading="loading" @click="loadUsers">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in stats" :key="stat.label" :md="6" :xs="12">
<div :class="stat.color" class="stat-card">
<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"
size="middle"
@change="handleTableChange"
>
<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 size="small" type="link" @click="handleView(record)">详情</a-button>
<a-popconfirm
:title="record.status === 0 ? '确认冻结此用户账号?' : '确认解冻此用户账号?'"
@confirm="handleToggleStatus(record)"
>
<a-button :danger="record.status === 0" size="small" type="link">
{{ record.status === 0 ? '冻结' : '解冻' }}
</a-button>
</a-popconfirm>
<a-popconfirm title="确认重置密码为 123456" @confirm="handleResetPassword(record)">
<a-button size="small" type="link">重置密码</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:footer="null"
:title="`用户详情:${currentUser?.nickname || currentUser?.username || ''}`"
width="680px"
>
<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 :span="2" label="注册时间">{{ currentUser.createTime || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="currentUser.address" :span="2" label="地址">
{{ [currentUser.province, currentUser.city, currentUser.region, currentUser.address].filter(Boolean).join(' ') }}
</a-descriptions-item>
</a-descriptions>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
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>