feat(user): 支持修改当前用户资料接口并更新个人中心调用
- 新增 updateSelfUser 方法,用于修改当前登录用户的个人资料 - 个人中心调用接口由原 updateUser 切换为 updateSelfUser,避免学生账号权限不足导致的保存失败 - 更新了头像保存、个人信息保存等相关调用代码,添加异常捕获和提示 - 修正环境变量 .env.development 中 API URL 配置为生产地址 - 优化审核记录排序逻辑,新增获取最新审核记录和编辑入口显示判断函数 - 调整审核状态标签使用统一排序规则,防止显示和编辑状态不一致 - 新增社团活动、社团编辑、志愿时长录入、学分记录编辑、社会实践学分申报、青马学员编辑等多个新增组件及表单功能 - 各新增功能支持表单验证、操作反馈和数据加载优化
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user