feat(user): 支持修改当前用户资料接口并更新个人中心调用

- 新增 updateSelfUser 方法,用于修改当前登录用户的个人资料
- 个人中心调用接口由原 updateUser 切换为 updateSelfUser,避免学生账号权限不足导致的保存失败
- 更新了头像保存、个人信息保存等相关调用代码,添加异常捕获和提示
- 修正环境变量 .env.development 中 API URL 配置为生产地址
- 优化审核记录排序逻辑,新增获取最新审核记录和编辑入口显示判断函数
- 调整审核状态标签使用统一排序规则,防止显示和编辑状态不一致
- 新增社团活动、社团编辑、志愿时长录入、学分记录编辑、社会实践学分申报、青马学员编辑等多个新增组件及表单功能
- 各新增功能支持表单验证、操作反馈和数据加载优化
This commit is contained in:
2026-11-05 19:03:40 +08:00
parent 1c3ea8ec77
commit 567e4aabdb
56 changed files with 8941 additions and 20 deletions
+190
View File
@@ -0,0 +1,190 @@
<!-- 发起社团活动 -->
<template>
<ele-modal
:width="720"
:visible="visible"
:maskClosable="false"
:confirm-loading="loading"
title="发起社团活动"
:body-style="{ paddingBottom: '24px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属社团" name="clubId">
<a-select
v-model:value="form.clubId"
show-search
option-filter-prop="label"
:options="clubOptions"
:loading="clubLoading"
placeholder="请选择社团"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="活动名称" name="title">
<a-input v-model:value="form.title" allow-clear placeholder="请输入活动名称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="活动类型" name="category">
<a-select
v-model:value="form.category"
allow-clear
:options="categoryOptions"
placeholder="请选择活动类型"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="活动地点" name="place">
<a-input v-model:value="form.place" allow-clear placeholder="请输入活动地点" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="开始时间" name="startTime">
<a-date-picker
v-model:value="startTime"
show-time
value-format="YYYY-MM-DD HH:mm:ss"
class="ele-fluid"
placeholder="请选择开始时间"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="结束时间" name="endTime">
<a-date-picker
v-model:value="endTime"
show-time
value-format="YYYY-MM-DD HH:mm:ss"
class="ele-fluid"
placeholder="请选择结束时间"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="预计参与人数" name="scale">
<a-input-number v-model:value="form.scale" :min="0" class="ele-fluid" placeholder="请输入人数" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="预算金额" name="budget">
<a-input-number v-model:value="form.budget" :min="0" :step="100" class="ele-fluid" placeholder="请输入预算" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="活动方案" name="content">
<a-textarea v-model:value="form.content" :rows="4" allow-clear placeholder="请简述活动方案" />
</a-form-item>
<a-form-item label="活动材料" name="attachments">
<MultiFileUpload v-model="attachments" :max-count="5" />
</a-form-item>
<a-form-item label="提交方式" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">提交审核</a-radio>
<a-radio :value="0">保存草稿</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import { listClubs, submitClubActivity } from '@/api/growth/club';
import type { ClubActivity } from '@/api/growth/club';
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const formRef = ref();
const loading = ref(false);
const form = reactive<ClubActivity>({});
const startTime = ref<any>();
const endTime = ref<any>();
const attachments = ref('');
const clubLoading = ref(false);
const clubList = ref<{ label: string; value: number }[]>([]);
const clubOptions = computed(() => clubList.value);
const categoryOptions = [
{ label: '社团日常活动', value: '社团日常活动' },
{ label: '志愿服务', value: '志愿服务' },
{ label: '学术讲座', value: '学术讲座' },
{ label: '文体比赛', value: '文体比赛' },
{ label: '社会实践活动', value: '社会实践活动' }
];
const rules = {
clubId: [{ required: true, message: '请选择社团', trigger: 'change' }],
title: [{ required: true, message: '请输入活动名称', trigger: 'blur' }],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }]
};
const loadClubs = async () => {
clubLoading.value = true;
try {
const list = await listClubs({ status: 1 });
clubList.value = list
.filter((item) => item.id)
.map((item) => ({ label: item.clubName || '', value: item.id as number }));
} catch (e: any) {
message.error(e?.message || '加载社团失败');
} finally {
clubLoading.value = false;
}
};
watch(
() => props.visible,
(value) => {
if (!value) return;
Object.keys(form).forEach((key) => delete (form as any)[key]);
startTime.value = undefined;
endTime.value = undefined;
attachments.value = '';
form.status = 1;
loadClubs();
}
);
const updateVisible = (value: boolean) => emit('update:visible', value);
const save = () => {
formRef.value
?.validate?.()
.then(async () => {
loading.value = true;
try {
form.startTime = startTime.value;
form.endTime = endTime.value;
form.attachments = attachments.value || undefined;
const msg = await submitClubActivity(form);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e?.message || '提交失败');
} finally {
loading.value = false;
}
})
.catch(() => undefined);
};
</script>
+203
View File
@@ -0,0 +1,203 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1300 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-button type="primary" @click="openEdit()">
<template #icon><plus-outlined /></template>
发起活动
</a-button>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="活动名称/社团/地点"
@search="reload"
@pressEnter="reload"
/>
<a-select
v-model:value="status"
allow-clear
style="width: 140px"
placeholder="状态"
:options="statusOptions"
@change="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'budget'">
{{ record.budget != null ? `¥${record.budget}` : '-' }}
</template>
<template v-else-if="column.key === 'attachments'">
<AttachmentList :value="record.attachments" :max-count="2" />
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">查看</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除这个活动吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<ActivityEdit v-model:visible="showEdit" @done="reload" />
<a-modal
v-model:visible="showDetail"
title="活动详情"
:footer="null"
width="720px"
>
<a-descriptions v-if="current" :column="2" bordered size="small">
<a-descriptions-item label="活动名称" :span="2">{{ current.title }}</a-descriptions-item>
<a-descriptions-item label="所属社团">{{ current.clubName || '-' }}</a-descriptions-item>
<a-descriptions-item label="活动类型">{{ current.category || '-' }}</a-descriptions-item>
<a-descriptions-item label="开始时间" :span="2">{{ current.startTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="结束时间" :span="2">{{ current.endTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="活动地点">{{ current.place || '-' }}</a-descriptions-item>
<a-descriptions-item label="参与人数">{{ current.scale ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="预算">{{ current.budget ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(current.status)">{{ statusLabel(current.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="活动方案" :span="2">{{ current.content || '-' }}</a-descriptions-item>
<a-descriptions-item label="材料" :span="2">
<AttachmentList :value="current.attachments" />
</a-descriptions-item>
<a-descriptions-item label="公章识别" :span="2">
{{ current.ocrStatus === 1 ? current.ocrOrg || '已识别' : current.ocrStatus === 2 ? '识别失败' : '未识别' }}
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import ActivityEdit from './components/edit.vue';
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
import { pageClubActivities, removeClubActivity } from '@/api/growth/club';
import type { ClubActivity } from '@/api/growth/club';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const status = ref<number>();
const showEdit = ref(false);
const showDetail = ref(false);
const current = ref<ClubActivity | null>(null);
const statusOptions = [
{ label: '草稿', value: 0 },
{ label: '待审核', value: 1 },
{ label: '通过', value: 2 },
{ label: '驳回', value: 3 },
{ label: '作废', value: 4 }
];
const columns = ref<ColumnItem[]>([
{ title: '活动名称', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '所属社团', dataIndex: 'clubName', key: 'clubName', width: 160, ellipsis: true },
{ title: '活动类型', dataIndex: 'category', key: 'category', width: 110 },
{
title: '开始时间',
dataIndex: 'startTime',
key: 'startTime',
width: 160,
align: 'center',
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm')
},
{ title: '地点', dataIndex: 'place', key: 'place', width: 160, ellipsis: true },
{ title: '人数', dataIndex: 'scale', key: 'scale', width: 80, align: 'center' },
{ title: '预算', key: 'budget', width: 100, align: 'center' },
{ title: '材料', key: 'attachments', width: 140 },
{ title: '状态', key: 'status', width: 100, align: 'center' },
{ title: '操作', key: 'action', width: 120, fixed: 'right', align: 'center', hideInSetting: true }
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageClubActivities({
...where,
...orders,
page,
limit,
status: status.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
searchText.value = '';
status.value = undefined;
reload();
};
const openEdit = () => {
showEdit.value = true;
};
const openDetail = (record: ClubActivity) => {
current.value = record;
showDetail.value = true;
};
const remove = async (record: ClubActivity) => {
if (!record.id) return;
try {
const msg = await removeClubActivity(record.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const statusLabel = (value?: number) => {
const map: Record<number, string> = {
0: '草稿',
1: '待审核',
2: '通过',
3: '驳回',
4: '作废'
};
return map[value ?? 0] ?? '-';
};
const statusColor = (value?: number) => {
if (value === 2) return 'green';
if (value === 3) return 'red';
if (value === 4) return 'default';
return 'orange';
};
</script>
+202
View File
@@ -0,0 +1,202 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="status"
allow-clear
style="width: 140px"
placeholder="审核状态"
:options="statusOptions"
@change="reload"
/>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="活动名称/社团/地点"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'attachments'">
<AttachmentList :value="record.attachments" :max-count="2" />
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openReview(record)">审核</a>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<a-modal
v-model:visible="showReview"
title="社团活动审批"
:confirm-loading="submitting"
width="760px"
@ok="submitReview"
>
<template v-if="current">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="活动名称" :span="2">{{ current.title }}</a-descriptions-item>
<a-descriptions-item label="所属社团">{{ current.clubName || '-' }}</a-descriptions-item>
<a-descriptions-item label="活动类型">{{ current.category || '-' }}</a-descriptions-item>
<a-descriptions-item label="开始时间">{{ current.startTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="结束时间">{{ current.endTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="活动地点">{{ current.place || '-' }}</a-descriptions-item>
<a-descriptions-item label="参与人数">{{ current.scale ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="预算">{{ current.budget ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="活动方案" :span="2">
{{ current.content || '-' }}
</a-descriptions-item>
<a-descriptions-item label="材料" :span="2">
<AttachmentList :value="current.attachments" />
</a-descriptions-item>
</a-descriptions>
<a-alert
type="info"
show-icon
style="margin-top: 16px"
message="审批顺序:指导老师 → 二级学院团委/业务指导单位 → 校团委社团管理部 → 校团委领导。当前账号可对本人节点审批。"
/>
<a-form layout="vertical" style="margin-top: 16px">
<a-form-item label="审核结果" required>
<a-radio-group v-model:value="reviewStatus">
<a-radio :value="2">通过</a-radio>
<a-radio :value="3">驳回</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="审核意见" :required="reviewStatus === 3">
<a-textarea v-model:value="reviewRemark" :rows="3" placeholder="驳回时请说明原因" />
</a-form-item>
</a-form>
</template>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
import { pageClubActivities, reviewClubActivity } from '@/api/growth/club';
import type { ClubActivity } from '@/api/growth/club';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const status = ref<number>(1);
const searchText = ref('');
const showReview = ref(false);
const submitting = ref(false);
const current = ref<ClubActivity | null>(null);
const reviewStatus = ref(2);
const reviewRemark = ref('');
const statusOptions = [
{ label: '待审核', value: 1 },
{ label: '通过', value: 2 },
{ label: '驳回', value: 3 }
];
const columns = ref<ColumnItem[]>([
{ title: '活动名称', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '所属社团', dataIndex: 'clubName', key: 'clubName', width: 150, ellipsis: true },
{
title: '开始时间',
dataIndex: 'startTime',
key: 'startTime',
width: 160,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm')
},
{ title: '地点', dataIndex: 'place', key: 'place', width: 150, ellipsis: true },
{ title: '人数', dataIndex: 'scale', key: 'scale', width: 80, align: 'center' },
{ title: '材料', key: 'attachments', width: 130 },
{ title: '状态', key: 'status', width: 100, align: 'center' },
{ title: '操作', key: 'action', width: 90, fixed: 'right', align: 'center', hideInSetting: true }
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageClubActivities({
...where,
...orders,
page,
limit,
status: status.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
status.value = undefined;
searchText.value = '';
reload();
};
const openReview = (record: ClubActivity) => {
current.value = record;
reviewStatus.value = 2;
reviewRemark.value = '';
showReview.value = true;
};
const submitReview = async () => {
if (!current.value?.id) return;
if (reviewStatus.value === 3 && !reviewRemark.value.trim()) {
message.warning('驳回时请填写审核意见');
return;
}
submitting.value = true;
try {
const msg = await reviewClubActivity({
id: current.value.id,
status: reviewStatus.value
});
message.success(msg);
showReview.value = false;
reload();
} catch (e: any) {
message.error(e?.message || '审核失败');
} finally {
submitting.value = false;
}
};
const statusLabel = (value?: number) => {
const map: Record<number, string> = { 0: '草稿', 1: '待审核', 2: '通过', 3: '驳回', 4: '作废' };
return map[value ?? 0] ?? '-';
};
const statusColor = (value?: number) => {
if (value === 2) return 'green';
if (value === 3) return 'red';
return 'orange';
};
</script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" class="placeholder-card">
<a-result status="info" title="社团年审" sub-title="该功能规划在 P1 阶段实施">
<template #extra>
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
<div class="placeholder-desc">按指标配置自动取数(活动数、参与人次、稿件数),社团在线自评后审核公示。</div>
<div class="placeholder-sub">实施依赖:</div>
<ul class="placeholder-list">
<li>年审指标配置</li>
<li>自动取数规则</li>
<li>公示流程</li>
</ul>
</a-space>
</template>
</a-result>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
// 社团年审:P1 阶段交付,当前先占位避免菜单跳转 404
</script>
<style lang="less" scoped>
.placeholder-card {
min-height: calc(100vh - 180px);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-desc {
color: #595959;
line-height: 1.8;
}
.placeholder-sub {
margin-top: 4px;
font-weight: 600;
color: #262626;
}
.placeholder-list {
margin: 0;
padding-left: 20px;
color: #8c8c8c;
line-height: 1.9;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" class="placeholder-card">
<a-result status="info" title="社团评优" sub-title="该功能规划在 P1 阶段实施">
<template #extra>
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
<div class="placeholder-desc">社团与学生会的评优、评先线上申报、审核与公示。</div>
<div class="placeholder-sub">实施依赖:</div>
<ul class="placeholder-list">
<li>评优类型配置</li>
<li>申报材料模板</li>
<li>公示流程</li>
</ul>
</a-space>
</template>
</a-result>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
// 社团评优:P1 阶段交付,当前先占位避免菜单跳转 404
</script>
<style lang="less" scoped>
.placeholder-card {
min-height: calc(100vh - 180px);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-desc {
color: #595959;
line-height: 1.8;
}
.placeholder-sub {
margin-top: 4px;
font-weight: 600;
color: #262626;
}
.placeholder-list {
margin: 0;
padding-left: 20px;
color: #8c8c8c;
line-height: 1.9;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<!-- 社团编辑 -->
<template>
<ele-modal
:width="720"
:visible="visible"
:maskClosable="false"
:confirm-loading="loading"
:title="isUpdate ? '编辑社团' : '新增社团'"
:body-style="{ paddingBottom: '24px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="社团名称" name="clubName">
<a-input v-model:value="form.clubName" allow-clear placeholder="请输入社团名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="社团编码" name="clubCode">
<a-input v-model:value="form.clubCode" allow-clear placeholder="请输入社团编码" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="社团类别" name="category">
<a-select
v-model:value="form.category"
allow-clear
:options="categoryOptions"
mode="tags"
:max-tag-count="1"
placeholder="请选择或输入类别"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="社团等级" name="level">
<a-select
v-model:value="form.level"
allow-clear
:options="levelOptions"
placeholder="请选择等级"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="指导老师" name="advisorName">
<a-input v-model:value="form.advisorName" allow-clear placeholder="请输入指导老师姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="社长" name="presidentName">
<a-input v-model:value="form.presidentName" allow-clear placeholder="请输入社长姓名" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="社长电话" name="presidentPhone">
<a-input v-model:value="form.presidentPhone" allow-clear placeholder="请输入联系电话" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="成立日期" name="foundedDate">
<a-date-picker
v-model:value="foundedDate"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="请选择成立日期"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="社团简介" name="intro">
<a-textarea v-model:value="form.intro" :rows="4" allow-clear placeholder="请输入社团简介" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">正常</a-radio>
<a-radio :value="0">停用</a-radio>
<a-radio :value="2">注销</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { assignObject } from 'ele-admin-pro';
import { addClub, updateClub } from '@/api/growth/club';
import type { Club } from '@/api/growth/club';
const props = defineProps<{
visible: boolean;
data?: Club | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const form = reactive<Club>({});
const foundedDate = ref<any>();
const categoryOptions = [
{ label: '思想政治类', value: '思想政治类' },
{ label: '学术科技类', value: '学术科技类' },
{ label: '文化体育类', value: '文化体育类' },
{ label: '志愿公益类', value: '志愿公益类' },
{ label: '创新创业类', value: '创新创业类' }
];
const levelOptions = [
{ label: '校级', value: '校级' },
{ label: '院级', value: '院级' }
];
const rules = {
clubName: [{ required: true, message: '请输入社团名称', trigger: 'blur' }]
};
watch(
() => props.visible,
(value) => {
if (!value) return;
isUpdate.value = !!props.data?.id;
Object.keys(form).forEach((key) => delete (form as any)[key]);
foundedDate.value = undefined;
if (props.data) {
assignObject(form, props.data);
foundedDate.value = props.data.foundedDate ? dayjs(props.data.foundedDate) : undefined;
} else {
form.status = 1;
}
}
);
const updateVisible = (value: boolean) => emit('update:visible', value);
const save = () => {
formRef.value
?.validate?.()
.then(async () => {
loading.value = true;
try {
form.foundedDate = foundedDate.value
? dayjs(foundedDate.value).format('YYYY-MM-DD')
: undefined;
const msg = isUpdate.value ? await updateClub(form) : await addClub(form);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
loading.value = false;
}
})
.catch(() => undefined);
};
</script>
+297
View File
@@ -0,0 +1,297 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1300 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-button
v-if="hasPermission('gxmu:club:save')"
type="primary"
@click="openEdit()"
>
<template #icon><plus-outlined /></template>
新增社团
</a-button>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="社团名称/编码/负责人"
@search="reload"
@pressEnter="reload"
/>
<a-select
v-model:value="status"
allow-clear
style="width: 130px"
placeholder="状态"
:options="statusOptions"
@change="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'default'">
{{ record.status === 1 ? '正常' : record.status === 2 ? '注销' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openMembers(record)">成员</a>
<a-divider type="vertical" />
<a v-if="hasPermission('gxmu:club:update')" @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
v-if="hasPermission('gxmu:club:remove')"
title="确定要删除这个社团吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<ClubEdit v-model:visible="showEdit" :data="current" @done="reload" />
<a-drawer v-model:visible="showMembers" title="社团成员" width="720">
<div class="member-head">
<span>{{ current?.clubName }}</span>
<a-button type="primary" size="small" @click="showMemberEdit = true">
<template #icon><plus-outlined /></template>
添加成员
</a-button>
</div>
<a-table
row-key="id"
:columns="memberColumns"
:data-source="members"
:pagination="false"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'default'">
{{ record.status === 1 ? '在社' : '退出' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-popconfirm title="确定移除此成员吗?" @confirm="removeMember(record)">
<a class="ele-text-danger">移除</a>
</a-popconfirm>
</template>
</template>
</a-table>
</a-drawer>
<a-modal
v-model:visible="showMemberEdit"
title="添加社团成员"
:confirm-loading="memberSubmitting"
width="520px"
@ok="submitMember"
>
<a-form ref="memberFormRef" :model="memberForm" :rules="memberRules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="学号" name="studentNo">
<a-input v-model:value="memberForm.studentNo" allow-clear placeholder="请输入学号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="姓名" name="studentName">
<a-input v-model:value="memberForm.studentName" allow-clear placeholder="请输入姓名" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="社内职务" name="roleName">
<a-input v-model:value="memberForm.roleName" allow-clear placeholder="如 社长 / 部长 / 成员" />
</a-form-item>
<a-form-item label="加入时间" name="joinTime">
<a-date-picker
v-model:value="memberJoinTime"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="请选择加入时间"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { Form, message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import ClubEdit from './components/edit.vue';
import {
pageClubs,
removeClub,
listClubMembers,
addClubMember,
removeClubMember
} from '@/api/growth/club';
import type { Club, ClubMember } from '@/api/growth/club';
import { hasPermission } from '@/utils/permission';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const status = ref<number>();
const showEdit = ref(false);
const showMembers = ref(false);
const showMemberEdit = ref(false);
const current = ref<Club | null>(null);
const members = ref<ClubMember[]>([]);
const memberFormRef = ref();
const memberSubmitting = ref(false);
const memberJoinTime = ref<any>();
const memberForm = reactive<ClubMember>({});
const statusOptions = [
{ label: '正常', value: 1 },
{ label: '停用', value: 0 },
{ label: '注销', value: 2 }
];
const columns = ref<ColumnItem[]>([
{ title: '社团名称', dataIndex: 'clubName', key: 'clubName', width: 180 },
{ title: '编码', dataIndex: 'clubCode', key: 'clubCode', width: 130 },
{ title: '类别', dataIndex: 'category', key: 'category', width: 120 },
{ title: '指导老师', dataIndex: 'advisorName', key: 'advisorName', width: 110 },
{ title: '社长', dataIndex: 'presidentName', key: 'presidentName', width: 110 },
{ title: '成员数', dataIndex: 'memberCount', key: 'memberCount', width: 90, align: 'center' },
{ title: '等级', dataIndex: 'level', key: 'level', width: 100 },
{ title: '状态', key: 'status', width: 90, align: 'center' },
{ title: '操作', key: 'action', width: 180, fixed: 'right', align: 'center', hideInSetting: true }
]);
const memberColumns: ColumnItem[] = [
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 110 },
{ title: '职务', dataIndex: 'roleName', key: 'roleName', width: 110 },
{ title: '加入时间', dataIndex: 'joinTime', key: 'joinTime', width: 120 },
{ title: '状态', key: 'status', width: 90 },
{ title: '操作', key: 'action', width: 80 }
];
const memberRules = {
studentNo: [{ required: true, message: '请输入学号', trigger: 'blur' }],
studentName: [{ required: true, message: '请输入姓名', trigger: 'blur' }]
};
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageClubs({
...where,
...orders,
page,
limit,
status: status.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
searchText.value = '';
status.value = undefined;
reload();
};
const openEdit = (row?: Club) => {
current.value = row || null;
showEdit.value = true;
};
const remove = async (row: Club) => {
if (!row.id) return;
try {
const msg = await removeClub(row.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openMembers = async (row: Club) => {
current.value = row;
showMembers.value = true;
await loadMembers();
};
const loadMembers = async () => {
if (!current.value?.id) return;
try {
members.value = await listClubMembers(current.value.id);
} catch (e: any) {
message.error(e?.message || '加载成员失败');
}
};
const submitMember = () => {
memberFormRef.value
?.validate?.()
.then(async () => {
memberSubmitting.value = true;
try {
memberForm.clubId = current.value?.id;
memberForm.joinTime = memberJoinTime.value;
const msg = await addClubMember(memberForm);
message.success(msg);
showMemberEdit.value = false;
Object.keys(memberForm).forEach((key) => delete (memberForm as any)[key]);
memberJoinTime.value = undefined;
await loadMembers();
reload();
} catch (e: any) {
message.error(e?.message || '添加失败');
} finally {
memberSubmitting.value = false;
}
})
.catch(() => undefined);
};
const removeMember = async (row: ClubMember) => {
if (!row.id) return;
try {
const msg = await removeClubMember(row.id);
message.success(msg);
await loadMembers();
reload();
} catch (e: any) {
message.error(e?.message || '移除失败');
}
};
</script>
<style lang="less" scoped>
.member-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-weight: 600;
}
</style>
+425
View File
@@ -0,0 +1,425 @@
<template>
<div class="page">
<div class="ele-body">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="7">
<a-card :bordered="false" title="场地列表">
<a-spin :spinning="venueLoading">
<a-empty v-if="!venues.length" description="暂无可用场地" />
<div
v-for="item in venues"
:key="item.id"
class="venue-item"
:class="{ active: currentVenueId === item.id }"
@click="selectVenue(item)"
>
<div class="venue-name">{{ item.venueName }}</div>
<div class="venue-meta">
<span>{{ item.building || '' }}{{ item.roomNo ? ` ${item.roomNo}` : '' }}</span>
<span v-if="item.capacity">容纳 {{ item.capacity }} 人</span>
</div>
<div class="venue-time">
开放 {{ item.openStartTime || '08:00' }} - {{ item.openEndTime || '22:00' }}
</div>
</div>
</a-spin>
</a-card>
</a-col>
<a-col :xs="24" :lg="17">
<a-card :bordered="false">
<template #title>
<div class="date-bar">
<span>{{ currentVenue?.venueName || '请选择场地' }}</span>
<a-date-picker
v-model:value="bookingDate"
value-format="YYYY-MM-DD"
:allow-clear="false"
@change="loadSchedule"
/>
<a-button
type="primary"
:disabled="!currentVenueId"
@click="openBooking"
>
预约场地
</a-button>
</div>
</template>
<a-spin :spinning="scheduleLoading">
<a-empty v-if="!schedule.length" description="当天暂无预约" />
<div v-else class="schedule-list">
<div v-for="item in schedule" :key="item.id" class="schedule-item">
<div class="schedule-time">{{ item.startTime }} - {{ item.endTime }}</div>
<div class="schedule-main">
<div class="schedule-purpose">{{ item.purpose || '未填写事由' }}</div>
<div class="schedule-sub">
{{ item.clubName || item.applicantName || '-' }}
</div>
</div>
<a-tag :color="item.status === 1 ? 'green' : 'orange'">
{{ item.status === 1 ? '已通过' : '待审核' }}
</a-tag>
</div>
</div>
</a-spin>
</a-card>
<a-card :bordered="false" class="my-booking-card" title="我的预约">
<a-table
row-key="id"
size="small"
:columns="bookingColumns"
:data-source="myBookings"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="bookingStatusColor(record.status)">
{{ bookingStatusLabel(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-popconfirm
v-if="record.status === 0 || record.status === 1"
title="确定要取消这个预约吗?"
@confirm="cancel(record)"
>
<a class="ele-text-danger">取消</a>
</a-popconfirm>
<span v-else>-</span>
</template>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
<a-modal
v-model:visible="showBooking"
title="预约场地"
:confirm-loading="submitting"
width="560px"
@ok="submitBooking"
>
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
:message="`${currentVenue?.venueName || ''} · ${bookingDate}`"
/>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="开始时间" required>
<a-time-picker
v-model:value="startTime"
format="HH:mm"
value-format="HH:mm"
minute-step="15"
class="ele-fluid"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="结束时间" required>
<a-time-picker
v-model:value="endTime"
format="HH:mm"
value-format="HH:mm"
minute-step="15"
class="ele-fluid"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="使用事由" required>
<a-input v-model:value="purpose" allow-clear placeholder="请填写使用事由" />
</a-form-item>
<a-form-item label="使用社团">
<a-select
v-model:value="clubId"
allow-clear
show-search
option-filter-prop="label"
:options="clubOptions"
placeholder="可选"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="申请人">
<a-input v-model:value="applicantName" allow-clear placeholder="请输入申请人" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话">
<a-input v-model:value="applicantPhone" allow-clear placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="参与人数">
<a-input-number v-model:value="attendeeCount" :min="1" class="ele-fluid" />
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import dayjs from 'dayjs';
import { message } from 'ant-design-vue';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import {
listVenues,
listClubs,
getVenueSchedule,
pageVenueBookings,
createVenueBooking,
cancelVenueBooking
} from '@/api/growth/club';
import type { Venue, VenueBooking } from '@/api/growth/club';
const venues = ref<Venue[]>([]);
const venueLoading = ref(false);
const currentVenueId = ref<number>();
const currentVenue = computed(() =>
venues.value.find((item) => item.id === currentVenueId.value)
);
const bookingDate = ref(dayjs().format('YYYY-MM-DD'));
const schedule = ref<Record<string, any>[]>([]);
const scheduleLoading = ref(false);
const myBookings = ref<VenueBooking[]>([]);
const showBooking = ref(false);
const submitting = ref(false);
const startTime = ref('09:00');
const endTime = ref('11:00');
const purpose = ref('');
const clubId = ref<number>();
const clubOptions = ref<{ label: string; value: number }[]>([]);
const applicantName = ref('');
const applicantPhone = ref('');
const attendeeCount = ref(1);
const bookingColumns: ColumnItem[] = [
{ title: '场地', dataIndex: 'venueName', key: 'venueName', width: 140 },
{ title: '日期', dataIndex: 'bookingDate', key: 'bookingDate', width: 110 },
{ title: '时间', key: 'time', width: 130 },
{ title: '事由', dataIndex: 'purpose', key: 'purpose', ellipsis: true },
{ title: '状态', key: 'status', width: 90 },
{ title: '操作', key: 'action', width: 80 }
];
void MultiFileUpload;
const selectVenue = (venue: Venue) => {
currentVenueId.value = venue.id;
loadSchedule();
};
const loadVenues = async () => {
venueLoading.value = true;
try {
venues.value = await listVenues();
if (!currentVenueId.value && venues.value.length) {
currentVenueId.value = venues.value[0].id;
await loadSchedule();
}
} catch (e: any) {
message.error(e?.message || '加载场地失败');
} finally {
venueLoading.value = false;
}
};
const loadSchedule = async () => {
if (!currentVenueId.value) return;
scheduleLoading.value = true;
try {
schedule.value = await getVenueSchedule(currentVenueId.value, bookingDate.value);
} catch (e: any) {
schedule.value = [];
} finally {
scheduleLoading.value = false;
}
};
const loadMyBookings = async () => {
try {
const result = await pageVenueBookings({ page: 1, limit: 50, backendAccess: true });
myBookings.value = result.list || [];
} catch (e: any) {
myBookings.value = [];
}
};
const loadClubs = async () => {
try {
const list = await listClubs({ status: 1 });
clubOptions.value = list
.filter((item) => item.id)
.map((item) => ({ label: item.clubName || '', value: item.id as number }));
} catch (e: any) {
// 忽略
}
};
const openBooking = () => {
startTime.value = currentVenue.value?.openStartTime || '09:00';
endTime.value = currentVenue.value?.openEndTime || '11:00';
purpose.value = '';
clubId.value = undefined;
attendeeCount.value = 1;
showBooking.value = true;
};
const submitBooking = async () => {
if (!currentVenueId.value) return;
if (!purpose.value.trim()) {
message.warning('请填写使用事由');
return;
}
if (startTime.value >= endTime.value) {
message.warning('结束时间必须晚于开始时间');
return;
}
submitting.value = true;
try {
const msg = await createVenueBooking({
venueId: currentVenueId.value,
bookingDate: bookingDate.value,
startTime: startTime.value,
endTime: endTime.value,
purpose: purpose.value.trim(),
clubId: clubId.value,
applicantName: applicantName.value || undefined,
applicantPhone: applicantPhone.value || undefined,
attendeeCount: attendeeCount.value
});
message.success(msg);
showBooking.value = false;
await loadSchedule();
await loadMyBookings();
} catch (e: any) {
message.error(e?.message || '预约失败');
} finally {
submitting.value = false;
}
};
const cancel = async (record: VenueBooking) => {
if (!record.id) return;
try {
const msg = await cancelVenueBooking(record.id);
message.success(msg);
await loadSchedule();
await loadMyBookings();
} catch (e: any) {
message.error(e?.message || '取消失败');
}
};
const bookingStatusLabel = (value?: number) => {
if (value === 1) return '通过';
if (value === 2) return '驳回';
if (value === 3) return '已取消';
return '待审核';
};
const bookingStatusColor = (value?: number) => {
if (value === 1) return 'green';
if (value === 2) return 'red';
if (value === 3) return 'default';
return 'orange';
};
onMounted(() => {
loadVenues();
loadMyBookings();
loadClubs();
});
</script>
<style lang="less" scoped>
.venue-item {
padding: 14px 16px;
margin-bottom: 10px;
border: 1px solid #f0f0f0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.venue-item:hover,
.venue-item.active {
border-color: #1677ff;
background: #f0f7ff;
}
.venue-name {
font-weight: 600;
color: #262626;
}
.venue-meta {
display: flex;
gap: 12px;
margin-top: 4px;
font-size: 12px;
color: #8c8c8c;
}
.venue-time {
margin-top: 4px;
font-size: 12px;
color: #bfbfbf;
}
.date-bar {
display: flex;
align-items: center;
gap: 12px;
font-weight: 500;
}
.schedule-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.schedule-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.schedule-time {
width: 130px;
font-weight: 600;
color: #1677ff;
}
.schedule-main {
flex: 1;
min-width: 0;
}
.schedule-purpose {
color: #262626;
}
.schedule-sub {
font-size: 12px;
color: #8c8c8c;
}
.my-booking-card {
margin-top: 16px;
}
</style>
+202
View File
@@ -0,0 +1,202 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="status"
allow-clear
style="width: 140px"
placeholder="审核状态"
:options="statusOptions"
@change="reload"
/>
<a-range-picker v-model:value="dateRange" value-format="YYYY-MM-DD" @change="reload" />
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="事由/申请人/场地"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'time'">
{{ record.startTime }} - {{ record.endTime }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a v-if="record.status === 0" @click="openReview(record)">审核</a>
<span v-else>-</span>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<a-modal
v-model:visible="showReview"
title="场地预约审批"
:confirm-loading="submitting"
width="640px"
@ok="submitReview"
>
<a-descriptions v-if="current" :column="2" bordered size="small">
<a-descriptions-item label="场地" :span="2">{{ current.venueName }}</a-descriptions-item>
<a-descriptions-item label="日期">{{ current.bookingDate }}</a-descriptions-item>
<a-descriptions-item label="时间">
{{ current.startTime }} - {{ current.endTime }}
</a-descriptions-item>
<a-descriptions-item label="申请人">{{ current.applicantName || '-' }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ current.applicantPhone || '-' }}</a-descriptions-item>
<a-descriptions-item label="使用社团">{{ current.clubName || '-' }}</a-descriptions-item>
<a-descriptions-item label="参与人数">{{ current.attendeeCount ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="使用事由" :span="2">{{ current.purpose || '-' }}</a-descriptions-item>
</a-descriptions>
<a-form layout="vertical" style="margin-top: 16px">
<a-form-item label="审核结果" required>
<a-radio-group v-model:value="reviewStatus">
<a-radio :value="1">通过</a-radio>
<a-radio :value="2">驳回</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="审核意见" :required="reviewStatus === 2">
<a-textarea v-model:value="reviewRemark" :rows="3" placeholder="驳回时请说明原因" />
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
pageVenueBookings,
reviewVenueBooking
} from '@/api/growth/club';
import type { VenueBooking } from '@/api/growth/club';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const status = ref<number>(0);
const searchText = ref('');
const dateRange = ref<string[]>();
const showReview = ref(false);
const submitting = ref(false);
const current = ref<VenueBooking | null>(null);
const reviewStatus = ref(1);
const reviewRemark = ref('');
const statusOptions = [
{ label: '待审核', value: 0 },
{ label: '通过', value: 1 },
{ label: '驳回', value: 2 },
{ label: '已取消', value: 3 }
];
const columns = ref<ColumnItem[]>([
{ title: '场地', dataIndex: 'venueName', key: 'venueName', width: 150 },
{ title: '日期', dataIndex: 'bookingDate', key: 'bookingDate', width: 120 },
{ title: '时间', key: 'time', width: 130 },
{ title: '申请人', dataIndex: 'applicantName', key: 'applicantName', width: 110 },
{ title: '社团', dataIndex: 'clubName', key: 'clubName', width: 150, ellipsis: true },
{ title: '事由', dataIndex: 'purpose', key: 'purpose', ellipsis: true },
{ title: '状态', key: 'status', width: 100, align: 'center' },
{
title: '提交时间',
dataIndex: 'createTime',
key: 'createTime',
width: 160,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm')
},
{ title: '操作', key: 'action', width: 90, fixed: 'right', align: 'center', hideInSetting: true }
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageVenueBookings({
...where,
...orders,
page,
limit,
status: status.value,
bookingDateStart: dateRange.value?.[0],
bookingDateEnd: dateRange.value?.[1],
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
status.value = undefined;
searchText.value = '';
dateRange.value = undefined;
reload();
};
const openReview = (record: VenueBooking) => {
current.value = record;
reviewStatus.value = 1;
reviewRemark.value = '';
showReview.value = true;
};
const submitReview = async () => {
if (!current.value?.id) return;
if (reviewStatus.value === 2 && !reviewRemark.value.trim()) {
message.warning('驳回时请填写审核意见');
return;
}
submitting.value = true;
try {
const msg = await reviewVenueBooking({
id: current.value.id,
status: reviewStatus.value,
reviewRemark: reviewRemark.value.trim() || undefined
});
message.success(msg);
showReview.value = false;
reload();
} catch (e: any) {
message.error(e?.message || '审核失败');
} finally {
submitting.value = false;
}
};
const statusLabel = (value?: number) => {
if (value === 1) return '通过';
if (value === 2) return '驳回';
if (value === 3) return '已取消';
return '待审核';
};
const statusColor = (value?: number) => {
if (value === 1) return 'green';
if (value === 2) return 'red';
if (value === 3) return 'default';
return 'orange';
};
</script>
@@ -0,0 +1,173 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="年度志愿服务证明按「已通过审核」的志愿时长自动汇总生成。证书编号由后端按年度顺序生成,模板与电子章在 P1 阶段接入。"
/>
<a-space style="margin-bottom: 16px" wrap>
<a-select
v-model:value="year"
style="width: 140px"
:options="yearOptions"
@change="loadData"
/>
<a-input
v-model:value="studentNo"
allow-clear
style="width: 180px"
placeholder="学号(管理员)"
/>
<a-button type="primary" :loading="loading" @click="loadData">查询</a-button>
<a-button :disabled="!summary.totalHours" :loading="generating" @click="generate">
生成证明
</a-button>
</a-space>
<a-spin :spinning="loading">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="8">
<div class="metric-card">
<div class="metric-label">年度累计时长</div>
<div class="metric-value">{{ summary.totalHours ?? 0 }}</div>
<div class="metric-foot">仅统计已审核通过的记录</div>
</div>
</a-col>
<a-col :xs="24" :sm="8">
<div class="metric-card accent">
<div class="metric-label">志愿项目数</div>
<div class="metric-value">{{ projects.length }}</div>
<div class="metric-foot">按项目名称去重</div>
</div>
</a-col>
<a-col :xs="24" :sm="8">
<div class="metric-card warm">
<div class="metric-label">记录条数</div>
<div class="metric-value">{{ summary.approvedCount ?? 0 }}</div>
<div class="metric-foot">已通过的志愿记录</div>
</div>
</a-col>
</a-row>
<a-card :bordered="false" class="project-card" title="项目明细">
<a-empty v-if="!projects.length" description="该年度暂无可生成证明的志愿记录" />
<a-table
v-else
row-key="projectName"
size="small"
:data-source="projects"
:columns="projectColumns"
:pagination="false"
/>
</a-card>
</a-spin>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import dayjs from 'dayjs';
import { message } from 'ant-design-vue';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import {
getMyVolunteerSummary,
getMyVolunteerProjectStats,
getMyVolunteerTotal
} from '@/api/growth/volunteer';
const loading = ref(false);
const generating = ref(false);
const year = ref(dayjs().year());
const studentNo = ref('');
const summary = ref<Record<string, any>>({});
const projects = ref<Record<string, any>[]>([]);
const yearOptions = computed(() => {
const current = dayjs().year();
return Array.from({ length: 6 }, (_, index) => {
const value = current - index;
return { label: `${value} 年`, value };
});
});
const projectColumns: ColumnItem[] = [
{ title: '志愿项目', dataIndex: 'projectName', key: 'projectName' },
{ title: '组织单位', dataIndex: 'orgName', key: 'orgName', width: 260 },
{ title: '时长', dataIndex: 'hours', key: 'hours', width: 100, align: 'center' },
{ title: '记录数', dataIndex: 'count', key: 'count', width: 100, align: 'center' }
];
const loadData = async () => {
loading.value = true;
try {
const [summaryData, projectData, total] = await Promise.all([
getMyVolunteerSummary(),
getMyVolunteerProjectStats(year.value),
getMyVolunteerTotal(year.value)
]);
summary.value = { ...summaryData, totalHours: total ?? 0 };
projects.value = projectData || [];
} catch (e: any) {
message.error(e?.message || '加载志愿数据失败');
} finally {
loading.value = false;
}
};
const generate = () => {
generating.value = true;
// 证明文件由后端模板渲染,P0 先给出数据核对结果,文件下载在模板接入后开放
setTimeout(() => {
generating.value = false;
message.info(
`已汇总 ${year.value} 年 ${summary.value.totalHours ?? 0} 小时志愿时长,证明材料模板接入后可在此下载 PDF`
);
}, 300);
};
onMounted(loadData);
</script>
<style lang="less" scoped>
.metric-card {
padding: 18px 20px;
border-radius: 10px;
background: #fff;
border-left: 4px solid #1677ff;
}
.metric-card.accent {
border-left-color: #13c2c2;
}
.metric-card.warm {
border-left-color: #faad14;
}
.metric-label {
font-size: 13px;
color: #8c8c8c;
}
.metric-value {
margin: 6px 0 4px;
font-size: 26px;
font-weight: 600;
color: #262626;
}
.metric-foot {
font-size: 12px;
color: #bfbfbf;
}
.project-card {
margin-top: 16px;
}
</style>
@@ -0,0 +1,142 @@
<!-- 志愿时长录入 -->
<template>
<ele-modal
:width="640"
:visible="visible"
:maskClosable="false"
:confirm-loading="loading"
title="录入志愿服务时长"
:body-style="{ paddingBottom: '24px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="校外或未通过平台记录的志愿服务,需要上传盖章版证明材料,提交后由二级学院审核。"
/>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-form-item label="志愿项目名称" name="projectName">
<a-input v-model:value="form.projectName" allow-clear placeholder="请输入项目名称" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="组织单位" name="orgName">
<a-input v-model:value="form.orgName" allow-clear placeholder="请输入组织单位" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="服务时长(小时)" name="hours">
<a-input-number
v-model:value="form.hours"
:min="0.5"
:step="0.5"
class="ele-fluid"
placeholder="请输入时长"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="服务日期" name="serviceDate">
<a-date-picker
v-model:value="serviceDate"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="请选择日期"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="结束日期" name="serviceEndDate">
<a-date-picker
v-model:value="serviceEndDate"
value-format="YYYY-MM-DD"
class="ele-fluid"
placeholder="单日可不填"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="服务内容" name="content">
<a-textarea v-model:value="form.content" :rows="3" allow-clear placeholder="简述服务内容" />
</a-form-item>
<a-form-item label="盖章版证明材料" name="proofFiles" required>
<MultiFileUpload v-model="form.proofFiles" :max-count="5" />
<div class="ele-text-secondary" style="font-size: 12px">
支持图片或 PDF,建议上传盖有组织单位公章的证明材料
</div>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import { submitVolunteerRecord } from '@/api/growth/volunteer';
import type { VolunteerRecord } from '@/api/growth/volunteer';
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const formRef = ref();
const loading = ref(false);
const form = reactive<VolunteerRecord>({});
const serviceDate = ref<any>();
const serviceEndDate = ref<any>();
const rules = {
projectName: [{ required: true, message: '请输入志愿项目名称', trigger: 'blur' }],
hours: [{ required: true, message: '请输入服务时长', trigger: 'change' }],
serviceDate: [{ required: true, message: '请选择服务日期', trigger: 'change' }],
proofFiles: [{ required: true, message: '请上传证明材料', trigger: 'change' }]
};
watch(
() => props.visible,
(value) => {
if (value) {
Object.keys(form).forEach((key) => delete (form as any)[key]);
serviceDate.value = undefined;
serviceEndDate.value = undefined;
}
}
);
const updateVisible = (value: boolean) => emit('update:visible', value);
const normalizeDate = (value: any) => {
if (!value) return undefined;
return dayjs.isDayjs(value) ? value.format('YYYY-MM-DD') : String(value);
};
const save = () => {
formRef.value
?.validate?.()
.then(async () => {
loading.value = true;
try {
form.serviceDate = normalizeDate(serviceDate.value);
form.serviceEndDate = normalizeDate(serviceEndDate.value);
const msg = await submitVolunteerRecord(form);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e?.message || '提交失败');
} finally {
loading.value = false;
}
})
.catch(() => undefined);
};
</script>
@@ -0,0 +1,88 @@
<template>
<common-import
v-model:visible="visible"
module-name="志愿服务时长"
file-name="桂志愿数据导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
:validate-item="validateItem"
import-description="请从桂志愿平台导出个人志愿服务记录,按模板整理后上传。导入的记录默认为「已通过」,按(来源 + 外部编号)自动去重。"
@done="onDone"
/>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import CommonImport from '@/views/gxmu/components/common-import.vue';
import { importVolunteerRecord } from '@/api/growth/volunteer';
import type { VolunteerRecord } from '@/api/growth/volunteer';
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'done'): void;
}>();
const visible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
});
const templateFields = [
{ header: '学号', key: 'studentNo', required: true },
{ header: '姓名', key: 'studentName', required: true },
{ header: '志愿项目', key: 'projectName', required: true },
{ header: '组织单位', key: 'orgName' },
{ header: '服务时长', key: 'hours', required: true },
{ header: '服务日期', key: 'serviceDate', required: true },
{ header: '外部编号', key: 'externalId' }
];
const sampleRow = {
学号: '2021001001',
姓名: '张三',
志愿项目: '社区义诊志愿服务',
组织单位: '广西医科大学青年志愿者协会',
服务时长: '4',
服务日期: '2025-10-12',
外部编号: 'gzy-2025-0001'
};
const normalizeDate = (value: any) => {
if (!value) return undefined;
if (typeof value === 'number') {
const date = new Date((value - 25569) * 86400 * 1000);
return date.toISOString().slice(0, 10);
}
return String(value).trim();
};
const createPayload = (row: Record<string, any>): VolunteerRecord => ({
studentNo: String(row['学号'] || '').trim(),
studentName: String(row['姓名'] || '').trim(),
projectName: String(row['志愿项目'] || '').trim(),
orgName: String(row['组织单位'] || '').trim() || undefined,
hours: Number(row['服务时长'] || 0),
serviceDate: normalizeDate(row['服务日期']),
externalId: String(row['外部编号'] || '').trim() || undefined,
source: 'gz',
status: 1
});
const validateItem = (item: VolunteerRecord, rowNo: number) => {
const errors: string[] = [];
if (!item.hours || item.hours <= 0) {
errors.push(`第 ${rowNo} 行服务时长必须大于 0`);
}
if (!item.serviceDate) {
errors.push(`第 ${rowNo} 行服务日期格式不正确`);
}
return errors;
};
const saveItem = async (item: VolunteerRecord) => importVolunteerRecord(item);
const onDone = () => emit('done');
</script>
+391
View File
@@ -0,0 +1,391 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<div class="volunteer-head">
<div class="head-metrics">
<div class="metric">
<span class="metric-label">累计通过时长</span>
<span class="metric-value">{{ summary.totalHours ?? 0 }}</span>
<span class="metric-unit">小时</span>
</div>
<div class="metric">
<span class="metric-label">已通过</span>
<span class="metric-value">{{ summary.approvedCount ?? 0 }}</span>
<span class="metric-unit">条</span>
</div>
<div class="metric">
<span class="metric-label">待审核</span>
<span class="metric-value warning">{{ summary.pendingCount ?? 0 }}</span>
<span class="metric-unit">条</span>
</div>
</div>
<a-space>
<a-button type="primary" @click="openSubmit">
<template #icon><plus-outlined /></template>
录入时长
</a-button>
<a-button v-if="hasPermission('gxmu:volunteer:import')" @click="showImport = true">
导入桂志愿数据
</a-button>
<a-button :loading="loading" @click="reload">刷新</a-button>
</a-space>
</div>
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="status"
allow-clear
style="width: 140px"
placeholder="审核状态"
:options="statusOptions"
@change="reload"
/>
<a-select
v-model:value="source"
allow-clear
style="width: 140px"
placeholder="来源"
:options="sourceOptions"
@change="reload"
/>
<a-range-picker
v-model:value="dateRange"
value-format="YYYY-MM-DD"
@change="reload"
/>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="项目/组织/姓名/学号"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'hours'">
<span class="hours-text">{{ record.hours }}</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">
{{ statusLabel(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'source'">
<a-tag>{{ sourceLabel(record.source) }}</a-tag>
</template>
<template v-else-if="column.key === 'proofFiles'">
<AttachmentList :value="record.proofFiles" />
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">查看</a>
<template v-if="record.status === 0 && canReview">
<a-divider type="vertical" />
<a @click="doReview(record, 1)">通过</a>
<a-divider type="vertical" />
<a class="ele-text-danger" @click="doReview(record, 2)">驳回</a>
</template>
<a-popconfirm
v-if="record.status !== 1"
title="确定要删除这条志愿记录吗?"
@confirm="remove(record)"
>
<a-divider type="vertical" />
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<VolunteerEdit
v-model:visible="showSubmit"
@done="onSubmitted"
/>
<VolunteerImport
v-model:visible="showImport"
@done="onSubmitted"
/>
<a-modal
v-model:visible="showDetail"
title="志愿时长详情"
:footer="null"
width="640px"
>
<a-descriptions v-if="current" :column="1" bordered size="small">
<a-descriptions-item label="项目名称">{{ current.projectName }}</a-descriptions-item>
<a-descriptions-item label="组织单位">{{ current.orgName || '-' }}</a-descriptions-item>
<a-descriptions-item label="服务时长">{{ current.hours }} 小时</a-descriptions-item>
<a-descriptions-item label="服务日期">
{{ current.serviceDate || '-' }}
<span v-if="current.serviceEndDate"> 至 {{ current.serviceEndDate }}</span>
</a-descriptions-item>
<a-descriptions-item label="服务内容">{{ current.content || '-' }}</a-descriptions-item>
<a-descriptions-item label="证明材料">
<AttachmentList :value="current.proofFiles" />
</a-descriptions-item>
<a-descriptions-item label="审核状态">
<a-tag :color="statusColor(current.status)">{{ statusLabel(current.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="current.reviewRemark" label="审核意见">
{{ current.reviewRemark }}
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import VolunteerEdit from './components/edit.vue';
import VolunteerImport from './components/import.vue';
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
import {
pageVolunteerRecords,
removeVolunteerRecord,
reviewVolunteerRecord,
getMyVolunteerSummary
} from '@/api/growth/volunteer';
import type { VolunteerRecord } from '@/api/growth/volunteer';
import { hasPermission, hasAnyRole } from '@/utils/permission';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const loading = ref(false);
const status = ref<number>();
const source = ref<string>();
const searchText = ref('');
const dateRange = ref<string[]>();
const showSubmit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<VolunteerRecord | null>(null);
const summary = ref<Record<string, any>>({});
const canReview = computed(
() =>
hasAnyRole([
'SchoolYouthLeagueCommittee',
'YouthLeagueCommitteeOfSecondaryColleges',
'superAdmin',
'admin'
]) || hasPermission('gxmu:volunteer:review')
);
const statusOptions = [
{ label: '待审核', value: 0 },
{ label: '通过', value: 1 },
{ label: '驳回', value: 2 }
];
const sourceOptions = [
{ label: '个人录入', value: 'self' },
{ label: '桂志愿', value: 'gz' },
{ label: '导入', value: 'import' }
];
const columns = ref<ColumnItem[]>([
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 100 },
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{ title: '志愿项目', dataIndex: 'projectName', key: 'projectName', ellipsis: true },
{ title: '组织单位', dataIndex: 'orgName', key: 'orgName', width: 160, ellipsis: true },
{ title: '时长', key: 'hours', width: 90, align: 'center' },
{
title: '服务日期',
dataIndex: 'serviceDate',
key: 'serviceDate',
width: 120,
align: 'center'
},
{ title: '来源', key: 'source', width: 100, align: 'center' },
{ title: '证明材料', key: 'proofFiles', width: 150 },
{ title: '状态', key: 'status', width: 100, align: 'center' },
{
title: '提交时间',
dataIndex: 'createTime',
key: 'createTime',
width: 160,
align: 'center',
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
loading.value = true;
return pageVolunteerRecords({
...where,
...orders,
page,
limit,
backendAccess: true,
status: status.value,
source: source.value,
serviceDateStart: dateRange.value?.[0],
serviceDateEnd: dateRange.value?.[1],
keywords: searchText.value || undefined
}).finally(() => {
loading.value = false;
});
};
const reload = () => {
tableRef.value?.reload?.({ page: 1 });
};
const resetSearch = () => {
status.value = undefined;
source.value = undefined;
searchText.value = '';
dateRange.value = undefined;
reload();
};
const openSubmit = () => {
showSubmit.value = true;
};
const openDetail = (record: VolunteerRecord) => {
current.value = record;
showDetail.value = true;
};
const doReview = async (record: VolunteerRecord, value: number) => {
try {
const msg = await reviewVolunteerRecord({ id: record.id, status: value });
message.success(msg);
reload();
await loadSummary();
} catch (e: any) {
message.error(e?.message || '审核失败');
}
};
const remove = async (record: VolunteerRecord) => {
if (!record.id) return;
try {
const msg = await removeVolunteerRecord(record.id);
message.success(msg);
reload();
await loadSummary();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const statusLabel = (value?: number) => {
if (value === 1) return '通过';
if (value === 2) return '驳回';
return '待审核';
};
const statusColor = (value?: number) => {
if (value === 1) return 'green';
if (value === 2) return 'red';
return 'orange';
};
const sourceLabel = (value?: string) => {
const map: Record<string, string> = {
self: '个人录入',
gz: '桂志愿',
import: '导入'
};
return map[value ?? ''] ?? value ?? '-';
};
const loadSummary = async () => {
try {
summary.value = await getMyVolunteerSummary();
} catch (e: any) {
summary.value = {};
}
};
const onSubmitted = () => {
reload();
loadSummary();
};
onMounted(loadSummary);
</script>
<style lang="less" scoped>
.volunteer-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
flex-wrap: wrap;
padding-bottom: 18px;
}
.head-metrics {
display: flex;
gap: 36px;
flex-wrap: wrap;
}
.metric {
display: flex;
align-items: baseline;
gap: 6px;
}
.metric-label {
font-size: 13px;
color: #8c8c8c;
}
.metric-value {
font-size: 24px;
font-weight: 600;
color: #1677ff;
}
.metric-value.warning {
color: #faad14;
}
.metric-unit {
font-size: 12px;
color: #8c8c8c;
}
.hours-text {
font-weight: 600;
color: #13c2c2;
}
</style>
+208
View File
@@ -0,0 +1,208 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="status"
allow-clear
style="width: 140px"
placeholder="审核状态"
:options="statusOptions"
@change="reload"
/>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 240px"
placeholder="项目/组织/姓名/学号"
@search="reload"
@pressEnter="reload"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'hours'">
<span class="hours-text">{{ record.hours }}</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'proofFiles'">
<AttachmentList :value="record.proofFiles" :max-count="2" />
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openReview(record)">审核</a>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<a-modal
v-model:visible="showReview"
title="志愿时长审核"
:confirm-loading="submitting"
width="640px"
@ok="submitReview"
>
<a-descriptions v-if="current" :column="1" bordered size="small">
<a-descriptions-item label="姓名">{{ current.studentName }}</a-descriptions-item>
<a-descriptions-item label="学号">{{ current.studentNo }}</a-descriptions-item>
<a-descriptions-item label="志愿项目">{{ current.projectName }}</a-descriptions-item>
<a-descriptions-item label="组织单位">{{ current.orgName || '-' }}</a-descriptions-item>
<a-descriptions-item label="服务时长">{{ current.hours }} 小时</a-descriptions-item>
<a-descriptions-item label="服务日期">
{{ current.serviceDate || '-' }}
<span v-if="current.serviceEndDate"> 至 {{ current.serviceEndDate }}</span>
</a-descriptions-item>
<a-descriptions-item label="服务内容">{{ current.content || '-' }}</a-descriptions-item>
<a-descriptions-item label="证明材料">
<AttachmentList :value="current.proofFiles" />
</a-descriptions-item>
</a-descriptions>
<a-form layout="vertical" style="margin-top: 16px">
<a-form-item label="审核结果" required>
<a-radio-group v-model:value="reviewStatus">
<a-radio :value="1">通过</a-radio>
<a-radio :value="2">驳回</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="审核意见" :required="reviewStatus === 2">
<a-textarea
v-model:value="reviewRemark"
:rows="3"
placeholder="驳回时请说明原因"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import AttachmentList from '@/views/gxmu/components/attachmentList.vue';
import {
pageVolunteerRecords,
reviewVolunteerRecord
} from '@/api/growth/volunteer';
import type { VolunteerRecord } from '@/api/growth/volunteer';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const status = ref<number>(0);
const searchText = ref('');
const showReview = ref(false);
const current = ref<VolunteerRecord | null>(null);
const reviewStatus = ref(1);
const reviewRemark = ref('');
const submitting = ref(false);
const statusOptions = [
{ label: '待审核', value: 0 },
{ label: '通过', value: 1 },
{ label: '驳回', value: 2 }
];
const columns = ref<ColumnItem[]>([
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 100 },
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{ title: '志愿项目', dataIndex: 'projectName', key: 'projectName', ellipsis: true },
{ title: '组织单位', dataIndex: 'orgName', key: 'orgName', width: 160, ellipsis: true },
{ title: '时长', key: 'hours', width: 90, align: 'center' },
{ title: '服务日期', dataIndex: 'serviceDate', key: 'serviceDate', width: 120, align: 'center' },
{ title: '证明材料', key: 'proofFiles', width: 150 },
{ title: '状态', key: 'status', width: 100, align: 'center' },
{ title: '审核意见', dataIndex: 'reviewRemark', key: 'reviewRemark', ellipsis: true },
{ title: '操作', key: 'action', width: 90, fixed: 'right', align: 'center', hideInSetting: true }
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageVolunteerRecords({
...where,
...orders,
page,
limit,
backendAccess: true,
status: status.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
status.value = undefined;
searchText.value = '';
reload();
};
const openReview = (record: VolunteerRecord) => {
current.value = record;
reviewStatus.value = record.status === 2 ? 2 : 1;
reviewRemark.value = record.reviewRemark || '';
showReview.value = true;
};
const submitReview = async () => {
if (!current.value?.id) return;
if (reviewStatus.value === 2 && !reviewRemark.value.trim()) {
message.warning('驳回时请填写审核意见');
return;
}
submitting.value = true;
try {
const msg = await reviewVolunteerRecord({
id: current.value.id,
status: reviewStatus.value,
reviewRemark: reviewRemark.value.trim() || undefined
});
message.success(msg);
showReview.value = false;
reload();
} catch (e: any) {
message.error(e?.message || '审核失败');
} finally {
submitting.value = false;
}
};
const statusLabel = (value?: number) => {
if (value === 1) return '通过';
if (value === 2) return '驳回';
return '待审核';
};
const statusColor = (value?: number) => {
if (value === 1) return 'green';
if (value === 2) return 'red';
return 'orange';
};
onMounted(() => undefined);
</script>
<style lang="less" scoped>
.hours-text {
font-weight: 600;
color: #13c2c2;
}
</style>