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
@@ -13,13 +13,12 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { ReviewList } from '@/api/gxmu/reviewList/model';
import { getLatestReview } from '../utils/review';
const props = defineProps<{
reviewList?: ReviewList[];
}>();
const latest = computed(() => {
const list = props.reviewList ?? [];
return [...list].sort((a, b) => Number(b.id || 0) - Number(a.id || 0))[0];
});
// 与「能否编辑」共用同一套排序规则,避免两处结论不一致
const latest = computed(() => getLatestReview(props.reviewList));
</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>
+224
View File
@@ -0,0 +1,224 @@
<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: 1000 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<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 === 'totalScore'">
<span class="score-text">{{ record.totalScore ?? 0 }}</span>
</template>
<template v-else-if="column.key === 'action'">
<a @click="openScore(record)">评分</a>
</template>
</template>
</ele-pro-table>
</a-card>
<a-drawer
v-model:visible="showDetail"
:title="`${current?.studentName || ''} · 考核评分`"
width="820"
>
<a-space style="margin-bottom: 16px">
<a-button type="primary" @click="openAddScore">新增评分</a-button>
<a-tag color="blue">累计 {{ current?.totalScore ?? 0 }} 分</a-tag>
</a-space>
<a-table
row-key="id"
size="small"
:data-source="scores"
:columns="scoreColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a @click="editScore(record)">编辑</a>
</template>
</template>
</a-table>
</a-drawer>
<a-modal
v-model:visible="showScoreModal"
title="录入考核评分"
:confirm-loading="submitting"
width="520px"
@ok="saveScore"
>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="评分维度" required>
<a-select
v-model:value="form.dimension"
:options="dimensionOptions"
placeholder="请选择维度"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="评分项">
<a-input v-model:value="form.item" allow-clear placeholder="如 理论学习" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="得分" required>
<a-input-number v-model:value="form.score" :min="0" class="ele-fluid" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="满分">
<a-input-number v-model:value="form.maxScore" :min="0" class="ele-fluid" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="评语">
<a-textarea v-model:value="form.comment" :rows="3" />
</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 type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import {
pageQmgcStudents,
listQmgcScores,
saveQmgcScore
} from '@/api/growth/qmgc';
import type { QmgcStudent, QmgcScore } from '@/api/growth/qmgc';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showDetail = ref(false);
const showScoreModal = ref(false);
const submitting = ref(false);
const current = ref<QmgcStudent | null>(null);
const scores = ref<QmgcScore[]>([]);
const form = ref<Partial<QmgcScore>>({});
const dimensionOptions = [
{ label: '理论学习', value: '理论学习' },
{ label: '实践锻炼', value: '实践锻炼' },
{ label: '日常表现', value: '日常表现' },
{ label: '考核考试', value: '考核考试' }
];
const columns = ref<ColumnItem[]>([
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 110 },
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{ title: '期次', dataIndex: 'batch', key: 'batch', width: 160 },
{ title: '阶段', dataIndex: 'stage', key: 'stage', width: 100 },
{ title: '累计得分', key: 'totalScore', width: 110, align: 'center' },
{ title: '操作', key: 'action', width: 90, fixed: 'right', align: 'center', hideInSetting: true }
]);
const scoreColumns: ColumnItem[] = [
{ title: '维度', dataIndex: 'dimension', key: 'dimension', width: 130 },
{ title: '评分项', dataIndex: 'item', key: 'item', width: 160 },
{ title: '得分', dataIndex: 'score', key: 'score', width: 90 },
{ title: '满分', dataIndex: 'maxScore', key: 'maxScore', width: 90 },
{ title: '评语', dataIndex: 'comment', key: 'comment', ellipsis: true },
{ title: '操作', key: 'action', width: 80 }
];
const datasource = ({ page, limit, where, orders }: any) => {
return pageQmgcStudents({
...where,
...orders,
page,
limit,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
searchText.value = '';
reload();
};
const openScore = async (record: QmgcStudent) => {
current.value = record;
showDetail.value = true;
await loadScores();
};
const loadScores = async () => {
if (!current.value?.id) return;
try {
scores.value = await listQmgcScores(current.value.id);
} catch (e: any) {
scores.value = [];
}
};
const openAddScore = () => {
form.value = { maxScore: 100 };
showScoreModal.value = true;
};
const editScore = (row: QmgcScore) => {
form.value = { ...row };
showScoreModal.value = true;
};
const saveScore = async () => {
if (!current.value?.id) return;
if (!form.value.dimension) {
message.warning('请选择评分维度');
return;
}
submitting.value = true;
try {
await saveQmgcScore({
...form.value,
studentId: current.value.id
} as QmgcScore);
message.success('保存成功');
showScoreModal.value = false;
await loadScores();
reload();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
submitting.value = false;
}
};
</script>
<style lang="less" scoped>
.score-text {
font-weight: 600;
color: #1677ff;
}
</style>
@@ -0,0 +1,163 @@
<!-- 青马学员编辑 -->
<template>
<ele-modal
:width="680"
: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="studentName">
<a-input v-model:value="form.studentName" allow-clear placeholder="请输入姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="学号" name="studentNo">
<a-input v-model:value="form.studentNo" allow-clear placeholder="请输入学号" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="学院" name="collegeName">
<a-input v-model:value="form.collegeName" allow-clear placeholder="请输入学院" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="班级" name="className">
<a-input v-model:value="form.className" allow-clear placeholder="请输入班级" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="期次" name="batch">
<a-input v-model:value="form.batch" allow-clear placeholder="如 2026 年第 1 期" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="年度" name="year">
<a-input-number v-model:value="form.year" :min="2000" :max="2100" class="ele-fluid" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="学员编号" name="candidateNo">
<a-input v-model:value="form.candidateNo" allow-clear placeholder="请输入学员编号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="当前阶段" name="stage">
<a-select v-model:value="form.stage" :options="stageOptions" placeholder="请选择阶段" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="培育结果" name="result">
<a-select v-model:value="form.result" allow-clear :options="resultOptions" placeholder="请选择" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="1">在培</a-radio>
<a-radio :value="2">结业</a-radio>
<a-radio :value="0">退出</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="form.remark" :rows="2" allow-clear placeholder="选填" />
</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 { assignObject } from 'ele-admin-pro';
import { addQmgcStudent, updateQmgcStudent } from '@/api/growth/qmgc';
import type { QmgcStudent } from '@/api/growth/qmgc';
const props = defineProps<{
visible: boolean;
data?: QmgcStudent | 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<QmgcStudent>({});
const stageOptions = [
{ label: '选拔', value: 'selection' },
{ label: '学习', value: 'study' },
{ label: '实践', value: 'practice' },
{ label: '考核', value: 'exam' },
{ label: '结业', value: 'graduated' }
];
const resultOptions = [
{ label: '在培', value: 'ongoing' },
{ label: '通过', value: 'passed' },
{ label: '未通过', value: 'failed' },
{ label: '退出', value: 'quit' }
];
const rules = {
studentName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
studentNo: [{ 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]);
if (props.data) {
assignObject(form, props.data);
} else {
form.status = 1;
form.stage = 'selection';
form.result = 'ongoing';
}
}
);
const updateVisible = (value: boolean) => emit('update:visible', value);
const save = () => {
formRef.value
?.validate?.()
.then(async () => {
loading.value = true;
try {
const msg = isUpdate.value
? await updateQmgcStudent(form)
: await addQmgcStudent(form);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
loading.value = false;
}
})
.catch(() => undefined);
};
</script>
+404
View File
@@ -0,0 +1,404 @@
<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:qmgcStudent: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-input
v-model:value="batch"
allow-clear
style="width: 180px"
placeholder="期次"
@pressEnter="reload"
/>
<a-select
v-model:value="stage"
allow-clear
style="width: 150px"
placeholder="阶段"
:options="stageOptions"
@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 === 'stage'">
<a-tag color="blue">{{ stageLabel(record.stage) }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : record.status === 2 ? 'blue' : 'default'">
{{ record.status === 1 ? '在培' : record.status === 2 ? '结业' : '退出' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">档案</a>
<a-divider type="vertical" />
<a v-if="hasPermission('gxmu:qmgcStudent:update')" @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
v-if="hasPermission('gxmu:qmgcStudent:remove')"
title="确定要删除这个学员档案吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<QmgcStudentEdit v-model:visible="showEdit" :data="current" @done="reload" />
<a-drawer v-model:visible="showDetail" title="青马培养档案" width="880">
<template v-if="detail">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="姓名">{{ student?.studentName }}</a-descriptions-item>
<a-descriptions-item label="学号">{{ student?.studentNo }}</a-descriptions-item>
<a-descriptions-item label="期次">{{ student?.batch || '-' }}</a-descriptions-item>
<a-descriptions-item label="学员编号">{{ student?.candidateNo || '-' }}</a-descriptions-item>
<a-descriptions-item label="当前阶段">{{ stageLabel(student?.stage) }}</a-descriptions-item>
<a-descriptions-item label="累计得分">{{ student?.totalScore ?? 0 }}</a-descriptions-item>
</a-descriptions>
<a-tabs style="margin-top: 16px">
<a-tab-pane key="material" tab="培养材料">
<a-space style="margin-bottom: 12px">
<a-button size="small" type="primary" @click="showMaterial = true">
提交材料
</a-button>
</a-space>
<a-empty v-if="!materials.length" description="暂无材料" />
<a-list v-else :data-source="materials" item-layout="vertical">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.title" :description="item.scoreComment || item.stage" />
<template #actions>
<a-tag :color="item.status === 1 ? 'green' : 'orange'">
{{ item.status === 1 ? `已评分 ${item.score ?? 0}` : '待评分' }}
</a-tag>
<a @click="openScore(item)">评分</a>
</template>
</a-list-item>
</template>
</a-list>
</a-tab-pane>
<a-tab-pane key="task" tab="任务进度">
<a-empty v-if="!tasks.length" description="暂无任务" />
<a-table
v-else
row-key="taskId"
size="small"
:data-source="tasks"
:columns="taskColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.assignStatus === 1 ? 'green' : 'orange'">
{{ record.assignStatus === 1 ? '已完成' : '未完成' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a v-if="record.assignStatus !== 1" @click="completeTask(record)">标记完成</a>
<span v-else>-</span>
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="score" tab="考核评分">
<a-table
row-key="id"
size="small"
:data-source="scores"
:columns="scoreColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a @click="openScore(record)">编辑</a>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</template>
</a-drawer>
<a-modal
v-model:visible="showMaterial"
title="提交培养材料"
:confirm-loading="submitting"
width="560px"
@ok="submitMaterial"
>
<a-form layout="vertical">
<a-form-item label="材料标题" required>
<a-input v-model:value="materialForm.title" allow-clear placeholder="请输入标题" />
</a-form-item>
<a-form-item label="阶段">
<a-select
v-model:value="materialForm.stage"
allow-clear
:options="stageOptions"
placeholder="请选择阶段"
/>
</a-form-item>
<a-form-item label="内容">
<a-textarea v-model:value="materialForm.content" :rows="4" placeholder="请输入材料内容" />
</a-form-item>
<a-form-item label="附件">
<MultiFileUpload v-model="materialFile" :max-count="3" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:visible="showScoreModal"
title="评分"
:confirm-loading="submitting"
width="480px"
@ok="submitScore"
>
<a-form layout="vertical">
<a-form-item label="得分" required>
<a-input-number v-model:value="scoreValue" :min="0" :max="100" class="ele-fluid" />
</a-form-item>
<a-form-item label="评语">
<a-textarea v-model:value="scoreComment" :rows="3" />
</a-form-item>
</a-form>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import QmgcStudentEdit from './components/edit.vue';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import {
pageQmgcStudents,
removeQmgcStudent,
getQmgcStudentDetail,
submitQmgcMaterial,
scoreQmgcMaterial,
completeQmgcTask
} from '@/api/growth/qmgc';
import type { QmgcStudent, QmgcMaterial, QmgcScore } from '@/api/growth/qmgc';
import { hasPermission } from '@/utils/permission';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const batch = ref('');
const stage = ref<string>();
const showEdit = ref(false);
const showDetail = ref(false);
const showMaterial = ref(false);
const showScoreModal = ref(false);
const current = ref<QmgcStudent | null>(null);
const detail = ref<Record<string, any> | null>(null);
const materialForm = ref<Partial<QmgcMaterial>>({});
const materialFile = ref('');
const submitting = ref(false);
const scoreValue = ref(0);
const scoreComment = ref('');
const scoreTargetId = ref<number>();
const stageOptions = [
{ label: '选拔', value: 'selection' },
{ label: '学习', value: 'study' },
{ label: '实践', value: 'practice' },
{ label: '考核', value: 'exam' },
{ label: '结业', value: 'graduated' }
];
const student = computed(() => detail.value?.student || null);
const materials = computed<QmgcMaterial[]>(() => detail.value?.materials || []);
const tasks = computed(() => detail.value?.tasks || []);
const scores = computed<QmgcScore[]>(() => detail.value?.scores || []);
const columns = ref<ColumnItem[]>([
{ title: '姓名', dataIndex: 'studentName', key: 'studentName', width: 110 },
{ title: '学号', dataIndex: 'studentNo', key: 'studentNo', width: 140 },
{ title: '学院', dataIndex: 'collegeName', key: 'collegeName', width: 160 },
{ title: '期次', dataIndex: 'batch', key: 'batch', width: 160 },
{ title: '年度', dataIndex: 'year', key: 'year', width: 90, align: 'center' },
{ title: '阶段', key: 'stage', width: 100, align: 'center' },
{ title: '累计得分', dataIndex: 'totalScore', key: 'totalScore', width: 100, align: 'center' },
{ title: '状态', key: 'status', width: 90, align: 'center' },
{ title: '操作', key: 'action', width: 180, fixed: 'right', align: 'center', hideInSetting: true }
]);
const taskColumns: ColumnItem[] = [
{ title: '任务', key: 'title', ellipsis: true },
{ title: '类型', key: 'type', width: 90 },
{ title: '截止', key: 'deadline', width: 170 },
{ title: '状态', key: 'status', width: 90 },
{ title: '操作', key: 'action', width: 100 }
];
const scoreColumns: ColumnItem[] = [
{ title: '维度', dataIndex: 'dimension', key: 'dimension', width: 140 },
{ title: '评分项', dataIndex: 'item', key: 'item', width: 180 },
{ title: '得分', dataIndex: 'score', key: 'score', width: 90 },
{ title: '评语', dataIndex: 'comment', key: 'comment', ellipsis: true },
{ title: '操作', key: 'action', width: 80 }
];
const datasource = ({ page, limit, where, orders }: any) => {
return pageQmgcStudents({
...where,
...orders,
page,
limit,
batch: batch.value || undefined,
stage: stage.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
searchText.value = '';
batch.value = '';
stage.value = undefined;
reload();
};
const openEdit = (row?: QmgcStudent) => {
current.value = row || null;
showEdit.value = true;
};
const openDetail = async (row: QmgcStudent) => {
current.value = row;
if (!row.id) return;
try {
detail.value = await getQmgcStudentDetail(row.id);
showDetail.value = true;
} catch (e: any) {
message.error(e?.message || '加载档案失败');
}
};
const remove = async (row: QmgcStudent) => {
if (!row.id) return;
try {
const msg = await removeQmgcStudent(row.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const reloadDetail = async () => {
if (!current.value?.id) return;
detail.value = await getQmgcStudentDetail(current.value.id);
};
const submitMaterial = async () => {
if (!materialForm.value.title?.trim()) {
message.warning('请填写材料标题');
return;
}
submitting.value = true;
try {
await submitQmgcMaterial({
studentId: current.value?.id,
title: materialForm.value.title,
stage: materialForm.value.stage,
content: materialForm.value.content,
fileUrl: materialFile.value || undefined
});
message.success('提交成功');
showMaterial.value = false;
materialForm.value = {};
materialFile.value = '';
await reloadDetail();
} catch (e: any) {
message.error(e?.message || '提交失败');
} finally {
submitting.value = false;
}
};
const openScore = (item: any) => {
scoreTargetId.value = item.id;
scoreValue.value = item.score ?? 0;
scoreComment.value = item.scoreComment || item.comment || '';
showScoreModal.value = true;
};
const submitScore = async () => {
if (!scoreTargetId.value) return;
submitting.value = true;
try {
await scoreQmgcMaterial(scoreTargetId.value, Number(scoreValue.value), scoreComment.value);
message.success('评分成功');
showScoreModal.value = false;
await reloadDetail();
} catch (e: any) {
message.error(e?.message || '评分失败');
} finally {
submitting.value = false;
}
};
const completeTask = async (record: any) => {
if (!current.value?.id) return;
try {
const msg = await completeQmgcTask(current.value.id, record.task?.id);
message.success(msg);
await reloadDetail();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const stageLabel = (value?: string) => {
const map: Record<string, string> = {
selection: '选拔',
study: '学习',
practice: '实践',
exam: '考核',
graduated: '结业'
};
return map[value ?? ''] ?? value ?? '-';
};
</script>
+256
View File
@@ -0,0 +1,256 @@
<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: 1100 }"
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
v-model:value="batch"
allow-clear
style="width: 180px"
placeholder="期次"
@pressEnter="reload"
/>
<a-select
v-model:value="type"
allow-clear
style="width: 140px"
placeholder="任务类型"
:options="typeOptions"
@change="reload"
/>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 220px"
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 === 'type'">
<a-tag :color="record.type === 'online' ? 'blue' : 'purple'">
{{ record.type === 'online' ? '线上' : '线下' }}
</a-tag>
</template>
<template v-else-if="column.key === 'mustDo'">
<a-tag :color="record.mustDo === 1 ? 'red' : 'default'">
{{ record.mustDo === 1 ? '必修' : '选修' }}
</a-tag>
</template>
<template v-else-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-space>
<a @click="openEdit(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>
<a-modal
v-model:visible="showEdit"
:title="current?.id ? '编辑任务' : '新增任务'"
:confirm-loading="submitting"
width="600px"
@ok="save"
>
<a-form layout="vertical">
<a-form-item label="任务标题" required>
<a-input v-model:value="form.title" allow-clear placeholder="请输入任务标题" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="适用期次">
<a-input v-model:value="form.batch" allow-clear placeholder="空表示全部" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="阶段">
<a-select v-model:value="form.stage" allow-clear :options="stageOptions" placeholder="请选择" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="任务类型">
<a-radio-group v-model:value="form.type">
<a-radio value="online">线上</a-radio>
<a-radio value="offline">线下</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="是否必修">
<a-switch :checked="form.mustDo === 1" @change="(v) => (form.mustDo = v ? 1 : 0)" />
</a-form-item>
</a-col>
</a-row>
<a-form-item v-if="form.type === 'online'" label="跳转地址">
<a-input v-model:value="form.linkPath" allow-clear placeholder="如 /growth/credit" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="完成可得学分">
<a-input-number v-model:value="form.score" :min="0" class="ele-fluid" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="截止时间">
<a-date-picker
v-model:value="deadline"
show-time
value-format="YYYY-MM-DD HH:mm:ss"
class="ele-fluid"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="任务说明">
<a-textarea v-model:value="form.content" :rows="3" />
</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 { PlusOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import {
pageQmgcTasks,
addQmgcTask,
updateQmgcTask,
removeQmgcTask
} from '@/api/growth/qmgc';
import type { QmgcTask } from '@/api/growth/qmgc';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const batch = ref('');
const type = ref<string>();
const searchText = ref('');
const showEdit = ref(false);
const submitting = ref(false);
const current = ref<QmgcTask | null>(null);
const form = ref<Partial<QmgcTask>>({});
const deadline = ref<any>();
const typeOptions = [
{ label: '线上', value: 'online' },
{ label: '线下', value: 'offline' }
];
const stageOptions = [
{ label: '选拔', value: 'selection' },
{ label: '学习', value: 'study' },
{ label: '实践', value: 'practice' },
{ label: '考核', value: 'exam' },
{ label: '结业', value: 'graduated' }
];
const columns = ref<ColumnItem[]>([
{ title: '任务标题', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '期次', dataIndex: 'batch', key: 'batch', width: 160 },
{ title: '阶段', dataIndex: 'stage', key: 'stage', width: 100 },
{ title: '类型', key: 'type', width: 90 },
{ title: '必修', key: 'mustDo', width: 90 },
{ title: '学分', dataIndex: 'score', key: 'score', width: 90 },
{
title: '截止时间',
dataIndex: 'deadline',
key: 'deadline',
width: 170,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm')
},
{ title: '状态', key: 'status', width: 90 },
{ title: '操作', key: 'action', width: 130, fixed: 'right' }
]);
const datasource = ({ page, limit, where, orders }: any) => {
return pageQmgcTasks({
...where,
...orders,
page,
limit,
batch: batch.value || undefined,
type: type.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
batch.value = '';
type.value = undefined;
searchText.value = '';
reload();
};
const openEdit = (row?: QmgcTask) => {
current.value = row || null;
form.value = row ? { ...row } : { type: 'online', mustDo: 1, status: 1 };
deadline.value = row?.deadline;
showEdit.value = true;
};
const save = async () => {
if (!form.value.title?.trim()) {
message.warning('请输入任务标题');
return;
}
submitting.value = true;
try {
form.value.deadline = deadline.value;
const msg = current.value?.id
? await updateQmgcTask(form.value as QmgcTask)
: await addQmgcTask(form.value as QmgcTask);
message.success(msg);
showEdit.value = false;
reload();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
submitting.value = false;
}
};
const remove = async (row: QmgcTask) => {
if (!row.id) return;
try {
const msg = await removeQmgcTask(row.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
</script>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" class="placeholder-card">
<a-result status="info" title="AI辅助教学设计" sub-title="该功能规划在 P1 阶段实施">
<template #extra>
<a-space direction="vertical" size="12" style="text-align: left; max-width: 640px">
<div class="placeholder-desc">输入团课/青马课程主题,生成 PPT 大纲、课堂讨论题与随堂测验。</div>
<div class="placeholder-sub">实施依赖:</div>
<ul class="placeholder-list">
<li>Dify 教学设计工作流</li>
<li>AI 生成页外壳组件</li>
</ul>
</a-space>
</template>
</a-result>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
// AI辅助教学设计: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>
+293
View File
@@ -0,0 +1,293 @@
<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-button
v-if="hasPermission('gxmu:thematicCourse:save')"
type="primary"
@click="openEdit()"
>
<template #icon><plus-outlined /></template>
新增资源
</a-button>
<a-button
v-if="hasPermission('gxmu:thematicCourse:sync')"
:loading="syncing"
@click="doSync"
>
<template #icon><sync-outlined /></template>
同步资源
</a-button>
<a-select
v-model:value="type"
allow-clear
style="width: 150px"
placeholder="资源类型"
:options="THEMATIC_TYPE_OPTIONS"
@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 === 'type'">
<a-tag color="blue">{{ typeLabel(record.type) }}</a-tag>
</template>
<template v-else-if="column.key === 'title'">
<a @click="openDetail(record)">{{ record.title }}</a>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">查看</a>
<a-divider type="vertical" />
<a v-if="hasPermission('gxmu:thematicCourse:save')" @click="openEdit(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>
<a-modal
v-model:visible="showEdit"
:title="current?.id ? '编辑团课资源' : '新增团课资源'"
:confirm-loading="submitting"
width="640px"
@ok="save"
>
<a-form layout="vertical">
<a-form-item label="标题" required>
<a-input v-model:value="form.title" allow-clear placeholder="请输入标题" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="资源类型">
<a-select v-model:value="form.type" :options="THEMATIC_TYPE_OPTIONS" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="标签">
<a-input v-model:value="form.tags" allow-clear placeholder="逗号分隔" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="摘要">
<a-textarea v-model:value="form.summary" :rows="2" />
</a-form-item>
<a-form-item label="正文/讲稿">
<a-textarea v-model:value="form.content" :rows="6" />
</a-form-item>
<a-form-item label="附件">
<MultiFileUpload v-model="fileUrl" :max-count="3" />
</a-form-item>
<a-form-item label="来源链接">
<a-input v-model:value="form.sourceUrl" allow-clear placeholder="可选" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:visible="showDetail"
:title="current?.title || '团课资源详情'"
:footer="null"
width="820px"
>
<template v-if="current">
<a-space style="margin-bottom: 12px">
<a-tag color="blue">{{ typeLabel(current.type) }}</a-tag>
<a-tag v-if="current.sourceSite">{{ current.sourceSite }}</a-tag>
<a-tag v-if="current.tags">{{ current.tags }}</a-tag>
<a-tag>{{ current.heat ?? 0 }} 次浏览</a-tag>
</a-space>
<div v-if="current.summary" class="detail-summary">{{ current.summary }}</div>
<div class="detail-content" v-html="renderedContent"></div>
<a-space v-if="current.fileUrl || current.sourceUrl" style="margin-top: 16px">
<a-button v-if="current.fileUrl" type="primary" @click="openUrl(current.fileUrl)">
下载附件
</a-button>
<a-button v-if="current.sourceUrl" @click="openUrl(current.sourceUrl)">查看原文</a-button>
</a-space>
</template>
</a-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined, SyncOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type { ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types';
import MultiFileUpload from '@/components/MultiFileUpload/index.vue';
import {
pageThematicCourses,
addThematicCourse,
updateThematicCourse,
removeThematicCourse,
syncThematicCourses,
THEMATIC_TYPE_OPTIONS
} from '@/api/growth/thematic';
import type { ThematicCourse } from '@/api/growth/thematic';
import { hasPermission } from '@/utils/permission';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const type = ref<string>();
const searchText = ref('');
const showEdit = ref(false);
const showDetail = ref(false);
const submitting = ref(false);
const syncing = ref(false);
const current = ref<ThematicCourse | null>(null);
const form = ref<Partial<ThematicCourse>>({});
const fileUrl = ref('');
const columns = ref<ColumnItem[]>([
{ title: '标题', key: 'title', ellipsis: true },
{ title: '类型', key: 'type', width: 110, align: 'center' },
{ title: '来源', dataIndex: 'sourceSite', key: 'sourceSite', width: 140 },
{ title: '标签', dataIndex: 'tags', key: 'tags', width: 180, ellipsis: true },
{ title: '热度', dataIndex: 'heat', key: 'heat', width: 90, align: 'center' },
{
title: '同步时间',
dataIndex: 'lastSyncTime',
key: 'lastSyncTime',
width: 160,
customRender: ({ text }) => (text ? toDateString(text, 'yyyy-MM-dd HH:mm') : '-')
},
{ title: '操作', key: 'action', width: 150, fixed: 'right', align: 'center' }
]);
const datasource = ({ page, limit, where, orders }: any) => {
return pageThematicCourses({
...where,
...orders,
page,
limit,
type: type.value,
keywords: searchText.value || undefined
});
};
const reload = () => tableRef.value?.reload?.({ page: 1 });
const resetSearch = () => {
type.value = undefined;
searchText.value = '';
reload();
};
const openEdit = (row?: ThematicCourse) => {
current.value = row || null;
form.value = row ? { ...row } : { type: 'courseware' };
fileUrl.value = row?.fileUrl || '';
showEdit.value = true;
};
const openDetail = (row: ThematicCourse) => {
current.value = row;
showDetail.value = true;
};
const save = async () => {
if (!form.value.title?.trim()) {
message.warning('请输入标题');
return;
}
submitting.value = true;
try {
form.value.fileUrl = fileUrl.value || undefined;
const msg = current.value?.id
? await updateThematicCourse(form.value as ThematicCourse)
: await addThematicCourse(form.value as ThematicCourse);
message.success(msg);
showEdit.value = false;
reload();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
submitting.value = false;
}
};
const remove = async (row: ThematicCourse) => {
if (!row.id) return;
try {
const msg = await removeThematicCourse(row.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const doSync = async () => {
syncing.value = true;
try {
const msg = await syncThematicCourses();
message.success(msg);
reload();
} catch (e: any) {
message.error(e?.message || '同步失败');
} finally {
syncing.value = false;
}
};
const openUrl = (url?: string) => {
if (url) window.open(url, '_blank');
};
const typeLabel = (value?: string) =>
THEMATIC_TYPE_OPTIONS.find((item) => item.value === value)?.label ?? value ?? '-';
const renderedContent = computed(() => {
const content = current.value?.content || '';
if (!content) return '<p class="ele-text-secondary">暂无正文</p>';
// 简单换行保留,避免引入额外 markdown 依赖
return content
.split('\n')
.map((line) => `<p>${line || '&nbsp;'}</p>`)
.join('');
});
</script>
<style lang="less" scoped>
.detail-summary {
padding: 10px 14px;
margin-bottom: 12px;
background: #fafafa;
border-radius: 8px;
color: #595959;
}
.detail-content {
max-height: 50vh;
overflow: auto;
line-height: 1.8;
color: #262626;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" class="placeholder-card">
<a-result status="info" title="AI主题团日设计" 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>Dify 主题团日工作流</li>
<li>复用活动策划页壳</li>
</ul>
</a-space>
</template>
</a-result>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
// AI主题团日设计: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>
+27 -4
View File
@@ -1,11 +1,24 @@
import type { ReviewList } from '@/api/gxmu/reviewList/model';
export function shouldHideEditByReviewList(reviewList?: ReviewList[]) {
/**
* 取最新一条审核记录。
*
* <p>统一排序规则:先按 sortNumber(并行组序号)降序,再按 createTime、id 降序。
* 原先 reviewStatusTag 只按 id、这里只按 createTime+id,两处可能给出不同结论
* (并行审核某组后审通过、前组已驳回时,状态标签与「能否编辑」会互相矛盾),
* 现在收敛到这一处。
*/
export function getLatestReview(reviewList?: ReviewList[]): ReviewList | undefined {
const list = reviewList ?? [];
if (!list.length) {
return false;
return undefined;
}
const latest = [...list].sort((a, b) => {
return [...list].sort((a, b) => {
const sortA = Number(a.sortNumber ?? 0);
const sortB = Number(b.sortNumber ?? 0);
if (sortA !== sortB) {
return sortB - sortA;
}
const timeA = Date.parse(a.createTime || '');
const timeB = Date.parse(b.createTime || '');
const safeTimeA = Number.isFinite(timeA) ? timeA : 0;
@@ -15,5 +28,15 @@ export function shouldHideEditByReviewList(reviewList?: ReviewList[]) {
}
return Number(b.id || 0) - Number(a.id || 0);
})[0];
return latest?.status !== 2;
}
/**
* 最新记录不是驳回时,隐藏编辑入口(待审核或已通过都不允许改)。
*/
export function shouldHideEditByReviewList(reviewList?: ReviewList[]) {
const list = reviewList ?? [];
if (!list.length) {
return false;
}
return getLatestReview(list)?.status !== 2;
}