This commit is contained in:
2026-07-20 11:50:47 +08:00
parent 95d43746f6
commit 019ed28d37
401 changed files with 61622 additions and 23869 deletions
@@ -5,7 +5,7 @@
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '编辑企业法治检查报告' : '添加企业法治检查报告'"
:title="isUpdate ? '编辑' : '添加'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
@@ -19,53 +19,18 @@
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="" name="companyName">
<a-form-item label="" name="chatId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.companyName"
v-model:value="form.chatId"
/>
</a-form-item>
<a-form-item label="检查单位" name="checkCompanyName">
<a-input
allow-clear
placeholder="请输入检查单位"
v-model:value="form.checkCompanyName"
/>
</a-form-item>
<a-form-item label="" name="checkDate">
<a-form-item label="" name="conversationId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.checkDate"
/>
</a-form-item>
<a-form-item label="" name="type">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.type"
/>
</a-form-item>
<a-form-item label="本月检查次数" name="checkNum">
<a-input
allow-clear
placeholder="请输入本月检查次数"
v-model:value="form.checkNum"
/>
</a-form-item>
<a-form-item label="" name="comment">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.comment"
/>
</a-form-item>
<a-form-item label="" name="remark">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.remark"
v-model:value="form.conversationId"
/>
</a-form-item>
<a-form-item label="" name="userId">
@@ -75,6 +40,26 @@
v-model:value="form.userId"
/>
</a-form-item>
<a-form-item label="" name="content">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.content"
/>
</a-form-item>
<a-form-item label="" name="reply">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.reply"
/>
</a-form-item>
<a-form-item label="状态, 0正常, 1冻结" 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-item label="是否删除, 0否, 1是" name="deleted">
<a-input
allow-clear
@@ -97,8 +82,8 @@
import { ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject, uuid } from 'ele-admin-pro';
import { addLawOrgCheckLog, updateLawOrgCheckLog } from '@/api/law/lawOrgCheckLog';
import { LawOrgCheckLog } from '@/api/law/lawOrgCheckLog/model';
import { addAiChatHistory, updateAiChatHistory } from '@/api/ai/aiChatHistory';
import { AiChatHistory } from '@/api/ai/aiChatHistory/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
@@ -115,7 +100,7 @@
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: LawOrgCheckLog | null;
data?: AiChatHistory | null;
}>();
const emit = defineEmits<{
@@ -131,16 +116,14 @@
const formRef = ref<FormInstance | null>(null);
// 用户信息
const form = reactive<LawOrgCheckLog>({
const form = reactive<AiChatHistory>({
id: undefined,
companyName: undefined,
checkCompanyName: undefined,
checkDate: undefined,
type: undefined,
checkNum: undefined,
comment: undefined,
remark: undefined,
chatId: undefined,
conversationId: undefined,
userId: undefined,
content: undefined,
reply: undefined,
status: undefined,
deleted: undefined,
tenantId: undefined,
createTime: undefined,
@@ -171,7 +154,7 @@
const formData = {
...form
};
const saveOrUpdate = isUpdate.value ? updateLawOrgCheckLog : addLawOrgCheckLog;
const saveOrUpdate = isUpdate.value ? updateAiChatHistory : addAiChatHistory;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
@@ -30,7 +30,7 @@
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">详情</a>
<a @click="openEdit(record)">修改</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@@ -45,7 +45,7 @@
</a-card>
<!-- 编辑弹窗 -->
<LawNotaryListEdit v-model:visible="showEdit" :data="current" @done="reload" />
<AiChatHistoryEdit v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</div>
</template>
@@ -61,17 +61,17 @@
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import LawNotaryListEdit from './components/lawNotaryListEdit.vue';
import { pageLawNotaryList, removeLawNotaryList, removeBatchLawNotaryList } from '@/api/law/lawNotaryList';
import type { LawNotaryList, LawNotaryListParam } from '@/api/law/lawNotaryList/model';
import AiChatHistoryEdit from './components/aiChatHistoryEdit.vue';
import { pageAiChatHistory, removeAiChatHistory, removeBatchAiChatHistory } from '@/api/ai/aiChatHistory';
import type { AiChatHistory, AiChatHistoryParam } from '@/api/ai/aiChatHistory/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<LawNotaryList[]>([]);
const selection = ref<AiChatHistory[]>([]);
// 当前编辑数据
const current = ref<LawNotaryList | null>(null);
const current = ref<AiChatHistory | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示批量移动弹窗
@@ -90,7 +90,7 @@
if (filters) {
where.status = filters.status;
}
return pageLawNotaryList({
return pageAiChatHistory({
...where,
...orders,
page,
@@ -101,50 +101,68 @@
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: 'ID',
title: '',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90,
},
{
title: '用户',
dataIndex: ['user', 'username'],
key: 'uid',
title: '',
dataIndex: 'chatId',
key: 'chatId',
align: 'center',
},
{
title: '公证事项',
title: '',
dataIndex: 'conversationId',
key: 'conversationId',
align: 'center',
},
{
title: '',
dataIndex: 'userId',
key: 'userId',
align: 'center',
},
{
title: '',
dataIndex: 'content',
key: 'content',
align: 'center',
},
{
title: '用途',
dataIndex: 'application',
key: 'usage',
title: '',
dataIndex: 'reply',
key: 'reply',
align: 'center',
},
{
title: '使用地',
dataIndex: 'address',
key: 'address',
title: '状态, 0正常, 1冻结',
dataIndex: 'status',
key: 'status',
align: 'center',
},
{
title: '译文',
dataIndex: 'translate',
key: 'translate',
title: '是否删除, 0否, 1是',
dataIndex: 'deleted',
key: 'deleted',
align: 'center',
},
{
title: '提交时间',
title: '注册时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd')
},
{
title: '修改时间',
dataIndex: 'updateTime',
key: 'updateTime',
align: 'center',
},
{
title: '操作',
@@ -157,13 +175,13 @@
]);
/* 搜索 */
const reload = (where?: LawNotaryListParam) => {
const reload = (where?: AiChatHistoryParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: LawNotaryList) => {
const openEdit = (row?: AiChatHistory) => {
current.value = row ?? null;
showEdit.value = true;
};
@@ -174,9 +192,9 @@
};
/* 删除单个 */
const remove = (row: LawNotaryList) => {
const remove = (row: AiChatHistory) => {
const hide = message.loading('请求中..', 0);
removeLawNotaryList(row.id)
removeAiChatHistory(row.id)
.then((msg) => {
hide();
message.success(msg);
@@ -201,7 +219,7 @@
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchLawNotaryList(selection.value.map((d) => d.id))
removeBatchAiChatHistory(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
@@ -221,7 +239,7 @@
};
/* 自定义行属性 */
const customRow = (record: LawNotaryList) => {
const customRow = (record: AiChatHistory) => {
return {
// 行点击事件
onClick: () => {
@@ -238,7 +256,7 @@
<script lang="ts">
export default {
name: 'LawNotaryList'
name: 'AiChatHistory'
};
</script>
@@ -19,6 +19,13 @@
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="" name="conversationId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.conversationId"
/>
</a-form-item>
<a-form-item label="" name="userId">
<a-input
allow-clear
@@ -26,54 +33,32 @@
v-model:value="form.userId"
/>
</a-form-item>
<a-form-item label="" name="title">
<a-form-item label="" name="roleId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.title"
v-model:value="form.roleId"
/>
</a-form-item>
<a-form-item label="" name="company">
<a-form-item label="" name="roleName">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.company"
v-model:value="form.roleName"
/>
</a-form-item>
<a-form-item label="" name="address">
<a-form-item label="" name="roleDesc">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.address"
v-model:value="form.roleDesc"
/>
</a-form-item>
<a-form-item label="" name="date">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.date"
/>
</a-form-item>
<a-form-item label="" name="pics">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.pics"
/>
</a-form-item>
<a-form-item label="" name="video">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.video"
/>
</a-form-item>
<a-form-item label="" name="content">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.content"
/>
<a-form-item label="状态, 0正常, 1冻结" 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-item label="是否删除, 0否, 1是" name="deleted">
<a-input
@@ -89,6 +74,13 @@
v-model:value="form.updateTime"
/>
</a-form-item>
<a-form-item label="" name="title">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.title"
/>
</a-form-item>
</a-form>
</ele-modal>
</template>
@@ -97,8 +89,8 @@
import { ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject, uuid } from 'ele-admin-pro';
import { addLawFeedback, updateLawFeedback } from '@/api/law/lawFeedback';
import { LawFeedback } from '@/api/law/lawFeedback/model';
import { addAiChatList, updateAiChatList } from '@/api/ai/aiChatList';
import { AiChatList } from '@/api/ai/aiChatList/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
@@ -115,7 +107,7 @@
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: LawFeedback | null;
data?: AiChatList | null;
}>();
const emit = defineEmits<{
@@ -131,20 +123,19 @@
const formRef = ref<FormInstance | null>(null);
// 用户信息
const form = reactive<LawFeedback>({
const form = reactive<AiChatList>({
id: undefined,
conversationId: undefined,
userId: undefined,
title: undefined,
company: undefined,
address: undefined,
date: undefined,
pics: undefined,
video: undefined,
content: undefined,
roleId: undefined,
roleName: undefined,
roleDesc: undefined,
status: undefined,
deleted: undefined,
tenantId: undefined,
createTime: undefined,
updateTime: undefined,
title: undefined,
});
/* 更新visible */
@@ -171,7 +162,7 @@
const formData = {
...form
};
const saveOrUpdate = isUpdate.value ? updateLawFeedback : addLawFeedback;
const saveOrUpdate = isUpdate.value ? updateAiChatList : addAiChatList;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
+269
View File
@@ -0,0 +1,269 @@
<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"
:customRow="customRow"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'image'">
<a-image :src="record.image" :width="50" />
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 1" color="green">显示</a-tag>
<a-tag v-if="record.status === 0" color="red">隐藏</a-tag>
</template>
<template v-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>
<!-- 编辑弹窗 -->
<AiChatListEdit v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</div>
</template>
<script lang="ts" setup>
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } 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 Search from './components/search.vue';
import AiChatListEdit from './components/aiChatListEdit.vue';
import { pageAiChatList, removeAiChatList, removeBatchAiChatList } from '@/api/ai/aiChatList';
import type { AiChatList, AiChatListParam } from '@/api/ai/aiChatList/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<AiChatList[]>([]);
// 当前编辑数据
const current = ref<AiChatList | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示批量移动弹窗
const showMove = ref(false);
// 加载状态
const loading = ref(true);
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
if (filters) {
where.status = filters.status;
}
return pageAiChatList({
...where,
...orders,
page,
limit
});
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: '',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90,
},
{
title: '',
dataIndex: 'conversationId',
key: 'conversationId',
align: 'center',
},
{
title: '',
dataIndex: 'userId',
key: 'userId',
align: 'center',
},
{
title: '',
dataIndex: 'roleId',
key: 'roleId',
align: 'center',
},
{
title: '',
dataIndex: 'roleName',
key: 'roleName',
align: 'center',
},
{
title: '',
dataIndex: 'roleDesc',
key: 'roleDesc',
align: 'center',
},
{
title: '状态, 0正常, 1冻结',
dataIndex: 'status',
key: 'status',
align: 'center',
},
{
title: '是否删除, 0否, 1是',
dataIndex: 'deleted',
key: 'deleted',
align: 'center',
},
{
title: '注册时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd')
},
{
title: '修改时间',
dataIndex: 'updateTime',
key: 'updateTime',
align: 'center',
},
{
title: '',
dataIndex: 'title',
key: 'title',
align: 'center',
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
/* 搜索 */
const reload = (where?: AiChatListParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: AiChatList) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 打开批量移动弹窗 */
const openMove = () => {
showMove.value = true;
};
/* 删除单个 */
const remove = (row: AiChatList) => {
const hide = message.loading('请求中..', 0);
removeAiChatList(row.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchAiChatList(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
/* 查询 */
const query = () => {
loading.value = true;
};
/* 自定义行属性 */
const customRow = (record: AiChatList) => {
return {
// 行点击事件
onClick: () => {
// console.log(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
}
};
};
query();
</script>
<script lang="ts">
export default {
name: 'AiChatList'
};
</script>
<style lang="less" scoped></style>
+262
View File
@@ -0,0 +1,262 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<!-- 搜索栏 -->
<a-space :size="10" style="flex-wrap: wrap; margin-bottom: 16px">
<a-input-search
v-model:value="keyword"
placeholder="按知识库名称搜索"
allow-clear
style="width: 260px"
@search="handleSearch"
@change="onKeywordChange"
/>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">重置</a-button>
</a-space>
<!-- 列表 -->
<a-spin :spinning="loading">
<a-empty v-if="!loading && list.length === 0" description="暂无知识库" />
<a-row v-else :gutter="[16, 16]">
<a-col
v-for="item in list"
:key="item.id"
:xs="24"
:sm="12"
:md="8"
:lg="6"
>
<a-card
hoverable
class="kb-card"
:body-style="{ padding: '16px' }"
>
<!-- 顶部:图标 + 名称 -->
<div class="kb-card-header">
<div class="kb-icon">
<BookOutlined style="font-size: 22px; color: #1677ff" />
</div>
<div class="kb-name" :title="item.name">{{ item.name }}</div>
</div>
<!-- 描述 -->
<div class="kb-desc" v-if="item.description">
{{ item.description }}
</div>
<div class="kb-desc empty" v-else>暂无描述</div>
<!-- 统计信息 -->
<a-row class="kb-stats" :gutter="8">
<a-col :span="8" class="kb-stat-item">
<div class="kb-stat-value">{{ item.total_documents ?? item.document_count ?? 0 }}</div>
<div class="kb-stat-label">文档数</div>
</a-col>
<a-col :span="8" class="kb-stat-item">
<div class="kb-stat-value">{{ item.total_available_documents ?? 0 }}</div>
<div class="kb-stat-label">可用文档</div>
</a-col>
<a-col :span="8" class="kb-stat-item">
<div class="kb-stat-value">{{ formatWordCount(item.word_count) }}</div>
<div class="kb-stat-label">字数</div>
</a-col>
</a-row>
<!-- 底部标签 -->
<div class="kb-footer">
<a-tag v-if="item.enable_api" color="success">API已开启</a-tag>
<a-tag v-else color="default">API未开启</a-tag>
<a-tag v-if="item.indexing_technique === 'high_quality'" color="blue">高质量</a-tag>
<span class="kb-time">{{ formatTime(item.updated_at) }}</span>
</div>
</a-card>
</a-col>
</a-row>
</a-spin>
<!-- 分页 -->
<div class="kb-pagination" v-if="total > 0">
<a-pagination
v-model:current="page"
v-model:pageSize="limit"
:total="total"
:page-size-options="['10', '20', '50']"
show-size-changer
show-quick-jumper
:show-total="(t: number) => `共 ${t} 条`"
@change="fetchList"
@showSizeChange="onPageSizeChange"
/>
</div>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { BookOutlined, SearchOutlined } from '@ant-design/icons-vue';
import { listKnowledgeBase } from '@/api/ai/knowledgeBase';
import type { KnowledgeBase } from '@/api/ai/knowledgeBase/model';
const keyword = ref<string>('');
const page = ref<number>(1);
const limit = ref<number>(20);
const total = ref<number>(0);
const loading = ref<boolean>(false);
const list = ref<KnowledgeBase[]>([]);
const fetchList = async () => {
loading.value = true;
try {
const res = await listKnowledgeBase({
page: page.value,
limit: limit.value,
keyword: keyword.value || undefined
});
list.value = res?.data ?? [];
total.value = res?.total ?? 0;
} catch (e: any) {
message.error(e?.message ?? '获取知识库列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchList();
};
const handleReset = () => {
keyword.value = '';
page.value = 1;
fetchList();
};
const onKeywordChange = () => {
if (!keyword.value) {
page.value = 1;
fetchList();
}
};
const onPageSizeChange = (_current: number, size: number) => {
page.value = 1;
limit.value = size;
fetchList();
};
const formatWordCount = (count?: number): string => {
if (count == null) return '0';
if (count >= 10000) return (count / 10000).toFixed(1) + 'w';
return String(count);
};
const formatTime = (ts?: number): string => {
if (!ts) return '';
const d = new Date(ts * 1000);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
onMounted(() => {
fetchList();
});
</script>
<style scoped>
.kb-card {
border-radius: 8px;
transition: box-shadow 0.2s;
}
.kb-card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.kb-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
background: #e6f4ff;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.kb-name {
font-weight: 600;
font-size: 15px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.kb-desc {
font-size: 13px;
color: #666;
min-height: 36px;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.kb-desc.empty {
color: #bbb;
font-style: italic;
}
.kb-stats {
border-top: 1px solid #f0f0f0;
padding-top: 10px;
margin-bottom: 10px;
}
.kb-stat-item {
text-align: center;
}
.kb-stat-value {
font-weight: 600;
font-size: 16px;
color: #1677ff;
}
.kb-stat-label {
font-size: 11px;
color: #999;
}
.kb-footer {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
border-top: 1px solid #f0f0f0;
padding-top: 10px;
}
.kb-time {
margin-left: auto;
font-size: 12px;
color: #bbb;
}
.kb-pagination {
margin-top: 24px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -19,17 +19,16 @@
:wrapper-col="{ md: { span: 24 }, sm: { span: 24 } }"
layout="vertical"
>
<a-form-item label="文章分类" name="categoryId">
<a-form-item label="知识库分类" name="categoryId">
<category-select
:data="categoryList"
placeholder="请选择文章分类"
placeholder="请选择知识库分类"
v-model:value="form.categoryId"
/>
</a-form-item>
<a-form-item label="文章标题" name="title">
<a-input
allow-clear
:maxlength="20"
placeholder="请输入文章标题"
v-model:value="form.title"
/>
@@ -56,10 +55,8 @@
/>
</div>
</a-form-item>
<a-form-item label="文章来源" name="source">
<source-select
v-model:value="form.source"
/>
<a-form-item label="简介" name="source">
<a-input v-model:value="form.source" />
</a-form-item>
<a-form-item label="展现方式" name="showType">
<a-radio-group v-model:value="form.showType">
@@ -203,7 +200,7 @@ const rules = reactive<Record<string, Rule[]>>({
{
required: true,
type: "number",
message: "请选择文章分类",
message: "请选择知识库分类",
trigger: "blur"
}
],
@@ -267,7 +264,7 @@ const onUpload = (item) => {
url: data.url,
status: "done"
});
form.image = data.path;
form.image = data.url;
message.success("上传成功");
})
.catch((e) => {
@@ -421,7 +418,7 @@ watch(
if (props.data.image) {
images.value.push({
uid: 1,
url: FILE_THUMBNAIL + props.data.image,
url: props.data.image,
status: "done"
});
}
+1 -1
View File
@@ -10,7 +10,7 @@
<div>
<ele-toolbar theme="default">
<a-space :size="10">
<span>文章分类</span>
<span>知识库分类</span>
<a-button
v-permission="'cms:article:save'"
type="primary"
+1 -1
View File
@@ -32,7 +32,7 @@
<a-image
:width="45"
v-if="record.image"
:src="FILE_THUMBNAIL + record.image"
:src="record.image"
:preview="false"
/>
</template>
@@ -5,7 +5,7 @@
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '编辑用户填写法律文书内容' : '添加用户填写法律文书内容'"
:title="isUpdate ? '编辑' : '添加'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
@@ -19,18 +19,11 @@
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="" name="groupId">
<a-form-item label="" name="articleId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.groupId"
/>
</a-form-item>
<a-form-item label="" name="content">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.content"
v-model:value="form.articleId"
/>
</a-form-item>
<a-form-item label="" name="userId">
@@ -40,6 +33,12 @@
v-model:value="form.userId"
/>
</a-form-item>
<a-form-item label="状态, 0正常, 1冻结" 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-item label="是否删除, 0否, 1是" name="deleted">
<a-input
allow-clear
@@ -62,8 +61,8 @@
import { ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject, uuid } from 'ele-admin-pro';
import { addLawLegalDocContent, updateLawLegalDocContent } from '@/api/law/lawLegalDocContent';
import { LawLegalDocContent } from '@/api/law/lawLegalDocContent/model';
import { addArticleCollect, updateArticleCollect } from '@/api/cms/articleCollect';
import { ArticleCollect } from '@/api/cms/articleCollect/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
@@ -80,7 +79,7 @@
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: LawLegalDocContent | null;
data?: ArticleCollect | null;
}>();
const emit = defineEmits<{
@@ -96,11 +95,11 @@
const formRef = ref<FormInstance | null>(null);
// 用户信息
const form = reactive<LawLegalDocContent>({
const form = reactive<ArticleCollect>({
id: undefined,
groupId: undefined,
content: undefined,
articleId: undefined,
userId: undefined,
status: undefined,
deleted: undefined,
tenantId: undefined,
createTime: undefined,
@@ -131,7 +130,7 @@
const formData = {
...form
};
const saveOrUpdate = isUpdate.value ? updateLawLegalDocContent : addLawLegalDocContent;
const saveOrUpdate = isUpdate.value ? updateArticleCollect : addArticleCollect;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
@@ -45,7 +45,7 @@
</a-card>
<!-- 编辑弹窗 -->
<LawLegalDocContentEdit v-model:visible="showEdit" :data="current" @done="reload" />
<ArticleCollectEdit v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</div>
</template>
@@ -61,17 +61,17 @@
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import LawLegalDocContentEdit from './components/lawLegalDocContentEdit.vue';
import { pageLawLegalDocContent, removeLawLegalDocContent, removeBatchLawLegalDocContent } from '@/api/law/lawLegalDocContent';
import type { LawLegalDocContent, LawLegalDocContentParam } from '@/api/law/lawLegalDocContent/model';
import ArticleCollectEdit from './components/articleCollectEdit.vue';
import { pageArticleCollect, removeArticleCollect, removeBatchArticleCollect } from '@/api/cms/articleCollect';
import type { ArticleCollect, ArticleCollectParam } from '@/api/cms/articleCollect/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<LawLegalDocContent[]>([]);
const selection = ref<ArticleCollect[]>([]);
// 当前编辑数据
const current = ref<LawLegalDocContent | null>(null);
const current = ref<ArticleCollect | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示批量移动弹窗
@@ -90,7 +90,7 @@
if (filters) {
where.status = filters.status;
}
return pageLawLegalDocContent({
return pageArticleCollect({
...where,
...orders,
page,
@@ -109,14 +109,8 @@
},
{
title: '',
dataIndex: 'groupId',
key: 'groupId',
align: 'center',
},
{
title: '',
dataIndex: 'content',
key: 'content',
dataIndex: 'articleId',
key: 'articleId',
align: 'center',
},
{
@@ -125,6 +119,12 @@
key: 'userId',
align: 'center',
},
{
title: '状态, 0正常, 1冻结',
dataIndex: 'status',
key: 'status',
align: 'center',
},
{
title: '是否删除, 0否, 1是',
dataIndex: 'deleted',
@@ -132,7 +132,7 @@
align: 'center',
},
{
title: '创建时间',
title: '注册时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
@@ -157,13 +157,13 @@
]);
/* 搜索 */
const reload = (where?: LawLegalDocContentParam) => {
const reload = (where?: ArticleCollectParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: LawLegalDocContent) => {
const openEdit = (row?: ArticleCollect) => {
current.value = row ?? null;
showEdit.value = true;
};
@@ -174,9 +174,9 @@
};
/* 删除单个 */
const remove = (row: LawLegalDocContent) => {
const remove = (row: ArticleCollect) => {
const hide = message.loading('请求中..', 0);
removeLawLegalDocContent(row.id)
removeArticleCollect(row.id)
.then((msg) => {
hide();
message.success(msg);
@@ -201,7 +201,7 @@
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchLawLegalDocContent(selection.value.map((d) => d.id))
removeBatchArticleCollect(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
@@ -221,7 +221,7 @@
};
/* 自定义行属性 */
const customRow = (record: LawLegalDocContent) => {
const customRow = (record: ArticleCollect) => {
return {
// 行点击事件
onClick: () => {
@@ -238,7 +238,7 @@
<script lang="ts">
export default {
name: 'LawLegalDocContent'
name: 'ArticleCollect'
};
</script>
@@ -5,7 +5,7 @@
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '编辑用户填写法律计算器内容' : '添加用户填写法律计算器内容'"
:title="isUpdate ? '编辑' : '添加'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
@@ -19,11 +19,18 @@
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="" name="groupId">
<a-form-item label="" name="articleId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.groupId"
v-model:value="form.articleId"
/>
</a-form-item>
<a-form-item label="" name="userId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.userId"
/>
</a-form-item>
<a-form-item label="" name="content">
@@ -33,12 +40,11 @@
v-model:value="form.content"
/>
</a-form-item>
<a-form-item label="" name="userId">
<a-input
allow-clear
placeholder="请输入"
v-model:value="form.userId"
/>
<a-form-item label="状态, 0正常, 1冻结" 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-item label="是否删除, 0否, 1是" name="deleted">
<a-input
@@ -62,8 +68,8 @@
import { ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject, uuid } from 'ele-admin-pro';
import { addLawLegalCalContent, updateLawLegalCalContent } from '@/api/law/lawLegalCalContent';
import { LawLegalCalContent } from '@/api/law/lawLegalCalContent/model';
import { addArticleComment, updateArticleComment } from '@/api/cms/articleComment';
import { ArticleComment } from '@/api/cms/articleComment/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
@@ -80,7 +86,7 @@
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: LawLegalCalContent | null;
data?: ArticleComment | null;
}>();
const emit = defineEmits<{
@@ -96,11 +102,12 @@
const formRef = ref<FormInstance | null>(null);
// 用户信息
const form = reactive<LawLegalCalContent>({
const form = reactive<ArticleComment>({
id: undefined,
groupId: undefined,
content: undefined,
articleId: undefined,
userId: undefined,
content: undefined,
status: undefined,
deleted: undefined,
tenantId: undefined,
createTime: undefined,
@@ -131,7 +138,7 @@
const formData = {
...form
};
const saveOrUpdate = isUpdate.value ? updateLawLegalCalContent : addLawLegalCalContent;
const saveOrUpdate = isUpdate.value ? updateArticleComment : addArticleComment;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
@@ -45,7 +45,7 @@
</a-card>
<!-- 编辑弹窗 -->
<LawLegalCalContentEdit v-model:visible="showEdit" :data="current" @done="reload" />
<ArticleCommentEdit v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</div>
</template>
@@ -61,17 +61,17 @@
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import LawLegalCalContentEdit from './components/lawLegalCalContentEdit.vue';
import { pageLawLegalCalContent, removeLawLegalCalContent, removeBatchLawLegalCalContent } from '@/api/law/lawLegalCalContent';
import type { LawLegalCalContent, LawLegalCalContentParam } from '@/api/law/lawLegalCalContent/model';
import ArticleCommentEdit from './components/articleCommentEdit.vue';
import { pageArticleComment, removeArticleComment, removeBatchArticleComment } from '@/api/cms/articleComment';
import type { ArticleComment, ArticleCommentParam } from '@/api/cms/articleComment/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<LawLegalCalContent[]>([]);
const selection = ref<ArticleComment[]>([]);
// 当前编辑数据
const current = ref<LawLegalCalContent | null>(null);
const current = ref<ArticleComment | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示批量移动弹窗
@@ -90,7 +90,7 @@
if (filters) {
where.status = filters.status;
}
return pageLawLegalCalContent({
return pageArticleComment({
...where,
...orders,
page,
@@ -109,14 +109,8 @@
},
{
title: '',
dataIndex: 'groupId',
key: 'groupId',
align: 'center',
},
{
title: '',
dataIndex: 'content',
key: 'content',
dataIndex: 'articleId',
key: 'articleId',
align: 'center',
},
{
@@ -125,6 +119,18 @@
key: 'userId',
align: 'center',
},
{
title: '',
dataIndex: 'content',
key: 'content',
align: 'center',
},
{
title: '状态, 0正常, 1冻结',
dataIndex: 'status',
key: 'status',
align: 'center',
},
{
title: '是否删除, 0否, 1是',
dataIndex: 'deleted',
@@ -132,7 +138,7 @@
align: 'center',
},
{
title: '创建时间',
title: '注册时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
@@ -157,13 +163,13 @@
]);
/* 搜索 */
const reload = (where?: LawLegalCalContentParam) => {
const reload = (where?: ArticleCommentParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: LawLegalCalContent) => {
const openEdit = (row?: ArticleComment) => {
current.value = row ?? null;
showEdit.value = true;
};
@@ -174,9 +180,9 @@
};
/* 删除单个 */
const remove = (row: LawLegalCalContent) => {
const remove = (row: ArticleComment) => {
const hide = message.loading('请求中..', 0);
removeLawLegalCalContent(row.id)
removeArticleComment(row.id)
.then((msg) => {
hide();
message.success(msg);
@@ -201,7 +207,7 @@
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchLawLegalCalContent(selection.value.map((d) => d.id))
removeBatchArticleComment(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
@@ -221,7 +227,7 @@
};
/* 自定义行属性 */
const customRow = (record: LawLegalCalContent) => {
const customRow = (record: ArticleComment) => {
return {
// 行点击事件
onClick: () => {
@@ -238,7 +244,7 @@
<script lang="ts">
export default {
name: 'LawLegalCalContent'
name: 'ArticleComment'
};
</script>
@@ -0,0 +1,358 @@
<!-- 编辑弹窗 -->
<template>
<ele-modal
:width="1000"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:confirm-loading="loading"
:title="isUpdate ? '编辑稿件' : '添加稿件'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="
styleResponsive ? { md: 4, sm: 5, xs: 24 } : { flex: '100px' }
"
:wrapper-col="
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="标题" name="title">
<a-input
allow-clear
placeholder="请输入稿件标题"
v-model:value="form.title"
/>
</a-form-item>
<a-form-item label="内容" name="content">
<div class="editor-wrapper">
<Toolbar
class="editor-toolbar"
:editor="editorRef"
:defaultConfig="toolbarConfig"
mode="default"
/>
<Editor
v-model="contentHtml"
class="editor-content"
:defaultConfig="editorConfig"
mode="default"
@onCreated="handleCreated"
/>
</div>
<div class="editor-tip">支持直接粘贴内容,粘贴图片时会自动上传。</div>
</a-form-item>
<a-form-item label="封面" name="cover">
<ele-image-upload
v-model:value="coverList"
:limit="1"
:drag="true"
:upload-handler="uploadHandler"
@upload="onUpload"
/>
</a-form-item>
<a-form-item label="排序" name="sortNumber">
<a-input-number
:min="0"
:max="9999"
class="ele-fluid"
placeholder="请输入排序号"
v-model:value="form.sortNumber"
/>
</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 { shallowRef, ref, reactive, watch, onBeforeUnmount } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject } from 'ele-admin-pro';
import { addManuscript, updateManuscript } from '@/api/cms/manuscript';
import { Manuscript } from '@/api/cms/manuscript/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form';
import { uploadFile } from '@/api/system/file';
import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import type {
IDomEditor,
IEditorConfig,
IToolbarConfig
} from '@wangeditor/editor';
import '@wangeditor/editor/dist/css/style.css';
const isUpdate = ref(false);
const useForm = Form.useForm;
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const props = defineProps<{
visible: boolean;
data?: Manuscript | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const loading = ref(false);
const maxable = ref(true);
const formRef = ref<FormInstance | null>(null);
const editorRef = shallowRef<IDomEditor>();
const contentHtml = ref('');
const coverList = ref<ItemType[]>([]);
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: ['group-video']
};
const editorConfig: Partial<IEditorConfig> = {
placeholder: '请输入稿件内容,支持粘贴图片自动上传',
MENU_CONF: {
uploadImage: {
allowedFileTypes: ['image/*'],
maxFileSize: 2 * 1024 * 1024,
base64LimitSize: 0,
async customUpload(file: File, insertFn: (url: string) => void) {
if (!file.type.startsWith('image/')) {
message.error('只能上传图片');
return;
}
try {
const result = await uploadFile(file);
insertFn(result.url);
} catch (e: any) {
message.error(e.message || '图片上传失败');
}
}
}
}
};
const form = reactive<Manuscript>({
id: undefined,
title: undefined,
content: undefined,
cover: undefined,
deleted: undefined,
tenantId: undefined,
createTime: undefined,
updateTime: undefined,
userId: undefined,
sortNumber: 0,
status: 1
});
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const hasEditorContent = (html?: string) => {
const text = String(html || '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, '')
.trim();
return !!text || /<img\b/i.test(String(html || ''));
};
const rules = reactive({
title: [
{
required: true,
type: 'string',
whitespace: true,
message: '请输入稿件标题',
trigger: 'blur'
}
],
content: [
{
validator: async () => {
if (!hasEditorContent(contentHtml.value)) {
return Promise.reject(new Error('请输入稿件内容'));
}
return Promise.resolve();
},
trigger: 'change'
}
],
sortNumber: [
{
required: true,
type: 'number',
message: '请输入排序号',
trigger: 'change'
}
]
});
const { resetFields, validate } = useForm(form, rules);
const handleCreated = (editor: IDomEditor) => {
editorRef.value = editor;
};
const uploadHandler = (file: File) => {
const item: ItemType = {
file,
uid: (file as any).uid,
name: file.name
};
if (!file.type.startsWith('image/')) {
message.error('只能选择图片');
return;
}
if (file.size / 1024 / 1024 > 2) {
message.error('大小不能超过 2MB');
return;
}
onUpload(item);
};
const onUpload = (item: ItemType) => {
const { file } = item;
uploadFile(file as File)
.then((data) => {
coverList.value = [
{
uid: data.id,
url: data.url,
status: 'done'
}
];
form.cover = data.url;
message.success('上传成功');
})
.catch((e) => {
message.error(e.message);
});
};
const resetEditor = () => {
contentHtml.value = '';
};
const save = async () => {
if (!formRef.value) {
return;
}
try {
await formRef.value.validate();
loading.value = true;
const formData = {
...form,
content: contentHtml.value
};
const saveOrUpdate = isUpdate.value ? updateManuscript : addManuscript;
const msg = await saveOrUpdate(formData);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
if (!e?.errorFields) {
message.error(e.message || '保存失败');
}
} finally {
loading.value = false;
}
};
watch(contentHtml, () => {
form.content = contentHtml.value;
validate('content').catch(() => {});
});
watch(
coverList,
(list) => {
if (!list.length) {
form.cover = '';
}
},
{ deep: true }
);
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
assignObject(form, props.data);
contentHtml.value = props.data.content || '';
coverList.value = props.data.cover
? [
{
uid: props.data.id || 1,
url: props.data.cover,
status: 'done'
}
]
: [];
isUpdate.value = true;
} else {
resetFields();
resetEditor();
coverList.value = [];
form.status = 1;
form.sortNumber = 0;
isUpdate.value = false;
}
} else {
resetFields();
resetEditor();
coverList.value = [];
}
},
{ immediate: true }
);
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor != null) {
editor.destroy();
}
});
</script>
<style lang="less" scoped>
.editor-wrapper {
border: 1px solid #d9d9d9;
border-radius: 6px;
overflow: hidden;
}
.editor-toolbar {
border-bottom: 1px solid #f0f0f0;
}
.editor-content {
min-height: 360px;
}
.editor-content :deep(.w-e-text-container) {
min-height: 360px;
}
.editor-tip {
margin-top: 8px;
color: #999;
font-size: 12px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,177 @@
<template>
<ele-modal
:width="860"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:confirm-loading="loading"
title="审核历史"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-alert
v-if="!data?.id"
type="warning"
show-icon
message="未选择稿件,无法新增审核记录"
style="margin-bottom: 12px"
/>
<a-descriptions
v-if="data?.id"
size="small"
:column="2"
style="margin-bottom: 12px"
>
<a-descriptions-item label="稿件ID">
{{ data?.id }}
</a-descriptions-item>
<a-descriptions-item label="标题">
{{ data?.title || '-' }}
</a-descriptions-item>
</a-descriptions>
<div style="overflow-y: auto; padding: 10px 0" class="ele-scrollbar-hover">
<a-timeline v-if="historyData.length > 0">
<a-timeline-item
v-for="item in historyData"
:key="item.id"
:color="item.status === 1 ? 'green' : item.status === 0 ? 'red' : 'gray'"
>
<div style="margin-bottom: 8px">
<span style="color: rgba(0, 0, 0, 0.45)">
{{ toDateString(item.createTime, 'yyyy-MM-dd HH:mm:ss') }}
</span>
<a-tag
:color="item.status === 1 ? 'success' : item.status === 0 ? 'error' : 'default'"
style="margin-left: 8px"
>
{{ item.status === 1 ? '通过' : item.status === 0 ? '不通过' : '-' }}
</a-tag>
</div>
<div v-if="item.user?.realName" style="margin-bottom: 4px">
<span style="font-weight: 500">审核人:</span>
<span>{{ item.user.realName }}</span>
</div>
<div v-if="item.content" style="color: rgba(0, 0, 0, 0.85)">
{{ item.content }}
</div>
</a-timeline-item>
</a-timeline>
<a-empty v-else description="暂无审核记录" :image="simpleImage" />
</div>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { Form, message, Empty } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue/es/form';
import { toDateString } from 'ele-admin-pro';
import { storeToRefs } from 'pinia';
import { useThemeStore } from '@/store/modules/theme';
import { addReviewList } from '@/api/gxmu/reviewList';
import type { ReviewList } from '@/api/gxmu/reviewList/model';
import type { Manuscript } from '@/api/cms/manuscript/model';
const REVIEW_MODULE = 'cms_manuscript';
const props = defineProps<{
visible: boolean;
data?: Manuscript | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const useForm = Form.useForm;
const loading = ref(false);
const maxable = ref(true);
const formRef = ref<FormInstance | null>(null);
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
const form = reactive<ReviewList>({
module: REVIEW_MODULE,
pk: undefined,
status: undefined,
content: undefined
});
const rules = reactive({
status: [
{ required: true, type: 'number', message: '请选择审核状态', trigger: 'change' }
]
});
const { resetFields } = useForm(form, rules);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const historyData = computed(() => {
const list = props.data?.reviewList ?? [];
return [...list].reverse();
});
const save = () => {
if (!props.data?.id) {
message.error('未选择稿件');
return;
}
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const payload: ReviewList = {
module: REVIEW_MODULE,
pk: props.data?.id,
status: form.status,
content: form.content
};
addReviewList(payload)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
form.module = REVIEW_MODULE;
form.pk = props.data?.id;
form.status = undefined;
form.content = undefined;
} else {
resetFields();
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
.ele-scrollbar-hover :deep(.ant-timeline-item-last > .ant-timeline-item-content) {
min-height: auto;
}
</style>
+276
View File
@@ -0,0 +1,276 @@
<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"
:customRow="customRow"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'cover'">
<a-image :src="record.cover" :width="50" />
</template>
<template v-if="column.key === 'currentReviewStatus'">
<template v-if="getLastReview(record)?.status === 1">
<a-tag color="green">通过</a-tag>
</template>
<template v-else-if="getLastReview(record)?.status === 2">
<a-tag color="red">不通过</a-tag>
</template>
<template v-else>
<a-tag>未审核</a-tag>
</template>
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 0">待审核</a-tag>
<a-tag v-if="record.status === 1" color="green">显示</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">修改</a>
<a-divider type="vertical" />
<a @click="openReviewModal(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>
<!-- 编辑弹窗 -->
<ManuscriptEdit v-model:visible="showEdit" :data="current" @done="reload" />
<!-- 新增审核记录弹窗 -->
<ReviewRecordModal
v-model:visible="showReviewModal"
:data="reviewTarget"
@done="reload"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } 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 Search from './components/search.vue';
import ManuscriptEdit from './components/manuscriptEdit.vue';
import ReviewRecordModal from './components/reviewRecordModal.vue';
import { pageManuscript, removeManuscript, removeBatchManuscript } from '@/api/cms/manuscript';
import type { Manuscript, ManuscriptParam } from '@/api/cms/manuscript/model';
import type { ReviewList } from '@/api/gxmu/reviewList/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<Manuscript[]>([]);
// 当前编辑数据
const current = ref<Manuscript | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示新增审核记录弹窗
const showReviewModal = ref(false);
// 当前要新增审核记录的稿件
const reviewTarget = ref<Manuscript | null>(null);
// 是否显示批量移动弹窗
const showMove = ref(false);
// 加载状态
const loading = ref(true);
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
if (filters) {
where.status = filters.status;
}
return pageManuscript({
...where,
...orders,
page,
limit
});
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90,
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
align: 'center',
},
{
title: '封面',
dataIndex: 'cover',
key: 'cover',
align: 'center',
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
sorter: true,
ellipsis: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
width: 260,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
/* 搜索 */
const reload = (where?: ManuscriptParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: Manuscript) => {
current.value = row ?? null;
showEdit.value = true;
};
const getLastReview = (row: Manuscript): ReviewList | undefined => {
const list = row.reviewList ?? [];
return list.length ? list[list.length - 1] : undefined;
};
/* 打开新增审核记录弹窗 */
const openReviewModal = (row: Manuscript) => {
reviewTarget.value = row;
showReviewModal.value = true;
};
/* 打开批量移动弹窗 */
const openMove = () => {
showMove.value = true;
};
/* 删除单个 */
const remove = (row: Manuscript) => {
const hide = message.loading('请求中..', 0);
removeManuscript(row.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchManuscript(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
/* 查询 */
const query = () => {
loading.value = true;
};
/* 自定义行属性 */
const customRow = (record: Manuscript) => {
return {
// 行点击事件
onClick: () => {
// console.log(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
}
};
};
query();
</script>
<script lang="ts">
export default {
name: 'Manuscript'
};
</script>
<style lang="less" scoped></style>
@@ -1,300 +0,0 @@
<!-- 编辑弹窗 -->
<template>
<ele-modal
:width="740"
:visible="visible"
:confirm-loading="loading"
:title="isUpdate ? '修改分类' : '新建分类'"
:body-style="{ paddingBottom: '8px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="styleResponsive ? { md: 6, sm: 4, xs: 24 } : { flex: '90px' }"
:wrapper-col="
styleResponsive ? { md: 18, sm: 20, xs: 24 } : { flex: '1' }
"
>
<a-row :gutter="16">
<a-col
v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
>
<a-form-item label="上级分类" name="parentId">
<a-tree-select
allow-clear
:tree-data="categoryList"
tree-default-expand-all
placeholder="请选择上级分类"
:value="form.parentId || undefined"
:dropdown-style="{ maxHeight: '360px', overflow: 'auto' }"
@update:value="(value?: number) => (form.parentId = value)"
/>
</a-form-item>
<a-form-item label="分类名称" name="title">
<a-input
allow-clear
placeholder="请输入分类名称"
v-model:value="form.title"
@pressEnter="save"
/>
</a-form-item>
</a-col>
<a-col
v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
>
<a-form-item label="排序号" name="sortNumber">
<a-input-number
:min="0"
:max="99999"
class="ele-fluid"
placeholder="请输入排序号"
v-model:value="form.sortNumber"
/>
</a-form-item>
<a-form-item label="是否展示">
<a-switch
checked-children="是"
un-checked-children="否"
:checked="form.status === 0"
@update:checked="updateHideValue"
/>
</a-form-item>
</a-col>
</a-row>
<div style="margin-bottom: 22px">
<a-divider />
</div>
<a-row :gutter="16">
<a-col
v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
>
<a-form-item label="索引图标" name="image">
<ele-image-upload
v-model:value="images"
:item-style="{ width: '45px', height: '45px' }"
:limit="1"
@upload="onUpload"
@remove="onClose"
/>
</a-form-item>
</a-col>
<a-col
v-bind="styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }"
>
<a-form-item
label="备注"
name="comments"
:label-col="
styleResponsive ? { md: 3, sm: 4, xs: 24 } : { flex: '90px' }
"
:wrapper-col="
styleResponsive ? { md: 21, sm: 20, xs: 24 } : { flex: '1' }
"
>
<a-textarea
:rows="4"
:maxlength="200"
placeholder="请输入备注信息"
v-model:value="form.comments"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { message } from 'ant-design-vue/es';
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
import { storeToRefs } from 'pinia';
import { useThemeStore } from '@/store/modules/theme';
import useFormData from '@/utils/use-form-data';
import type { Category } from '@/api/goods/category/model';
import { addCategory, updateCategory } from '@/api/goods/category';
import { FILE_SERVER } from "@/config/setting";
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
import { uploadFile } from "@/api/system/file";
import { LoginRecordParam } from "@/api/system/login-record/model";
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: Category | null;
// 上级分类id
parentId?: number;
// 全部分类数据
categoryList: Category[];
}>();
// 表单数据
const { form, resetFields, assignFields } = useFormData<Category>({
title: '',
categoryId: undefined,
parentId: undefined,
image: '',
status: 0,
sortNumber: 100
});
//
const formRef = ref<FormInstance | null>(null);
// 是否是修改
const isUpdate = ref(false);
// 提交状态
const loading = ref(false);
// 已上传数据, 可赋初始值用于回显
const images = ref([]);
// 表单验证规则
const rules = reactive<Record<string, Rule[]>>({
title: [
{
required: true,
message: '请输入分类名称',
type: 'string',
trigger: 'blur'
}
],
sortNumber: [
{
required: true,
message: '请输入排序号',
type: 'number',
trigger: 'blur'
}
],
meta: [
{
type: 'string',
validator: async (_rule: Rule, value: string) => {
if (value) {
const msg = '请输入正确的JSON格式';
try {
const obj = JSON.parse(value);
if (typeof obj !== 'object' || obj === null) {
return Promise.reject(msg);
}
} catch (_e) {
return Promise.reject(msg);
}
}
return Promise.resolve();
},
trigger: 'blur'
}
]
});
// 上传文件
const onUpload = (d: ItemType) => {
uploadFile(<File>d.file)
.then((result) => {
form.image = result.path;
message.success('上传成功');
})
.catch((e) => {
message.error(e.message);
});
};
const onClose = () => {
form.image = ''
}
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const categoryForm = {
...form,
parentId: form.parentId || 0
};
const saveOrUpdate = isUpdate.value ? updateCategory : addCategory;
saveOrUpdate(categoryForm)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const updateHideValue = (value: boolean) => {
form.status = value ? 0 : 1;
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
// 头像赋值
images.value = [];
if(props.data.image){
images.value.push({ uid:1, url: props.data.image, status: '' });
}
assignFields({
...props.data,
parentId:
props.data.parentId === 0 ? undefined : props.data.parentId
});
isUpdate.value = true;
} else {
form.parentId = props.parentId;
isUpdate.value = false;
}
} else {
resetFields();
formRef.value?.clearValidate();
}
}
);
</script>
<script lang="ts">
import * as icons from '@/layout/menu-icons';
export default {
components: icons,
data() {
return {
iconData: [
{
title: '已引入的图标',
icons: Object.keys(icons)
}
]
};
}
};
</script>
-281
View File
@@ -1,281 +0,0 @@
<template>
<div class="ele-body">
<a-card :bordered="false">
<!-- 表格 -->
<ele-pro-table
ref="tableRef"
row-key="categoryId"
:columns="columns"
:datasource="datasource"
:parse-data="parseData"
:need-page="false"
:expand-icon-column-index="1"
:expanded-row-keys="expandedRowKeys"
:scroll="{ x: 1200 }"
cache-key="proGoodsCategoryTable"
@done="onDone"
@expand="onExpand"
>
<template #toolbar>
<a-space>
<a-button type="primary" class="ele-btn-icon" @click="openEdit()">
<template #icon>
<plus-outlined />
</template>
<span>新建</span>
</a-button>
<a-button type="dashed" class="ele-btn-icon" @click="expandAll">
展开全部
</a-button>
<a-button type="dashed" class="ele-btn-icon" @click="foldAll">
折叠全部
</a-button>
<!-- 搜索表单 -->
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入搜索关键词"
@search="search"
@pressEnter="search"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'menuType'">
<a-tag v-if="isExternalLink(record.path)" color="red">外链</a-tag>
<a-tag v-else-if="isExternalLink(record.component)" color="orange">
内链
</a-tag>
<a-tag v-else-if="isDirectory(record)" color="blue">目录</a-tag>
<a-tag v-else-if="record.menuType === 0" color="green">分类</a-tag>
<a-tag v-else-if="record.menuType === 1">按钮</a-tag>
</template>
<template v-else-if="column.key === 'title'">
<a-avatar
:size="20"
v-if="record.image"
shape="square"
:src="`${record.image}`"
/>
<a-tooltip :title="`分类ID:${record.categoryId}`">
<span>{{ record.title }}</span>
</a-tooltip>
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 0" color="green">显示</a-tag>
<a-tag v-if="record.status === 1" color="orange">隐藏</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="openEdit(null, record.categoryId)">添加</a>
<a-divider type="vertical" />
<a @click="openEdit(record)">修改</a>
<a-divider type="vertical" />
<a-popconfirm
placement="topRight"
title="确定要删除此分类吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<category-edit
v-model:visible="showEdit"
:data="current"
:parent-id="parentId"
:category-list="categoryData"
@done="reload"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue/es';
import { PlusOutlined } from '@ant-design/icons-vue';
import type {
DatasourceFunction,
ColumnItem,
EleProTableDone
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
messageLoading,
toDateString,
isExternalLink,
toTreeData,
eachTreeData
} from 'ele-admin-pro/es';
import type { EleProTable } from 'ele-admin-pro/es';
import CategoryEdit from './components/category-edit.vue';
import type { Category, CategoryParam } from '@/api/goods/category/model';
import { listCategory, removeCategory } from "@/api/goods/category";
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格列配置
const columns = ref<ColumnItem[]>([
{
key: 'index',
width: 48,
align: 'center',
fixed: 'left',
hideInSetting: true,
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
},
{
title: '分类名称',
key: 'title',
showSorterTooltip: false,
ellipsis: true
},
{
title: '排序',
dataIndex: 'sortNumber',
sorter: true,
width: 180,
showSorterTooltip: false
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
sorter: true,
width: 180,
showSorterTooltip: false,
customRender: ({ text }) => ['显示', '隐藏'][text]
},
{
title: '创建时间',
dataIndex: 'createTime',
sorter: true,
showSorterTooltip: false,
ellipsis: true,
width: 180,
customRender: ({ text }) => toDateString(text)
},
{
title: '操作',
key: 'action',
width: 200,
align: 'center'
}
]);
// 当前编辑数据
const current = ref<Category | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 上级分类id
const parentId = ref<number>();
// 分类数据
const categoryData = ref<Category[]>([]);
// 表格展开的行
const expandedRowKeys = ref<number[]>([]);
// 表格数据源
const datasource: DatasourceFunction = ({ where }) => {
return listCategory({ ...where });
};
/* 数据转为树形结构 */
const parseData = (data: Category[]) => {
return toTreeData({
data: data.map((d) => {
return { ...d, key: d.categoryId, value: d.categoryId };
}),
idField: 'categoryId',
parentIdField: 'parentId'
});
};
/* 表格渲染完成回调 */
const onDone: EleProTableDone<Category> = ({ data }) => {
categoryData.value = data;
};
/* 刷新表格 */
const reload = (where?: CategoryParam) => {
tableRef?.value?.reload({ where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: Category | null, id?: number) => {
current.value = row ?? null;
parentId.value = id;
showEdit.value = true;
};
const search = (searchText) => {
reload({ title: searchText });
};
/* 删除单个 */
const remove = (row: Category) => {
if (row.children?.length) {
message.error('请先删除子节点');
return;
}
const hide = messageLoading('请求中..', 0);
removeCategory(row.categoryId)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 展开全部 */
const expandAll = () => {
let keys: number[] = [];
eachTreeData(categoryData.value, (d) => {
if (d.children && d.children.length && d.categoryId) {
keys.push(d.categoryId);
}
});
expandedRowKeys.value = keys;
};
/* 折叠全部 */
const foldAll = () => {
expandedRowKeys.value = [];
};
/* 点击展开图标时触发 */
const onExpand = (expanded: boolean, record: Category) => {
if (expanded) {
expandedRowKeys.value = [
...expandedRowKeys.value,
record.categoryId as number
];
} else {
expandedRowKeys.value = expandedRowKeys.value.filter(
(d) => d !== record.categoryId
);
}
};
/* 判断是否是目录 */
const isDirectory = (d: Category) => {
return !!d.children?.length;
};
</script>
<script lang="ts">
export default {
name: 'Category'
};
</script>
@@ -1,158 +0,0 @@
<!-- 角色编辑弹窗 -->
<template>
<ele-modal
:width="460"
:visible="visible"
:confirm-loading="loading"
:title="isUpdate ? '修改角色' : '添加角色'"
:body-style="{ paddingBottom: '8px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="styleResponsive ? { md: 5, sm: 5, xs: 24 } : { flex: '90px' }"
:wrapper-col="
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="角色名称" name="roleName">
<a-input
allow-clear
:maxlength="20"
placeholder="请输入角色名称"
v-model:value="form.roleName"
/>
</a-form-item>
<a-form-item label="角色标识" name="roleCode">
<a-input
allow-clear
:maxlength="20"
placeholder="请输入角色标识"
v-model:value="form.roleCode"
/>
</a-form-item>
<a-form-item label="备注">
<a-textarea
:rows="4"
:maxlength="200"
placeholder="请输入备注"
v-model:value="form.comments"
/>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { message } from 'ant-design-vue/es';
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
import { storeToRefs } from 'pinia';
import { useThemeStore } from '@/store/modules/theme';
import useFormData from '@/utils/use-form-data';
import { addRole, updateRole } from '@/api/system/role';
import type { Role } from '@/api/system/role/model';
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: Role | null;
}>();
//
const formRef = ref<FormInstance | null>(null);
// 是否是修改
const isUpdate = ref(false);
// 提交状态
const loading = ref(false);
// 表单数据
const { form, resetFields, assignFields } = useFormData<Role>({
roleId: undefined,
roleName: '',
roleCode: '',
comments: ''
});
// 表单验证规则
const rules = reactive<Record<string, Rule[]>>({
roleName: [
{
required: true,
message: '请输入角色名称',
type: 'string',
trigger: 'blur'
}
],
roleCode: [
{
required: true,
message: '请输入角色标识',
type: 'string',
trigger: 'blur'
}
]
});
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateRole : addRole;
saveOrUpdate(form)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
assignFields(props.data);
isUpdate.value = true;
} else {
isUpdate.value = false;
}
} else {
resetFields();
formRef.value?.clearValidate();
}
}
);
</script>
-245
View File
@@ -1,245 +0,0 @@
<template>
<div class="ele-body">
<a-card :bordered="false">
<!-- 表格 -->
<ele-pro-table
ref="tableRef"
row-key="commentId"
:columns="columns"
:datasource="datasource"
v-model:selection="selection"
:scroll="{ x: 800 }"
cache-key="proShopCommentTable"
>
<template #toolbar>
<a-space>
<!-- <a-button type="primary" class="ele-btn-icon" @click="openEdit()">-->
<!-- <template #icon>-->
<!-- <plus-outlined />-->
<!-- </template>-->
<!-- <span>新建</span>-->
<!-- </a-button>-->
<a-button
danger
type="primary"
class="ele-btn-icon"
@click="removeBatch"
>
<template #icon>
<delete-outlined />
</template>
<span>删除</span>
</a-button>
<a-radio-group :defaultValue="score" @change="handleTabs">
<a-radio-button :value="0">全部</a-radio-button>
<a-radio-button :value="10">好评</a-radio-button>
<a-radio-button :value="20">中评</a-radio-button>
<a-radio-button :value="30">差评</a-radio-button>
</a-radio-group>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入关键词"
@search="reload"
@pressEnter="reload"
>
<template #addonBefore>
<a-select
v-model:value="type"
style="width: 100px; margin: -5px -12px"
>
<a-select-option value="goodsName">商品名称</a-select-option>
<a-select-option value="goodsCode">商品编码</a-select-option>
<a-select-option value="userId">用户ID</a-select-option>
</a-select>
</template>
</a-input-search>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">修改</a>
<a-divider type="vertical" />
<a-popconfirm
placement="topRight"
title="确定要删除此评价吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<comment-edit v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</template>
<script lang="ts" setup>
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue/es';
import {
PlusOutlined,
DeleteOutlined,
ExclamationCircleOutlined
} from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro/es';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import { messageLoading, toDateString } from 'ele-admin-pro/es';
import CommentEdit from './components/comment-edit.vue';
import {
pageComments,
removeComment,
removeComments
} from '@/api/goods/comment';
import type { Comment, CommentParam } from '@/api/goods/comment/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格列配置
const columns = ref<ColumnItem[]>([
{
key: 'index',
width: 48,
align: 'center',
fixed: 'left',
hideInSetting: true,
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
},
{
title: '商品信息',
dataIndex: 'goods',
showSorterTooltip: false
},
{
title: '买家',
dataIndex: 'user',
showSorterTooltip: false
},
{
title: '评分',
dataIndex: 'score',
sorter: true,
showSorterTooltip: false
},
{
title: '评价内容',
dataIndex: 'content'
},
{
title: '图片评价',
dataIndex: 'isPicture'
},
{
title: '评价时间',
dataIndex: 'createTime',
sorter: true,
showSorterTooltip: false,
ellipsis: true,
customRender: ({ text }) => toDateString(text)
},
{
title: '操作',
key: 'action',
width: 200,
align: 'center'
}
]);
// 表格选中数据
const selection = ref<Comment[]>([]);
// 当前编辑数据
const current = ref<Comment | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
const type = ref('goodsName');
const searchText = ref('');
const score = ref(0);
const handleTabs = (e) => {
score.value = e.target.value;
};
// 表格数据源
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
if (type.value == 'goodsName') {
where.goodsName = searchText.value;
}
if (type.value == 'goodsCode') {
where.goodsCode = searchText.value;
}
if (type.value == 'userId') {
where.userId = searchText.value;
}
return pageComments({ ...where, ...orders, page, limit });
};
/* 搜索 */
const reload = (where?: CommentParam) => {
selection.value = [];
tableRef?.value?.reload({ page: 1, where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: Comment) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 删除单个 */
const remove = (row: Comment) => {
const hide = messageLoading('请求中..', 0);
removeComment(row.commentId)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的评价吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = messageLoading('请求中..', 0);
removeComments(selection.value.map((d) => d.commentId))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
</script>
<script lang="ts">
export default {
name: 'ShopComment'
};
</script>
@@ -1,433 +0,0 @@
<template>
<div>
<a-form-item label="商品规格" :labelCol="labelCol" :wrapperCol="wrapperCol">
<div v-if="true" class="form-item-help" style="line-height: 36px">
<small>最多添加3个商品规格组,生成的SKU数量不能超出50个</small>
</div>
<!-- 规格组 -->
<div
class="spec-group"
>
<div class="spec-group-item clearfix">
<a-input
class="group-item-input"
:readOnly="isSpecLocked"
placeholder="请输入规格名称"
@change="onChangeSpecGroupIpt"
/>
<a
v-if="!isSpecLocked"
class="group-item-delete"
href="javascript:;"
@click="handleDeleteSpecGroup(index)"
>删除规格组</a
>
</div>
<div class="spec-value clearfix">
<div
class="spec-value-item"
>
<a-input
class="value-item-input"
:readOnly="isSpecLocked"
placeholder="请输入规格值"
@change="onChangeSpecValueIpt"
/>
<a-icon
v-if="!isSpecLocked"
class="icon-close"
theme="filled"
type="close-circle"
@click="handleDeleteSpecValue(index, idx)"
/>
</div>
<div v-if="!isSpecLocked" class="spec-value-add">
<a
class="group-item-delete"
href="javascript:;"
@click="handleAddSpecValue(index)"
>新增规格值</a
>
</div>
</div>
</div>
<!-- 添加规格按钮 -->
<a-button
v-if="!isSpecLocked && multiSpecData.specList.length < 3"
class="spec-group-add-btn"
icon="plus"
@click="handleAddSpecGroup"
>添加规格组</a-button
>
</a-form-item>
<a-form-item
v-show="multiSpecData.skuList.length"
label="SKU列表"
:labelCol="labelCol"
:wrapperCol="wrapperCol"
>
<!-- 批量设置 -->
<div v-if="multiSpecData.skuList.length > 1" class="sku-batch">
<span class="title">批量设置:</span>
<a-input-number
placeholder="商品价格"
:min="0.01"
:precision="2"
/>
<a-input-number
placeholder="成本价格"
:min="0"
:precision="2"
/>
<a-input-number
placeholder="库存数量"
:min="0"
:precision="0"
/>
<a-input-number
placeholder="商品重量"
:min="0"
:precision="2"
/>
<a-input
placeholder="sku编码"
/>
<a-button @click="handleSkuBatch">立即设置</a-button>
</div>
<!-- sku列表table -->
<a-table
class="sku-list"
:columns="multiSpecData.skuColumns"
:dataSource="multiSpecData.skuList"
:scroll="{ x: true }"
:pagination="false"
bordered
>
<!-- 预览图 -->
<template #image="text, item">
<SelectImage
v-model="item.image_id"
:defaultList="item.image_id > 0 && item.image ? [item.image] : []"
:width="50"
/>
</template>
<!-- 商品价格 -->
<template #goods_price="text, item">
<a-input-number
v-model="item.goods_price"
size="small"
:min="0.01"
:precision="2"
/>
</template>
<!-- 成本价格 -->
<template #line_price="text, item">
<a-input-number
v-model="item.line_price"
size="small"
:min="0"
:precision="2"
/>
</template>
<!-- 库存数量 -->
<template #stock_num="text, item">
<a-input-number
v-model="item.stock_num"
size="small"
:min="0"
:precision="0"
/>
</template>
<!-- 商品重量 -->
<template #goods_weight="text, item">
<a-input-number
v-model="item.goods_weight"
size="small"
:min="0"
:precision="2"
/>
</template>
<!-- sku编码 -->
<template #goods_sku_no="text, item">
<a-input v-model="item.goods_sku_no" size="small" />
</template>
</a-table>
</a-form-item>
</div>
</template>
<script>
import PropTypes from 'ant-design-vue/es/_util/vue-types';
import MultiSpecModel from '@/common/model/goods/MultiSpec';
// import { SelectImage } from '@/components';
export default {
components: {
// SelectImage
},
props: {
// 默认的规格列表
defaultSpecList: PropTypes.array.def([]),
// 默认的SKU列表
defaultSkuList: PropTypes.array.def([]),
// 商品规格是否锁定(锁定状态下不允许编辑规格)
isSpecLocked: PropTypes.bool.def(false)
},
data() {
return {
// 标签布局属性
labelCol: { span: 3 },
// 输入框布局属性
wrapperCol: { span: 21 },
// 商品多规格模型
MultiSpecModel: new MultiSpecModel(),
// MultiSpecModel: Object,
multiSpecData: {
// 规格列表
specList: [],
// SKU列表
skuList: []
}
};
},
watch: {
defaultSpecList(val) {
if (val.length && this.MultiSpecModel.isEmpty()) {
this.getData();
}
}
},
// 初始化数据
created() {
// 获取规格及SKU信息
this.getData();
},
methods: {
// 获取规格及SKU信息(展示)
getData() {
const { defaultSpecList, defaultSkuList } = this;
this.multiSpecData = this.MultiSpecModel.getData(
defaultSpecList,
defaultSkuList
);
},
// 获取规格及SKU信息(表单提交)
getFromSpecData() {
return this.MultiSpecModel.getFromSpecData();
},
// 添加规格组
handleAddSpecGroup() {
if (this.checkSkuMaxNum()) {
this.MultiSpecModel.handleAddSpecGroup();
}
},
// 删除规格组
handleDeleteSpecGroup(groupIndex) {
const app = this;
const modal = this.$confirm({
title: '您确定要删除该规格组吗?',
content: '删除后不可恢复',
onOk() {
// 删除元素
app.MultiSpecModel.handleDeleteSpecGroup(groupIndex);
// 关闭对话框
modal.destroy();
}
});
},
// 新增规格值
handleAddSpecValue(groupIndex) {
if (this.checkSkuMaxNum()) {
this.MultiSpecModel.handleAddSpecValue(groupIndex);
}
},
// 删除规格值
handleDeleteSpecValue(groupIndex, valueIndex) {
const app = this;
const modal = this.$confirm({
title: '您确定要删除该规格值吗?',
content: '删除后不可恢复',
onOk() {
// 删除元素
app.MultiSpecModel.handleDeleteSpecValue(groupIndex, valueIndex);
// 关闭对话框
modal.destroy();
}
});
},
// 规格组输入框change事件
onChangeSpecGroupIpt() {
// 更新skuList
this.MultiSpecModel.onUpdate(true);
},
// 规格值输入框change事件
onChangeSpecValueIpt(event, itm) {
// 更新skuList
this.MultiSpecModel.onUpdate(true);
},
// 验证最大sku数量
checkSkuMaxNum() {
const skuList = this.multiSpecData.skuList;
if (skuList.length >= 50) {
this.$message.error(
`生成的sku列表数量不能大于50个,当前数量:${skuList.length}个`,
2.5
);
return false;
}
return true;
},
// 批量设置sku事件
handleSkuBatch() {
this.MultiSpecModel.handleSkuBatch();
},
// 验证多规格表单
verifyForm() {
if (!this.MultiSpecModel.verifyForm()) {
this.$message.error(this.MultiSpecModel.getError(), 2);
return false;
}
return true;
}
}
};
</script>
<style lang="less" scoped>
// 商品多规格
.spec-group {
width: 895px;
margin-bottom: 15px;
line-height: normal;
.ant-input {
height: 28px;
padding: 4px 11px;
font-size: @font-size-base;
line-height: 1.5;
&::placeholder {
font-size: @font-size-base;
}
}
.form-item-help {
line-height: 36px;
}
.spec-group-item {
background: #f4f5f9;
padding: 7px 12px;
line-height: 28px;
.group-item-input {
float: left;
width: 180px;
}
.group-item-delete {
font-size: 12px;
float: right;
}
}
.spec-value {
background: #fbfbfb;
padding: 8px 35px;
.spec-value-add,
.spec-value-item {
float: left;
margin-bottom: 6px;
margin-top: 6px;
}
.spec-value-add {
height: 28px;
line-height: 28px;
a {
font-size: 12px;
}
}
.spec-value-item {
position: relative;
margin-right: 20px;
.value-item-input {
width: 186px;
}
&:hover {
.icon-close {
display: block;
}
}
.icon-close {
display: none;
position: absolute;
top: -8px;
right: -8px;
cursor: pointer;
font-size: 16px;
color: #7d7d7d;
}
}
}
}
// 添加规格按钮
.spec-group-add-btn {
font-size: @font-size-base;
}
// 批量设置sku
.sku-batch {
line-height: 40px;
margin-bottom: 12px;
.title {
line-height: 28px;
margin-right: 15px;
font-size: @font-size-base;
}
/deep/.ant-input,
/deep/.ant-input-number {
width: 120px;
margin-right: 15px;
}
/deep/.ant-input {
width: 140px;
}
}
// sku列表
.sku-list {
width: 895px;
/deep/.ant-table-thead > tr > th,
/deep/.ant-table-tbody > tr > td {
white-space: nowrap;
}
/deep/.ant-table-tbody > tr > td {
padding: 12px 18px;
}
/deep/.ant-input-sm,
/deep/.ant-input-number-sm {
height: 28px;
}
/deep/.ant-input-number-sm input {
height: 26px;
}
}
</style>
@@ -1,39 +0,0 @@
<!-- 目录选择下拉框 -->
<template>
<a-tree-select
allow-clear
tree-default-expand-all
:placeholder="placeholder"
:value="value || undefined"
:tree-data="data"
:dropdown-style="{ maxHeight: '360px', overflow: 'auto' }"
@update:value="updateValue"
/>
</template>
<script lang="ts" setup>
import type { Goods } from '@/api/goods/model';
const emit = defineEmits<{
(e: 'update:value', value?: number): void;
}>();
withDefaults(
defineProps<{
// 选中的数据(v-modal)
value?: number;
// 提示信息
placeholder?: string;
// 文章分类
data: Goods[];
}>(),
{
placeholder: '请选择上级分类'
}
);
/* 更新选中数据 */
const updateValue = (value?: number) => {
emit('update:value', value);
};
</script>
@@ -1,45 +0,0 @@
<!-- 选择下拉框 -->
<template>
<a-select
show-search
optionFilterProp="label"
:options="data"
allow-clear
:value="value"
:placeholder="placeholder"
@update:value="updateValue"
@blur="onBlur"
/>
</template>
<script lang="ts" setup>
import { getDictionaryOptions } from '@/utils/common';
const emit = defineEmits<{
(e: 'update:value', value: string): void;
(e: 'blur'): void;
}>();
withDefaults(
defineProps<{
value?: string;
placeholder?: string;
}>(),
{
placeholder: '请选择服务器配置'
}
);
// 字典数据
const data = getDictionaryOptions('serverConfiguration');
/* 更新选中数据 */
const updateValue = (value: string) => {
emit('update:value', value);
};
/* 失去焦点 */
const onBlur = () => {
emit('blur');
};
</script>
@@ -1,51 +0,0 @@
<!-- 选择下拉框 -->
<template>
<a-select
optionFilterProp="label"
:options="data"
allow-clear
:value="value"
:placeholder="placeholder"
@update:value="updateValue"
@blur="onBlur"
@change="onChange"
/>
</template>
<script lang="ts" setup>
import { getDictionaryOptions } from '@/utils/common';
const emit = defineEmits<{
(e: 'update:value', value: string): void;
(e: 'blur'): void;
(e: 'change'): void;
}>();
withDefaults(
defineProps<{
value?: string;
placeholder?: string;
}>(),
{
placeholder: '请选择客户跟进状态'
}
);
// 字典数据
const data = getDictionaryOptions('customerFollowStatus');
/* 更新选中数据 */
const updateValue = (value: string) => {
emit('update:value', value);
};
/* 失去焦点 */
const onBlur = () => {
emit('blur');
};
/* 选择事件 */
const onChange = (e) => {
emit('change', e);
};
</script>
@@ -1,70 +0,0 @@
<!-- 角色选择下拉框 -->
<template>
<a-select
optionFilterProp="label"
:options="data"
allow-clear
:value="value"
:placeholder="placeholder"
@update:value="updateValue"
@blur="onBlur"
@change="onChange"
/>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { listDictionaryData } from '@/api/system/dictionary-data';
import type { SelectProps } from 'ant-design-vue';
const emit = defineEmits<{
(e: 'update:value', value: string): void;
(e: 'blur'): void;
(e: 'change'): void;
}>();
withDefaults(
defineProps<{
value?: string;
placeholder?: string;
}>(),
{
placeholder: '请选择客户来源'
}
);
// 字典数据
const data = ref<SelectProps['options']>([]);
/* 更新选中数据 */
const updateValue = (value: string) => {
emit('update:value', value);
};
/* 获取字典数据 */
listDictionaryData({
dictCode: 'customerSource'
})
.then((list) => {
data.value = list.map((d) => {
return {
value: d.dictDataCode,
label: d.dictDataName
};
});
})
.catch((e) => {
message.error(e.message);
});
/* 失去焦点 */
const onBlur = () => {
emit('blur');
};
/* 选择事件 */
const onChange = (e) => {
emit('change', e);
};
</script>
@@ -1,45 +0,0 @@
<!-- 角色选择下拉框 -->
<template>
<a-select
show-search
optionFilterProp="label"
:options="data"
allow-clear
:value="value"
:placeholder="placeholder"
@update:value="updateValue"
@blur="onBlur"
/>
</template>
<script lang="ts" setup>
import { getDictionaryOptions } from '@/utils/common';
const emit = defineEmits<{
(e: 'update:value', value: string): void;
(e: 'blur'): void;
}>();
withDefaults(
defineProps<{
value?: string;
placeholder?: string;
}>(),
{
placeholder: '请选择状态'
}
);
// 字典数据
const data = getDictionaryOptions('status');
/* 更新选中数据 */
const updateValue = (value: string) => {
emit('update:value', value);
};
/* 失去焦点 */
const onBlur = () => {
emit('blur');
};
</script>
@@ -1,63 +0,0 @@
<!-- 角色选择下拉框 -->
<template>
<a-select
optionFilterProp="label"
:options="data"
allow-clear
:value="value"
:placeholder="placeholder"
@update:value="updateValue"
@blur="onBlur"
/>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { listDictionaryData } from '@/api/system/dictionary-data';
import type { SelectProps } from 'ant-design-vue';
const emit = defineEmits<{
(e: 'update:value', value: string): void;
(e: 'blur'): void;
}>();
withDefaults(
defineProps<{
value?: string;
placeholder?: string;
}>(),
{
placeholder: '请选择客户类型'
}
);
// 字典数据
const data = ref<SelectProps['options']>([]);
/* 更新选中数据 */
const updateValue = (value: string) => {
emit('update:value', value);
};
/* 获取字典数据 */
listDictionaryData({
dictCode: 'customerType'
})
.then((list) => {
data.value = list.map((d) => {
return {
value: d.dictDataCode,
label: d.dictDataName
};
});
})
.catch((e) => {
message.error(e.message);
});
/* 失去焦点 */
const onBlur = () => {
emit('blur');
};
</script>
@@ -1,76 +0,0 @@
<!-- 角色选择下拉框 -->
<template>
<a-select
show-search
optionFilterProp="label"
:options="data"
allow-clear
:value="value"
:placeholder="placeholder"
@update:value="updateValue"
@search="onSearch"
@blur="onBlur"
/>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { listUsers } from '@/api/system/user';
import type { SelectProps } from 'ant-design-vue';
import { UserParam } from '@/api/system/user/model';
const emit = defineEmits<{
(e: 'update:value', value: string): void;
(e: 'blur'): void;
}>();
withDefaults(
defineProps<{
value?: string;
placeholder?: string;
}>(),
{
placeholder: '请选择订单类型'
}
);
// 字典数据
const data = ref<SelectProps['options']>([]);
/* 更新选中数据 */
const updateValue = (value: string) => {
emit('update:value', value);
};
// 默认搜索条件
const where = ref<UserParam>({});
const search = () => {
/* 获取用户列 */
listUsers({ ...where?.value })
.then((result) => {
data.value = result?.map((d) => {
return {
value: d.userId,
label: d.nickname
};
});
})
.catch((e) => {
message.error(e.message);
});
};
const onSearch = (e) => {
where.value.nickname = e;
search();
};
search();
/* 失去焦点 */
const onBlur = () => {
emit('blur');
};
</script>
@@ -1,70 +0,0 @@
<!-- 选择下拉框 -->
<template>
<a-select
show-search
optionFilterProp="label"
:options="data"
allow-clear
:value="value"
:placeholder="placeholder"
@update:value="updateValue"
@blur="onBlur"
@search="onSearch"
/>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { pageCustomer } from '@/api/oa/customer';
import type { SelectProps } from 'ant-design-vue';
const emit = defineEmits<{
(e: 'update:value', value: string): void;
(e: 'blur'): void;
}>();
withDefaults(
defineProps<{
value?: number;
placeholder?: string;
}>(),
{
placeholder: '请选择所属客户'
}
);
// 字典数据
const data = ref<SelectProps['options']>([]);
/* 更新选中数据 */
const updateValue = (value: string) => {
emit('update:value', value);
};
/* 获取客户列表数据 */
pageCustomer({ progress: '4' })
.then((result) => {
data.value = result?.list.map((d) => {
return {
value: d.customerId,
label: d.customerName
};
});
})
.catch((e) => {
message.error(e.message);
});
/* 失去焦点 */
const onBlur = () => {
emit('blur');
};
// 联动搜索
const onSearch = (customerName) => {
pageCustomer({ customerName }).then((res) => {
console.log(res);
});
};
</script>
@@ -1,53 +0,0 @@
<!-- 选择下拉框 -->
<template>
<a-select
show-search
optionFilterProp="label"
:options="data"
allow-clear
:value="value"
:placeholder="placeholder"
@update:value="updateValue"
@blur="onBlur"
/>
</template>
<script lang="ts" setup>
import { getDictionaryOptions } from '@/utils/common';
import { ref } from "vue";
import { SelectProps } from "ant-design-vue";
const emit = defineEmits<{
(e: 'update:value', value: string): void;
(e: 'blur'): void;
}>();
withDefaults(
defineProps<{
value?: string;
placeholder?: string;
}>(),
{
placeholder: '请选择运费模板'
}
);
// 字典数据
const data = ref<SelectProps['options']>([
{
label: '全国包邮(除偏远地区)',
text: '全国包邮(除偏远地区)',
value: 0
}
]);
/* 更新选中数据 */
const updateValue = (value: string) => {
emit('update:value', value);
};
/* 失去焦点 */
const onBlur = () => {
emit('blur');
};
</script>
@@ -1,629 +0,0 @@
<!-- 用户编辑弹窗 -->
<template>
<ele-modal
:width="880"
:visible="visible"
:confirm-loading="loading"
:maxable="maxAble"
:title="isUpdate ? '编辑商品' : '添加商品'"
:body-style="{ paddingBottom: '8px' }"
@update:visible="updateVisible"
:maskClosable="false"
@ok="save"
>
<a-tabs v-model:activeKey="activeKey" type="card">
<a-tab-pane key="1" tab="基本信息">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ md: { span: 8 }, sm: { span: 24 } }"
:wrapper-col="{ md: { span: 24 }, sm: { span: 24 } }"
layout="vertical"
>
<a-row :gutter="16">
<a-col
v-bind="
styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }
"
>
<a-form-item label="商品名称" name="goodsName">
<a-input
allow-clear
placeholder="请输入商品名称"
v-model:value="form.goodsName"
/>
</a-form-item>
<a-form-item label="参与机型">
<a-select
mode="multiple"
v-model:value="goodsPhoneModelList"
style="width: 100%"
>
<a-select-option :value="item.id" v-for="(item, index) in phoneModelList" :key="index">
{{ item.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col
v-bind="
styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }
"
>
<a-form-item label="商品编码" name="goodsCode">
<a-input
allow-clear
placeholder="请输入商品编码"
:disabled="isUpdate"
v-model:value="form.goodsCode"
/>
</a-form-item>
<a-form-item label="商品状态" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="0">上架</a-radio>
<a-radio :value="1">下架</a-radio>
</a-radio-group>
</a-form-item>
<!-- <a-form-item-->
<!-- label="运费模板"-->
<!-- name="deliveryId"-->
<!-- v-if="form.goodsType === 1"-->
<!-- >-->
<!-- <deliver-select v-model:value="form.deliveryId" />-->
<!-- </a-form-item>-->
</a-col>
</a-row>
<!-- <a-form-item label="商品图片" name="images">-->
<!-- <ele-image-upload-->
<!-- v-model:value="images"-->
<!-- :limit="14"-->
<!-- :drag="true"-->
<!-- :multiple="true"-->
<!-- :upload-handler="uploadHandler"-->
<!-- @upload="onUpload"-->
<!-- />-->
<!-- </a-form-item>-->
</a-form>
</a-tab-pane>
<a-tab-pane key="2" tab="价格">
<a-form
ref="formRef"
:label-col="{ md: { span: 8 }, sm: { span: 24 } }"
:wrapper-col="{ md: { span: 24 }, sm: { span: 24 } }"
layout="vertical"
:model="form"
:rules="rules"
>
<!-- <a-form-item label="规格类型" name="specType">-->
<!-- <a-radio-group v-model:value="form.specType">-->
<!-- <a-radio :value="0">单规格</a-radio>-->
<!-- &lt;!&ndash; <a-radio :value="1">多规格</a-radio>&ndash;&gt;-->
<!-- </a-radio-group>-->
<!-- </a-form-item>-->
<!-- <div style="margin-bottom: 22px">-->
<!-- <a-divider />-->
<!-- </div>-->
<a-row :gutter="16">
<a-col
v-bind="
styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }
"
>
<a-form-item
label="商品默认价格"
name="goodsPriceMin"
>
<a-input-number
:min="0.01"
:precision="2"
style="width: 200px"
v-model:value="form.goodsPriceMin"
/>
<span class="ml-10">元</span>
</a-form-item>
<a-form-item
label="成本价"
name="cost"
>
<a-input-number
:min="0.01"
:precision="2"
style="width: 200px"
v-model:value="form.cost"
/>
<span class="ml-10">元</span>
</a-form-item>
<a-form-item
label="划线价(原价)"
name="linePrice"
>
<a-input-number
:min="0.01"
:precision="2"
style="width: 200px"
@change="calCost"
v-model:value="form.linePriceMin"
/>
<span class="ml-10">元</span>
</a-form-item>
</a-col>
<!-- <a-col-->
<!-- v-bind="-->
<!-- styleResponsive ? { md: 12, sm: 24, xs: 24 } : { span: 12 }-->
<!-- "-->
<!-- >-->
<!-- <a-form-item-->
<!-- label="当前库存数量"-->
<!-- name="stockTotal"-->
<!-- v-if="form.specType === 0"-->
<!-- >-->
<!-- <a-input-number-->
<!-- :min="0"-->
<!-- :precision="0"-->
<!-- style="width: 200px"-->
<!-- v-model:value="form.stockTotal"-->
<!-- />-->
<!-- <span class="ml-10">件</span>-->
<!-- </a-form-item>-->
<!--&lt;!&ndash; <a-form-item&ndash;&gt;-->
<!--&lt;!&ndash; label="商品重量"&ndash;&gt;-->
<!--&lt;!&ndash; name="goodsWeight"&ndash;&gt;-->
<!--&lt;!&ndash; v-if="form.specType === 0"&ndash;&gt;-->
<!--&lt;!&ndash; >&ndash;&gt;-->
<!--&lt;!&ndash; <a-input-number&ndash;&gt;-->
<!--&lt;!&ndash; :min="0"&ndash;&gt;-->
<!--&lt;!&ndash; :precision="0"&ndash;&gt;-->
<!--&lt;!&ndash; style="width: 200px"&ndash;&gt;-->
<!--&lt;!&ndash; v-model:value="form.goodsWeight"&ndash;&gt;-->
<!--&lt;!&ndash; />&ndash;&gt;-->
<!--&lt;!&ndash; <span class="ml-10">千克 (Kg)</span>&ndash;&gt;-->
<!--&lt;!&ndash; </a-form-item>&ndash;&gt;-->
<!-- </a-col>-->
</a-row>
<a-form-item
label="商品规格"
name="deductStockType"
v-if="form.specType === 1"
>
<!-- <MultiSpec ref="MultiSpec" />-->
</a-form-item>
<div style="margin-bottom: 22px">
<a-divider />
</div>
</a-form>
</a-tab-pane>
<!-- <a-tab-pane key="3" tab="商品详情">-->
<!-- <a-form-->
<!-- ref="formRef"-->
<!-- :label-col="{ md: { span: 8 }, sm: { span: 24 } }"-->
<!-- :wrapper-col="{ md: { span: 24 }, sm: { span: 24 } }"-->
<!-- layout="vertical"-->
<!-- :model="form"-->
<!-- :rules="rules"-->
<!-- >-->
<!-- <a-form-item label="" name="content">-->
<!-- &lt;!&ndash; 编辑器 &ndash;&gt;-->
<!-- <tinymce-editor-->
<!-- v-model:value="content"-->
<!-- :disabled="disabled"-->
<!-- :init="config"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-form>-->
<!-- </a-tab-pane>-->
<a-tab-pane key="4" tab="更多设置">
<a-form
ref="formRef"
:label-col="{ md: { span: 8 }, sm: { span: 24 } }"
:wrapper-col="{ md: { span: 24 }, sm: { span: 24 } }"
layout="vertical"
:model="form"
:rules="rules"
>
<!-- <a-form-item label="商品卖点" name="sellingPoint">-->
<!-- <a-input-->
<!-- allow-clear-->
<!-- placeholder="【入手皆因热爱】限时限量领券至高立减400元,限时优惠,限量抢购!!赶快下单吧!"-->
<!-- v-model:value="form.sellingPoint"-->
<!-- />-->
<!-- </a-form-item>-->
<a-form-item label="排序号" name="sortNumber">
<a-input-number
:min="0"
:max="99999"
class="ele-fluid"
placeholder="请输入排序号(数字越小越靠前)"
v-model:value="form.sortNumber"
/>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from "vue";
import { message } from "ant-design-vue";
import type { Goods } from "@/api/goods/model";
import { addGoods, updateGoods } from "@/api/goods";
import { FormInstance, Rule, RuleObject } from "ant-design-vue/es/form";
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
import { uploadFile } from "@/api/system/file";
import { FILE_THUMBNAIL } from "@/config/setting";
import useFormData from "@/utils/use-form-data";
import CategorySelect from "./category-select.vue";
import TinymceEditor from "@/components/TinymceEditor/index.vue";
import { Category } from "@/api/goods/category/model";
import { useThemeStore } from "@/store/modules/theme";
import { storeToRefs } from "pinia";
import DeliverSelect from "./deliver-select.vue";
import { createCode } from "@/utils/common";
import { GoodsPhoneModel } from "@/api/goods/goodsPhoneModel";
import { listPhoneModel } from "@/api/vivo/phoneModel";
import { PhoneModel } from "@/api/vivo/phoneModel/model";
// import MultiSpec from './MultiSpec.vue';
// 是否是修改
const isUpdate = ref(false);
// 是否显示最大化切换按钮
const maxAble = ref(true);
const disabled = ref(false);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: Goods | null;
categoryList?: Category[];
}>();
const emit = defineEmits<{
(e: "done"): void;
(e: "update:visible", visible: boolean): void;
}>();
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
// 提交状态
const loading = ref(false);
// 已上传数据
const images = ref<ItemType[]>([]);
const formRef = ref<FormInstance | null>(null);
// 选项卡位置
const activeKey = ref("1");
// 编辑器内容,双向绑定
const content = ref<any>("");
/* 更新visible */
const updateVisible = (value: boolean) => {
emit("update:visible", value);
};
// 表单数据
const { form, resetFields, assignFields } = useFormData<Goods>({
goodsType: 0,
cost: 0,
goodsName: "",
goodsCode: `G${createCode()}`,
categoryId: 0,
image: "",
files: "",
goodsPriceMin: 0.01,
linePriceMin: 0.01,
createTime: "",
comments: "",
sortNumber: 100,
status: 0,
goodsId: 0,
specType: 0,
deliveryType: 0,
content: "",
deliveryId: 0,
sellingPoint: "",
purchaseLimit: 0,
deductStockType: 10,
salesActual: 180,
goodsWeight: 0,
stockTotal: 2000,
goodsPhoneModelList: []
});
/* 上传事件 */
const uploadHandler = (file: File) => {
const item: ItemType = {
file,
uid: (file as any).uid,
name: file.name
};
if (!file.type.startsWith("image")) {
message.error("只能选择图片");
return;
}
if (file.size / 1024 / 1024 > 2) {
message.error("大小不能超过 2MB");
return;
}
onUpload(item);
};
// 上传文件
const onUpload = (item) => {
const { file } = item;
uploadFile(file)
.then((data) => {
images.value.push({
uid: data.id,
url: FILE_THUMBNAIL + data.path,
status: "done"
});
message.success("上传成功");
})
.catch((e) => {
message.error(e.message);
});
};
const calCost = (value: number) => {
form.cost = value * 0.58
}
// 表单验证规则
const rules = reactive<Record<string, Rule[]>>({
goodsName: [
{
required: true,
message: "请输入商品名称",
type: "string",
trigger: "blur"
}
],
goodsCode: [
{
required: true,
type: "string",
message: "请填写商品编号",
trigger: "blur"
}
],
goodsPriceMin: [
{
required: true,
type: "number",
message: "请输入商品价格",
trigger: "blur"
}
],
// stockTotal: [
// {
// required: true,
// type: "number",
// message: "请输入商品库存",
// trigger: "blur"
// }
// ],
// specType: [
// {
// required: true,
// type: "number",
// message: "请选择规格类型",
// trigger: "blur"
// }
// ],
status: [
{
required: true,
type: "number",
message: "请选择商品状态",
trigger: "blur"
}
],
sortNumber: [
{
required: true,
type: "number",
message: "请输入排序号",
trigger: "blur"
}
],
// images: [
// {
// required: true,
// type: "string",
// message: "请选择商品图片",
// trigger: "blur",
// validator: async (_rule: RuleObject, value: string) => {
// if (images.value.length == 0) {
// return Promise.reject("请上传商品图片");
// }
// return Promise.resolve();
// }
// }
// ],
// customerId: [
// {
// required: true,
// type: "number",
// message: "请选择客户"
// }
// ],
linePriceMin: [
{
required: true,
type: "number",
message: "请输入商品划线价",
trigger: "blur"
}
],
cost: [
{
required: true,
type: "number",
message: "请输入成本价",
trigger: "blur"
}
],
// content: [
// {
// required: true,
// type: "string",
// message: "请输入文章内容",
// trigger: "blur",
// validator: async (_rule: RuleObject, value: string) => {
// if (content.value == "") {
// return Promise.reject("请输入文字内容");
// }
// return Promise.resolve();
// }
// }
// ],
// brand: [
// {
// required: true,
// type: "string",
// message: "请选项商品厂商",
// trigger: "blur"
// }
// ]
});
const editorRef = ref<InstanceType<typeof TinymceEditor> | null>(null);
const config = ref({
height: 500,
// 自定义文件上传(这里使用把选择的文件转成 blob 演示)
file_picker_callback: (callback: any, _value: any, meta: any) => {
const input = document.createElement("input");
input.setAttribute("type", "file");
// 设定文件可选类型
if (meta.filetype === "image") {
input.setAttribute("accept", "image/*");
} else if (meta.filetype === "media") {
input.setAttribute("accept", "video/*");
}
input.onchange = () => {
const file = input.files?.[0];
if (!file) {
return;
}
if (meta.filetype === "media") {
if (!file.type.startsWith("video/")) {
editorRef.value?.alert({ content: "只能选择视频文件" });
return;
}
}
if (file.size / 1024 / 1024 > 20) {
editorRef.value?.alert({ content: "大小不能超过 20MB" });
return;
}
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result != null) {
const blob = new Blob([e.target.result], { type: file.type });
callback(URL.createObjectURL(blob));
}
};
reader.readAsArrayBuffer(file);
};
input.click();
}
});
// const onSelect = (item) => {
// form.goodsId = item.goodsId;
// };
const goodsPhoneModelList = ref<number[]>([])
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
// 获取第一张图片作为封面图
// images.value.map((d, i) => {
// if (i === 0) {
// form.image = d.url;
// }
// });
console.log(form)
const goodsForm = {
...form,
goodsPhoneModelList: goodsPhoneModelList.value.length > 0 ? goodsPhoneModelList.value.map(item => {
return {
modelId: item
}
}) : [],
files: '[]'
// files: JSON.stringify(images.value),
// content: content.value
};
console.log(isUpdate.value)
const saveOrUpdate = isUpdate.value ? updateGoods : addGoods;
saveOrUpdate(goodsForm)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit("done");
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {
});
};
const phoneModelList = ref< PhoneModel[]>([]);
const getPhoneModelList = async () => {
phoneModelList.value = await listPhoneModel();
};
watch(
() => props.visible,
(visible) => {
if (visible) {
getPhoneModelList()
if (props.data) {
loading.value = false;
assignFields({
...props.data,
files: JSON.parse(String(props.data.files))
});
images.value = JSON.parse(String(props.data.files));
content.value = props.data.content;
isUpdate.value = true;
if (props.data.goodsPhoneModelList && props.data.goodsPhoneModelList.length > 0) {
goodsPhoneModelList.value = props.data.goodsPhoneModelList.map(item => {
return item.modelId
})
}
} else {
isUpdate.value = false;
content.value = "";
}
} else {
goodsPhoneModelList.value = []
resetFields();
formRef.value?.clearValidate();
}
}
);
</script>
<style lang="less">
.tab-pane {
min-height: 300px;
}
.ml-10 {
margin-left: 5px;
}
</style>
@@ -1,243 +0,0 @@
<!-- 用户编辑弹窗 -->
<template>
<a-drawer
width="75%"
:visible="visible"
:confirm-loading="loading"
:maxable="maxAble"
:title="'商品详情'"
:body-style="{ paddingBottom: '8px' }"
@update:visible="updateVisible"
:footer="null"
>
<a-form
:label-col="{ md: { span: 4 }, sm: { span: 24 } }"
:wrapper-col="{ md: { span: 19 }, sm: { span: 24 } }"
>
<div class="base-form" style="margin-bottom: 20px">
<a-descriptions :title="assets.name" bordered>
<a-descriptions-item label="商品名称">
{{ assets.name }}
</a-descriptions-item>
<a-descriptions-item label="商品IP">
{{ assets.code }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="assets.status === '0'" color="green">正常</a-tag>
<a-tag v-if="assets.status === '1'" color="red">过期</a-tag>
<a-tag v-if="assets.status === '2'" color="purple">不再续</a-tag>
</a-descriptions-item>
<a-descriptions-item label="宝塔面板">
<a href="#" @click="openUrl(assets)">{{ assets.panel }}</a>
</a-descriptions-item>
<a-descriptions-item label="宝塔账号">
{{ assets.panelAccount }}
</a-descriptions-item>
<a-descriptions-item label="宝塔密码">
<span v-role="['admin']">{{ assets.panelPassword }}</span>
<span v-any-role="['user', 'guest', 'superAdmin', 'developer']"
>****</span
>
</a-descriptions-item>
<a-descriptions-item label="品牌厂商">
{{ assets.brand }}
</a-descriptions-item>
<a-descriptions-item label="商品配置">
{{ assets.configuration }}
</a-descriptions-item>
<a-descriptions-item label="开发端口">
{{ assets.openPort }}
</a-descriptions-item>
<a-descriptions-item label="有效期">
<div v-if="assets.startTime && assets.endTime">
<div class="ele-text-info">{{ assets.startTime }}</div>
<div v-html="getEndTime(assets.endTime)"></div>
</div>
</a-descriptions-item>
<a-descriptions-item label="商品账号">
<span class="ele-text-danger">root</span>
</a-descriptions-item>
<a-descriptions-item label="商品密码">
<span class="ele-text-danger" v-role="['admin']">{{
assets.password
}}</span>
<span v-any-role="['user', 'guest', 'superAdmin', 'developer']"
>****</span
>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ timeAgo(assets.createTime) }}
</a-descriptions-item>
<a-descriptions-item label="排序">
{{ assets.sortNumber }}
</a-descriptions-item>
<a-descriptions-item label="发布人">
{{ assets.userId }}
</a-descriptions-item>
<a-descriptions-item span="1" label="备注">
{{ assets.comments }}
</a-descriptions-item>
</a-descriptions>
</div>
<a-space style="display: none">
<a-tabs v-model:activeKey="activeKey" v-role="'admin'">
<a-tab-pane key="1" tab="宝塔面板" force-render>
<a-descriptions title="宝塔面板" class="tab-pane">
<a-descriptions-item label="宝塔面板">
<a href="#" @click="openUrl(assets)">{{ assets.panel }}</a>
</a-descriptions-item>
<a-descriptions-item label="宝塔面板账号">
{{ assets.panelAccount }}
</a-descriptions-item>
<a-descriptions-item label="宝塔面板密码">
{{ assets.panelPassword }}
</a-descriptions-item>
<a-descriptions-item label="开发端口">
{{ assets.openPort }}
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="2" tab="账号密码" force-render>
<a-descriptions title="商品账号密码" class="tab-pane">
<a-descriptions-item label="商品账号">
root
</a-descriptions-item>
<a-descriptions-item label="商品密码">
{{ assets.password }}
</a-descriptions-item>
</a-descriptions>
<a-descriptions title="账号密码" class="tab-pane">
<a-descriptions-item label="登录账号">
{{ assets.brandAccount }}
</a-descriptions-item>
<a-descriptions-item label="登录密码">
{{ assets.brandPassword }}
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="3" tab="财务信息">
<a-descriptions title="财务信息" class="tab-pane">
<a-descriptions-item label="合同金额">
<span class="ele-text-warning">
¥{{ assets.financeAmount }}
</span>
</a-descriptions-item>
<a-descriptions-item label="购买年限">
{{ assets.financeYears }}年
</a-descriptions-item>
<a-descriptions-item label="每年续费金额">
<span class="ele-text-warning">
¥{{ assets.financeRenew }}
</span>
</a-descriptions-item>
<a-descriptions-item label="客户名称">
{{ assets.financeCustomerName }}
</a-descriptions-item>
<a-descriptions-item label="客户联系人">
{{ assets.financeCustomerContact }}
</a-descriptions-item>
<a-descriptions-item label="联系方式">
{{ assets.financeCustomerPhone }}
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
</a-tabs>
</a-space>
</a-form>
</a-drawer>
</template>
<script lang="ts" setup>
import { getEndTime } from '@/utils/common';
import { ref, reactive, watch } from 'vue';
import { Form } from 'ant-design-vue';
import { assignObject, timeAgo } from 'ele-admin-pro';
import type { Assets } from '@/api/oa/assets/model';
const useForm = Form.useForm;
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: Assets | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 用户信息
const assets = reactive<Assets>({
code: '',
createTime: '',
startTime: '',
endTime: '',
name: '',
brand: undefined,
account: '',
password: '',
configuration: '',
panelAccount: '',
panelPassword: '',
panel: '',
financeAmount: 0,
financeYears: 0,
financeRenew: 0,
financeCustomerName: '',
financeCustomerContact: '',
financeCustomerPhone: '',
brandAccount: '',
brandPassword: '',
openPort: '',
comments: '',
root: 'root',
userId: 0,
sortNumber: undefined,
status: undefined,
type: '',
assetsId: 0
});
// 请求状态
const loading = ref(true);
// 是否显示最大化切换按钮
const maxAble = ref(true);
// 选项卡位置
const activeKey = ref('1');
const { resetFields } = useForm(assets);
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
/* 打开外部链接 */
const openUrl = (record) => {
window.open(record.panel);
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
loading.value = false;
assignObject(assets, props.data);
}
} else {
resetFields();
}
}
);
</script>
<style lang="less">
.tab-pane {
min-height: 100px;
}
.important {
font-weight: bold;
}
</style>
@@ -1,311 +0,0 @@
<!-- 用户编辑弹窗 -->
<template>
<ele-modal
:width="880"
:visible="visible"
:confirm-loading="loading"
:maxable="maxAble"
:title="'设置商品区域价格'"
:body-style="{ paddingBottom: '8px' }"
@update:visible="updateVisible"
:maskClosable="false"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ md: { span: 8 }, sm: { span: 24 } }"
:wrapper-col="{ md: { span: 24 }, sm: { span: 24 } }"
layout="vertical"
>
<a-row style="margin-bottom: 1rem">
<a-col :span="12">
<span v-if="props.data" class="ml-10">成本价: {{ props.data.cost }}元</span>
</a-col>
<a-col :span="12">
<span v-if="props.data && form.amount"
class="ml-10">利润:{{ (form.amount - props.data.cost).toFixed(2)
}}元</span>
</a-col>
</a-row>
<a-form-item label="价格区域">
<a-cascader v-model:value="selectedArea" :options="areaList" placeholder="请选择区域"
:disabled="loginUser.cityId"
@change="selectArea" :fieldNames="{label: 'name', value: 'id'}" />
</a-form-item>
<template v-if="form.city">
<a-form-item label="商品价格" name="amount">
<a-input-number :min="0.01" :precision="2" style="width: 200px" v-model:value="form.amount">
<template #addonAfter>
<span>元</span>
</template>
</a-input-number>
</a-form-item>
<!-- <a-form-item :label="form.level3commissionMode === 1 ? '零售店长返利比例' : '零售店长返利金额'" name="level3commissionRate">-->
<!-- <a-input-number :min="0.01" :max="100" :precision="2" style="width: 200px"-->
<!-- v-model:value="form.level3commissionRate" >-->
<!-- <template #addonAfter>-->
<!-- <a-select v-model:value="form.level3commissionMode" style="width: 60px;">-->
<!-- <a-select-option :value="0">元</a-select-option>-->
<!-- <a-select-option :value="1">%</a-select-option>-->
<!-- </a-select>-->
<!-- </template>-->
<!-- </a-input-number>-->
<!-- </a-form-item>-->
<a-form-item :label="form.level4commissionMode === 1 ? '导购返利比例' : '导购返利金额'"
name="level4commissionRate">
<a-input-number :min="0.01" :max="100" :precision="2" style="width: 200px"
v-model:value="form.level4commissionRate">
<template #addonAfter>
<a-select v-model:value="form.level4commissionMode" style="width: 60px;">
<a-select-option :value="0">元</a-select-option>
<a-select-option :value="1">%</a-select-option>
</a-select>
</template>
</a-input-number>
</a-form-item>
<a-form-item :label="form.level6commissionMode === 1 ? '散户返利比例' : '散户返利金额'"
name="level6commissionRate">
<a-input-number :min="0.01" :max="100" :precision="2" style="width: 200px"
v-model:value="form.level6commissionRate">
<template #addonAfter>
<a-select v-model:value="form.level6commissionMode" style="width: 60px;">
<a-select-option :value="0">元</a-select-option>
<a-select-option :value="1">%</a-select-option>
</a-select>
</template>
</a-input-number>
</a-form-item>
<a-form-item :label="form.level7commissionMode === 1 ? '编外合伙人返利比例' : '编外合伙人返利金额'"
name="level7commissionRate">
<a-input-number :min="0.01" :max="100" :precision="2" style="width: 200px"
v-model:value="form.level7commissionRate">
<template #addonAfter>
<a-select v-model:value="form.level7commissionMode" style="width: 60px;">
<a-select-option :value="0">元</a-select-option>
<a-select-option :value="1">%</a-select-option>
</a-select>
</template>
</a-input-number>
</a-form-item>
<a-form-item label="售后返利比例" name="level5commissionRate">
<a-input-number :min="0.01" :max="100" :precision="2" style="width: 200px"
v-model:value="form.level5commissionRate">
<template #addonAfter>
<span>%</span>
</template>
</a-input-number>
</a-form-item>
<!-- <a-form-item label="售后店长返利比例" name="level8commissionRate">-->
<!-- <a-input-number :min="0.01" :max="100" :precision="2" style="width: 200px"-->
<!-- v-model:value="form.level8commissionRate" >-->
<!-- <template #addonAfter>-->
<!-- <span>%</span>-->
<!-- </template>-->
<!-- </a-input-number>-->
<!-- </a-form-item>-->
<!-- <a-form-item label="售后店长门店返利比例(基于门店售后返利比例)" name="fixStoreLeaderCommissionRate">-->
<!-- <a-input-number :min="0.01" :max="100" :precision="2" style="width: 200px"-->
<!-- v-model:value="form.fixStoreLeaderCommissionRate" >-->
<!-- <template #addonAfter>-->
<!-- <span>%</span>-->
<!-- </template>-->
<!-- </a-input-number>-->
<!-- </a-form-item>-->
</template>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch, computed } from "vue";
import { message } from "ant-design-vue";
import { FormInstance, Rule, RuleObject } from "ant-design-vue/es/form";
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
import useFormData from "@/utils/use-form-data";
import { Category } from "@/api/goods/category/model";
import { useThemeStore } from "@/store/modules/theme";
import { storeToRefs } from "pinia";
import { GoodsPrice } from "@/api/order/goodsPrice/model";
import { addGoodsPrice, goodsPriceByGoodsNCity, updateGoodsPrice } from "@/api/order/goodsPrice";
import { Goods } from "@/api/goods/model";
import { Area } from "@/api/system/area/model";
import { listArea, listAreaByParent } from "@/api/system/area";
import { useUserStore } from "@/store/modules/user";
import { User } from "@/api/system/user/model";
// 是否是修改
const isUpdate = ref(false);
// 是否显示最大化切换按钮
const maxAble = ref(true);
const disabled = ref(false);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: Goods | null;
categoryList?: Category[];
}>();
const emit = defineEmits<{
(e: "done"): void;
(e: "update:visible", visible: boolean): void;
}>();
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
// 提交状态
const loading = ref(false);
// 已上传数据
const images = ref<ItemType[]>([]);
const formRef = ref<FormInstance | null>(null);
// 选项卡位置
const activeKey = ref("1");
// 编辑器内容,双向绑定
const content = ref<any>("");
/* 更新visible */
const updateVisible = (value: boolean) => {
emit("update:visible", value);
};
const userStore = useUserStore();
const loginUser = computed(() => userStore.info ?? {});
// 表单数据
const { form, resetFields, assignFields } = useFormData<GoodsPrice>({
id: 0,
goodsId: 0,
amount: 0,
city: 0,
level3commissionRate: 0,
level4commissionRate: 0,
level5commissionRate: 0,
level6commissionRate: 0,
level7commissionRate: 0,
level8commissionRate: 0,
fixStoreLeaderCommissionRate: 0
});
// 表单验证规则
const rules = reactive<Record<string, Rule[]>>({});
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateGoodsPrice : addGoodsPrice;
saveOrUpdate(form)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit("done");
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
const selectedArea = ref<String[]>([]);
const areaList = ref<Area[]>([]);
const getAreaList = async () => {
areaList.value = await listAreaByParent();
};
const selectArea = async value => {
form.city = value[0];
const res = await goodsPriceByGoodsNCity({
goodsId: props.data?.goodsId,
cityId: value[0]
});
if (res) {
form.id = res.id;
form.amount = res.amount;
form.level3commissionRate = res.level3commissionRate;
form.level3commissionMode = res.level3commissionMode;
form.level4commissionRate = res.level4commissionRate;
form.level4commissionMode = res.level4commissionMode;
form.level5commissionRate = res.level5commissionRate;
form.level6commissionRate = res.level6commissionRate;
form.level6commissionMode = res.level6commissionMode;
form.level7commissionRate = res.level7commissionRate;
form.level7commissionMode = res.level7commissionMode;
form.level8commissionRate = res.level8commissionRate;
form.fixStoreLeaderCommissionRate = res.fixStoreLeaderCommissionRate;
isUpdate.value = true;
} else {
form.id = 0;
if (props?.data && props?.data.goodsPriceMin) form.amount = props.data.goodsPriceMin;
form.level3commissionRate = 0;
form.level3commissionMode = 0;
form.level4commissionRate = 0;
form.level4commissionMode = 0;
form.level5commissionRate = 0;
form.level6commissionRate = 0;
form.level6commissionMode = 0;
form.level7commissionRate = 0;
form.level7commissionMode = 0;
form.level8commissionRate = 0;
form.fixStoreLeaderCommissionRate = 0;
isUpdate.value = false;
}
};
watch(
() => props.visible,
async (visible) => {
await getAreaList();
if (visible) {
isUpdate.value = false;
if (props.data) {
loading.value = false;
if (props.data.goodsId) form.goodsId = props.data.goodsId;
if (props.data.goodsPrice) {
form.id = props.data.goodsPrice.id;
form.city = props.data.goodsPrice.city;
form.amount = props.data.goodsPrice.amount;
form.level3commissionRate = props.data.goodsPrice.level3commissionRate;
form.level3commissionMode = props.data.goodsPrice.level3commissionMode;
form.level4commissionRate = props.data.goodsPrice.level4commissionRate;
form.level4commissionMode = props.data.goodsPrice.level4commissionMode;
form.level5commissionRate = props.data.goodsPrice.level5commissionRate;
form.level6commissionRate = props.data.goodsPrice.level6commissionRate;
form.level6commissionMode = props.data.goodsPrice.level6commissionMode;
form.level7commissionRate = props.data.goodsPrice.level7commissionRate;
form.level7commissionMode = props.data.goodsPrice.level7commissionMode;
form.level8commissionRate = props.data.goodsPrice.level8commissionRate;
form.fixStoreLeaderCommissionRate = props.data.goodsPrice.fixStoreLeaderCommissionRate;
isUpdate.value = true;
} else {
if (props?.data && props?.data.goodsPriceMin) form.amount = props.data.goodsPriceMin;
}
if (loginUser.value.cityId) {
const cityItem = areaList.value.find((item) => item.id === loginUser.value.cityId);
if (cityItem) selectedArea.value = [cityItem.fid, cityItem.id];
}
}
} else {
selectedArea.value = [];
resetFields();
formRef.value?.clearValidate();
}
}
);
</script>
<style lang="less">
.tab-pane {
min-height: 300px;
}
.ml-10 {
margin-left: 5px;
}
</style>
@@ -1,65 +0,0 @@
<!-- 搜索表单 -->
<template>
<a-form
:label-col="{ md: { span: 6 }, sm: { span: 24 } }"
:wrapper-col="{ md: { span: 18 }, sm: { span: 24 } }"
>
<a-row>
<a-col :lg="6" :md="12" :sm="24" :xs="24">
<a-form-item label="服务器名称">
<a-input
v-model:value.trim="where.name"
placeholder="请输入服务器名称"
allow-clear
@pressEnter="search"
/>
</a-form-item>
</a-col>
<a-col :lg="6" :md="12" :sm="24" :xs="24">
<a-form-item label="IP地址">
<a-input
v-model:value.trim="where.code"
placeholder="请输入服务器IP"
allow-clear
@pressEnter="search"
/>
</a-form-item>
</a-col>
<a-col :lg="6" :md="12" :sm="24" :xs="24">
<a-form-item class="ele-text-right" :wrapper-col="{ span: 24 }">
<a-space>
<a-button type="primary" @click="search">查询</a-button>
<a-button @click="reset">重置</a-button>
</a-space>
</a-form-item>
</a-col>
</a-row>
</a-form>
</template>
<script lang="ts" setup>
import useSearch from '@/utils/use-search';
import type { AssetsParam } from '@/api/oa/assets/model';
const emit = defineEmits<{
(e: 'search', where?: AssetsParam): void;
}>();
// 表单数据
const { where, resetFields } = useSearch<AssetsParam>({
name: '',
code: '',
isExpire: ''
});
/* 搜索 */
const search = () => {
emit('search', where);
};
/* 重置 */
const reset = () => {
resetFields();
search();
};
</script>
@@ -1,289 +0,0 @@
<!-- 用户编辑弹窗 -->
<template>
<ele-modal
:width="680"
:visible="visible"
:confirm-loading="loading"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '编辑订单' : '添加订单'"
:body-style="{ paddingBottom: '8px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-space>
<a-form
:label-col="{ md: { span: 7 }, sm: { span: 4 }, xs: { span: 24 } }"
:wrapper-col="{ md: { span: 17 }, sm: { span: 20 }, xs: { span: 24 } }"
>
<a-row :gutter="16">
<a-col :md="12" :sm="24" :xs="24">
<a-form-item label="订单名称" v-bind="validateInfos.customerName">
<a-input
allow-clear
:maxlength="30"
placeholder="请输入订单名称"
v-model:value="form.customerName"
@blur="
validate('customerName', { trigger: 'blur' }).catch(() => {})
"
/>
</a-form-item>
<a-form-item label="订单标识" v-bind="validateInfos.customerCode">
<a-input
allow-clear
:maxlength="20"
placeholder="请输入用户账号"
v-model:value="form.customerCode"
/>
</a-form-item>
<a-form-item label="跟进状态" v-bind="validateInfos.progress">
<progress-select
v-model:value="form.progress"
@blur="
validate('progress', { trigger: 'blur' }).catch(() => {})
"
/>
</a-form-item>
<a-form-item label="订单类型" v-bind="validateInfos.customerType">
<type-select
v-model:value="form.customerType"
@blur="
validate('customerType', { trigger: 'blur' }).catch(() => {})
"
/>
</a-form-item>
<a-form-item label="订单来源" v-bind="validateInfos.customerSource">
<source-select
v-model:value="form.customerSource"
@blur="
validate('customerSource', { trigger: 'blur' }).catch(() => {})
"
/>
</a-form-item>
<a-form-item label="头像" v-bind="validateInfos.customerAvatar">
<ele-image-upload
v-model:value="images"
:item-style="{ width: '90px', height: '90px' }"
:limit="1"
@upload="onUpload"
/>
</a-form-item>
</a-col>
<a-col :md="12" :sm="24" :xs="24">
<a-form-item label="联系人" v-bind="validateInfos.customerContacts">
<a-input
allow-clear
:maxlength="20"
placeholder="请填写联系人"
v-model:value="form.customerContacts"
/>
</a-form-item>
<a-form-item label="手机号码" v-bind="validateInfos.customerMobile">
<a-input
allow-clear
:maxlength="20"
placeholder="请填写联系人手机号码"
v-model:value="form.customerMobile"
/>
</a-form-item>
<a-form-item
label="联系地址"
v-bind="validateInfos.customerAddress"
>
<a-input
allow-clear
placeholder="请填写联系地址"
v-model:value="form.customerAddress"
/>
</a-form-item>
<a-form-item label="公司座机" v-bind="validateInfos.customerPhone">
<a-input
allow-clear
:maxlength="20"
placeholder="请填写公司座机电话"
v-model:value="form.customerPhone"
/>
</a-form-item>
<a-form-item label="排序" v-bind="validateInfos.sortNumber">
<a-input
allow-clear
:maxlength="20"
placeholder="排序"
v-model:value="form.sortNumber"
/>
</a-form-item>
<a-form-item label="备注" v-bind="validateInfos.comments">
<a-textarea
:rows="4"
:maxlength="200"
placeholder="请输入备注"
v-model:value="form.comments"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-space>
</ele-modal>
</template>
<script lang="ts" setup>
import {ref, reactive, watch, computed} from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject } from 'ele-admin-pro';
import TypeSelect from './customer-edit/type-select.vue';
import ProgressSelect from './customer-edit/progress-select.vue';
import SourceSelect from './customer-edit/source-select.vue';
import { addCustomer, updateCustomer } from '@/api/oa/customer';
import type { Customer } from '@/api/oa/customer/model';
import { createCode } from '@/utils/common';
import { uploadFile } from '@/api/system/file';
import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
import { FILE_SERVER } from '@/config/setting';
import { useUserStore } from '@/store/modules/user';
const userStore = useUserStore();
// 当前用户信息
const loginUser = computed(() => userStore.info ?? {});
// 是否是修改
const isUpdate = ref(false);
const useForm = Form.useForm;
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: Customer | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 提交状态
const loading = ref(false);
// 是否显示最大化切换按钮
const maxable = ref(true);
// 用户信息
const form = reactive<Customer>({
customerCode: '',
customerName: '',
customerType: undefined,
progress: undefined,
customerMobile: '',
customerAvatar: '',
customerPhone: '',
customerContacts: '',
customerAddress: '',
comments: '',
status: '0',
sortNumber: 100,
customerId: 0,
userId: '',
});
// 已上传数据, 可赋初始值用于回显
const images = ref(<any>[]);
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules = reactive({
customerName: [
{
required: true,
type: 'string',
message: '请输入订单名称',
trigger: 'blur'
}
],
customerCode: [
{
required: true,
type: 'string',
message: '请输入合法的IP地址',
trigger: 'blur'
}
],
progress: [
{
required: true,
type: 'string',
message: '请选择跟进状态',
trigger: 'blur'
}
]
});
const { resetFields, validate, validateInfos } = useForm(form, rules);
/* 保存编辑 */
const save = () => {
validate()
.then(() => {
loading.value = true;
// 去除空格
form.customerName = form.customerName?.replace(/\s*/g, '');
if(isUpdate.value == false) {
form.userId = loginUser.value.userId;
}
const data = {
...form
};
// 转字符串
const saveOrUpdate = isUpdate.value ? updateCustomer : addCustomer;
saveOrUpdate(data)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
const onUpload = (d: ItemType) => {
uploadFile(<File>d.file)
.then((result) => {
form.customerAvatar = result.path;
message.success('上传成功');
})
.catch((e) => {
message.error(e.message);
});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
loading.value = false;
// 头像赋值
images.value = [];
if(props.data.customerAvatar){
images.value.push({ uid:1, url: FILE_SERVER + props.data.customerAvatar, status: '' });
}
assignObject(form, props.data);
isUpdate.value = true;
} else {
form.customerCode = createCode();
isUpdate.value = false;
}
} else {
resetFields();
}
}
);
</script>
<style lang="less"></style>
@@ -1,155 +0,0 @@
<!-- 用户编辑弹窗 -->
<template>
<ele-modal
width="75%"
:visible="visible"
:confirm-loading="loading"
:title="'订单详情'"
:maxable="true"
:body-style="{ paddingBottom: '8px' }"
@update:visible="updateVisible"
:footer="null"
>
<a-form
:label-col="{ md: { span: 4 }, sm: { span: 24 } }"
:wrapper-col="{ md: { span: 19 }, sm: { span: 24 } }"
>
<div class="base-form" style="margin-bottom: 20px">
<div class="card-head">
<a-avatar
:size="50"
style="margin-right: 10px"
:src="
customer.customerAvatar
? FILE_SERVER + customer.customerAvatar
: ''
"
/>
<h2>{{ customer.customerName }}</h2>
</div>
<a-descriptions bordered>
<a-descriptions-item label="订单名称">
{{ customer.customerName }}
</a-descriptions-item>
<a-descriptions-item label="订单标识">
{{ customer.customerCode }}
</a-descriptions-item>
<a-descriptions-item label="跟进状态">
<div color="blue" v-for="(d, index) in progress" :key="index">
<span v-if="d.key == customer.progress">{{ d.name }}</span>
</div>
</a-descriptions-item>
<a-descriptions-item label="座机电话">
{{ customer.customerPhone }}
</a-descriptions-item>
<a-descriptions-item label="联系人">
{{ customer.customerContacts }}
</a-descriptions-item>
<a-descriptions-item label="订单类型">
<div color="blue" v-for="(d, index) in customerType" :key="index">
<span v-if="d.key == customer.customerType">{{ d.name }}</span>
</div>
</a-descriptions-item>
<a-descriptions-item label="联系地址">
{{ customer.customerAddress }}
</a-descriptions-item>
<a-descriptions-item label="联系电话">
{{ customer.customerMobile }}
</a-descriptions-item>
<a-descriptions-item label="备注">
{{ customer.comments }}
</a-descriptions-item>
</a-descriptions>
<!-- <a-descriptions-->
<!-- title="其他信息"-->
<!-- :column="1"-->
<!-- bordered-->
<!-- style="margin-top: 30px"-->
<!-- >-->
<!-- <a-descriptions-item label="相关项目">-->
<!-- {{ customer.comments }}-->
<!-- </a-descriptions-item>-->
<!-- </a-descriptions>-->
</div>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { Form } from 'ant-design-vue';
import { assignObject } from 'ele-admin-pro';
import type { Customer } from '@/api/oa/customer/model';
import { FILE_SERVER } from '@/config/setting';
import { getDictionaryOptions } from '@/utils/common';
const useForm = Form.useForm;
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: Customer | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 用户信息
const customer = reactive<Customer>({
customerCode: '',
customerName: '',
customerType: undefined,
customerMobile: '',
customerAvatar: '',
customerPhone: '',
customerContacts: '',
customerAddress: '',
comments: '',
progress: '',
status: '0',
sortNumber: 100,
customerId: 0
});
// 请求状态
const loading = ref(true);
const { resetFields } = useForm(customer);
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
/* 获取字典数据 */
const customerType = getDictionaryOptions('customerType');
const progress = getDictionaryOptions('customerFollowStatus');
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
loading.value = false;
assignObject(customer, props.data);
}
} else {
resetFields();
}
}
);
</script>
<style lang="less">
.tab-pane {
min-height: 100px;
}
.card-head {
display: flex;
height: 40px;
align-items: center;
margin-bottom: 30px;
}
</style>
@@ -1,46 +0,0 @@
<!-- 选择下拉框 -->
<template>
<a-select
mode="multiple"
show-search
optionFilterProp="label"
:options="data"
allow-clear
:value="value"
:placeholder="placeholder"
@update:value="updateValue"
@blur="onBlur"
/>
</template>
<script lang="ts" setup>
import { getDictionaryOptions } from '@/utils/common';
const emit = defineEmits<{
(e: 'update:value', value: string): void;
(e: 'blur'): void;
}>();
withDefaults(
defineProps<{
value?: string;
placeholder?: string;
}>(),
{
placeholder: '请选择已开发的端口'
}
);
// 字典数据
const data = getDictionaryOptions('serverOpenPort');
/* 更新选中数据 */
const updateValue = (value: string) => {
emit('update:value', value);
};
/* 失去焦点 */
const onBlur = () => {
emit('blur');
};
</script>
@@ -1,50 +0,0 @@
<!-- 选择下拉框 -->
<template>
<a-space>
<a-button type="dashed" @click="reset">全部</a-button>
<a-button
v-for="(item, index) in data"
:key="index"
type="dashed"
v-model:value.trim="where.progress"
@click="search(item.value)"
>{{ item.label }}</a-button
>
</a-space>
</template>
<script lang="ts" setup>
import { CustomerParam } from '@/api/oa/customer/model';
import useSearch from '@/utils/use-search';
import { getDictionaryOptions } from '@/utils/common';
const emit = defineEmits<{
(e: 'search', where?: CustomerParam): void;
}>();
// 字典数据
const data = getDictionaryOptions('customerFollowStatus');
// 表单数据
const { where, resetFields } = useSearch<CustomerParam>({
progress: '',
customerType: '',
customerSource: '',
betweenTime: '',
userId: ''
});
/* 搜索 */
const search = (val) => {
where.progress = val == 'all' ? null : val;
emit('search', where);
};
/* 重置 */
const reset = () => {
resetFields();
search('all');
};
</script>
<style lang="less"></style>
@@ -1,50 +0,0 @@
<!-- 选择下拉框 -->
<template>
<a-space>
<a-button type="dashed" @click="reset">全部</a-button>
<a-button
v-for="(item, index) in data"
:key="index"
type="dashed"
v-model:value.trim="where.customerSource"
@click="search(item.value)"
>{{ item.label }}</a-button
>
</a-space>
</template>
<script lang="ts" setup>
import { CustomerParam } from '@/api/oa/customer/model';
import useSearch from '@/utils/use-search';
import { getDictionaryOptions } from '@/utils/common';
const emit = defineEmits<{
(e: 'search', where?: CustomerParam): void;
}>();
// 字典数据
const data = getDictionaryOptions('customerSource');
// 表单数据
const { where, resetFields } = useSearch<CustomerParam>({
progress: '',
customerType: '',
customerSource: '',
betweenTime: '',
userId: ''
});
/* 搜索 */
const search = (val) => {
where.customerSource = val == 'all' ? null : val;
emit('search', where);
};
/* 重置 */
const reset = () => {
resetFields();
search('all');
};
</script>
<style lang="less"></style>
@@ -1,45 +0,0 @@
<!-- 选择下拉框 -->
<template>
<a-select
show-search
optionFilterProp="label"
:options="data"
allow-clear
:value="value"
:placeholder="placeholder"
@update:value="updateValue"
@blur="onBlur"
/>
</template>
<script lang="ts" setup>
import { getDictionaryOptions } from '@/utils/common';
const emit = defineEmits<{
(e: 'update:value', value: string): void;
(e: 'blur'): void;
}>();
withDefaults(
defineProps<{
// value?: string;
placeholder?: string;
}>(),
{
placeholder: '请选择状态'
}
);
// 字典数据
const data = getDictionaryOptions('status');
/* 更新选中数据 */
const updateValue = (value: string) => {
emit('update:value', value);
};
/* 失去焦点 */
const onBlur = () => {
emit('blur');
};
</script>
@@ -1,48 +0,0 @@
<!-- 选择下拉框 -->
<template>
<a-space>
<a-button type="dashed" @click="reset">全部</a-button>
<a-button
v-for="(item, index) in data"
:key="index"
type="dashed"
v-model:value.trim="where.betweenTime"
@click="search(item.value)"
>{{ item.label }}</a-button
>
</a-space>
</template>
<script lang="ts" setup>
import { CustomerParam } from '@/api/oa/customer/model';
import useSearch from '@/utils/use-search';
import { getDictionaryOptions } from '@/utils/common';
const emit = defineEmits<{
(e: 'search', where?: CustomerParam): void;
}>();
// 字典数据
const data = getDictionaryOptions('timeFilter');
// 表单数据
const { where, resetFields } = useSearch<CustomerParam>({
progress: '',
customerType: '',
customerSource: '',
betweenTime: '',
userId: ''
});
/* 搜索 */
const search = (val) => {
where.betweenTime = val == 'all' ? null : val;
emit('search', where);
};
/* 重置 */
const reset = () => {
resetFields();
search('all');
};
</script>
@@ -1,53 +0,0 @@
<!-- 选择下拉框 -->
<template>
<a-space>
<a-button type="dashed" @click="reset">全部</a-button>
<a-button
v-for="(item, index) in data"
:key="index"
type="dashed"
v-model:value.trim="where.customerType"
@click="search(item.value)"
>{{ item.label }}</a-button
>
</a-space>
</template>
<script lang="ts" setup>
import { CustomerParam } from '@/api/oa/customer/model';
import useSearch from '@/utils/use-search';
import { getDictionaryOptions } from '@/utils/common';
const emit = defineEmits<{
(e: 'search', where?: CustomerParam): void;
}>();
// 字典数据
const data = getDictionaryOptions('customerType');
// 表单数据
const { where, resetFields } = useSearch<CustomerParam>({
progress: '',
customerType: '',
customerSource: '',
betweenTime: '',
userId: ''
});
/* 搜索 */
const search = (val) => {
where.customerType = val == 'all' ? null : val;
emit('search', where);
console.log(where);
};
/* 重置 */
const reset = () => {
resetFields();
search('all');
};
// reset();
</script>
<style lang="less"></style>
@@ -1,78 +0,0 @@
<!-- 选择下拉框 -->
<template>
<a-select
show-search
optionFilterProp="label"
:options="data"
allow-clear
:placeholder="placeholder"
@change="onChange"
style="width: 300px"
/>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { listUsers } from '@/api/system/user';
import type { SelectProps } from 'ant-design-vue';
import { UserParam } from '@/api/system/user/model';
import { CustomerParam } from '@/api/oa/customer/model';
const emit = defineEmits<{
(e: 'search', where?: CustomerParam): void;
(e: 'update:value', value: string): void;
(e: 'blur'): void;
}>();
withDefaults(
defineProps<{
// value?: string;
placeholder?: string;
}>(),
{
placeholder: '请选择客户负责人'
}
);
// 字典数据
const data = ref<SelectProps['options']>([]);
/* 更新选中数据 */
// const updateValue = (value: string) => {
// emit('update:value', value);
// };
// 默认搜索条件
const where = ref<UserParam>({});
const search = () => {
/* 获取用户列 */
listUsers({ ...where?.value })
.then((result) => {
data.value = result?.map((d) => {
return {
value: d.userId,
label: d.nickname
};
});
})
.catch((e) => {
message.error(e.message);
});
};
const onSearch = (userId) => {
};
search();
/* 失去焦点 */
const onBlur = () => {
};
// 搜素
const onChange = (userId) => {
emit('search', { userId });
};
</script>
-140
View File
@@ -1,140 +0,0 @@
<!-- 搜索表单 -->
<template>
<a-space>
<a-button
type="primary"
class="ele-btn-icon"
v-permission="'shop:goods:save'"
@click="add"
>
<template #icon>
<PlusOutlined />
</template>
<span>新建</span>
</a-button>
<a-button
danger
type="primary"
class="ele-btn-icon"
:v-role="`dev`"
@click="removeBatch"
v-if="props.selection.length > 0"
>
<template #icon>
<DeleteOutlined />
</template>
<span>批量删除</span>
</a-button>
<a-radio-group v-model:value="listType" @change="handleTabs">
<a-radio-button value="all">全部</a-radio-button>
<a-radio-button value="onSale">出售中</a-radio-button>
<a-radio-button value="offSale">已下架</a-radio-button>
<a-radio-button value="soldOut">已售罄</a-radio-button>
</a-radio-group>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入搜索关键词"
@search="search"
@pressEnter="search"
>
<template #addonBefore>
<a-select v-model:value="type" style="width: 120px; margin: -5px -12px">
<a-select-option value="goodsName">商品名称</a-select-option>
<a-select-option value="goodsCode">商品编号</a-select-option>
</a-select>
</template>
</a-input-search>
<!-- <CustomerSelect-->
<!-- v-model:value="where.goodsId"-->
<!-- :placeholder="`按商品筛选`"-->
<!-- @select="onSelect"-->
<!-- @clear="onClear"-->
<!-- />-->
</a-space>
</template>
<script lang="ts" setup>
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import useSearch from '@/utils/use-search';
import type { GoodsParam } from '@/api/goods/model';
// import CustomerSelect from '@/components/CustomerSelect/index.vue';
import { ref, watch } from 'vue';
const emit = defineEmits<{
(e: 'search', where?: GoodsParam): void;
(e: 'add'): void;
(e: 'remove'): void;
}>();
const props = defineProps<{
// 勾选的项目
selection?: [];
}>();
// 表单数据
const { where, resetFields } = useSearch<GoodsParam>({
goodsId: undefined,
goodsName: '',
goodsCode: ''
});
// 下来选项
const type = ref('goodsName');
// tabType
const listType = ref('all');
// 搜索内容
const searchText = ref('');
/* 搜索 */
const search = () => {
if (type.value == 'goodsName') {
where.goodsName = searchText.value;
where.goodsCode = undefined;
}
if (type.value == 'goodsCode') {
where.goodsCode = searchText.value;
where.goodsName = undefined;
}
emit('search', where);
};
const handleTabs = (e) => {
listType.value = e.target.value;
if (listType.value == 'all') {
resetFields();
}
if (listType.value == 'onSale') {
where.status = 0;
}
if (listType.value == 'offSale') {
where.status = 1;
}
if (listType.value == 'soldOut') {
where.stockTotal = 0;
}
emit('search', where);
};
// 新增
const add = () => {
emit('add');
};
// 批量删除
const removeBatch = () => {
emit('remove');
};
/* 重置 */
// const reset = () => {
// resetFields();
// search();
// };
// 监听字典id变化
watch(
() => props.selection,
() => {}
);
</script>
@@ -1,45 +0,0 @@
<!-- 角色选择下拉框 -->
<template>
<a-select
show-search
optionFilterProp="label"
:options="data"
allow-clear
:value="value"
:placeholder="placeholder"
@update:value="updateValue"
@blur="onBlur"
/>
</template>
<script lang="ts" setup>
import { getDictionaryOptions } from '@/utils/common';
const emit = defineEmits<{
(e: 'update:value', value: string): void;
(e: 'blur'): void;
}>();
withDefaults(
defineProps<{
value?: string;
placeholder?: string;
}>(),
{
placeholder: '请选择年限'
}
);
// 字典数据
const data = getDictionaryOptions('serverYears');
/* 更新选中数据 */
const updateValue = (value: string) => {
emit('update:value', value);
};
/* 失去焦点 */
const onBlur = () => {
emit('blur');
};
</script>
-367
View File
@@ -1,367 +0,0 @@
<template>
<div class="page">
<!-- <a-page-header :ghost="false" title="商品管理">-->
<!-- <div class="ele-text-secondary">-->
<!-- 商品管理模块,支持视频上传、多图上传、多规格。-->
<!-- </div>-->
<!-- </a-page-header>-->
<div class="ele-body">
<a-card :bordered="false">
<!-- 表格 -->
<ele-pro-table
ref="tableRef"
row-key="goodsId"
:columns="columns"
:height="tableHeight"
:datasource="datasource"
v-model:selection="selection"
:scroll="{ x: 800 }"
>
<template #toolbar>
<search
:selection="selection"
@search="reload"
@add="openEdit"
@remove="removeBatch"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'image'">
<a-image :src="record.image" :preview="false" :width="80" />
</template>
<template v-if="column.key === 'goodsPhoneModelList'">
<p v-if="!record.hide">{{ record.goodsPhoneModelList[0].phoneModelName
}}</p>
<template v-else>
<p v-for="(item, index) in record.goodsPhoneModelList" :key="index">
{{ item.phoneModelName }}
</p>
</template>
<a-button @click.native="record.hide = !record.hide"
v-if="record.goodsPhoneModelList.length > 0"
type="link"
size="small"
>
<template v-if="!record.hide">
<span>展开</span>
<DownOutlined />
</template>
<template v-else>
<span>折叠</span>
<UpOutlined />
</template>
</a-button>
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 0" color="green">上架</a-tag>
<a-tag v-if="record.status === 1" color="orange">下架</a-tag>
</template>
<template v-if="column.key === 'comments'">
<a-tooltip :title="record.comments" placement="topLeft">
{{ record.comments }}
</a-tooltip>
</template>
<template v-if="column.key === 'createTime'">
<a-tooltip :title="`${toDateString(record.createTime)}`">
{{ timeAgo(record.createTime) }}
</a-tooltip>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openPriceEdit(record)">价格设置</a>
<a-divider type="vertical" />
<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>
<!-- 编辑弹窗 -->
<goods-edit
v-model:visible="showEdit"
:data="current"
:category-list="data"
@done="reload"
/>
<goods-price-edit
v-model:visible="showPriceEdit"
:data="current"
:category-list="data"
@done="reload"
/>
<!-- 用户详情 -->
<goods-info v-model:visible="showInfo" :data="current" @done="reload" />
</div>
</div>
</template>
<!--suppress TypeScriptValidateTypes -->
<script lang="ts" setup>
import { timeAgo, toTreeData } from "ele-admin-pro";
import { createVNode, computed, ref } from "vue";
import { message, Modal } from "ant-design-vue";
import { ExclamationCircleOutlined, DownOutlined, UpOutlined } 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 { toDateString } from "ele-admin-pro";
import Search from "./components/search.vue";
import GoodsEdit from "./components/goods-edit.vue";
import GoodsInfo from "./components/goods-info.vue";
import { pageGoods, removeGoods, removeBatchGoods } from "@/api/goods";
import type { Goods, GoodsParam } from "@/api/goods/model";
import { listCategory } from "@/api/goods/category";
import { Category } from "@/api/goods/category/model";
import { FILE_THUMBNAIL } from "@/config/setting";
import GoodsPriceEdit from "@/views/goods/index/components/goods-price-edit.vue";
// import { getDictionaryOptions } from '@/utils/common';
// import { useUserStore } from '@/store/modules/user';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 是否显示资产详情
const showInfo = ref(false);
// 加载状态
const loading = ref(true);
// 树展开的key
const expandedRowKeys = ref<number[]>([]);
// 树选中的key
const selectedRowKeys = ref<number[]>([]);
// 当前用户信息
// const brand = getDictionaryOptions('serverBrand');
// 表格列配置
const columns = ref<ColumnItem[]>([
{
key: "index",
width: 48,
align: "center",
fixed: "left",
hideInSetting: true,
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
},
{
title: "商品名称",
dataIndex: "goodsName"
},
// {
// title: "商品分类",
// dataIndex: "categoryName"
// },
{
title: "参与机型",
key: "goodsPhoneModelList"
},
{
title: "成本价",
dataIndex: "cost",
customRender: ({ text }) => `${text}元`
},
{
title: "划线价",
dataIndex: "linePriceMin",
customRender: ({ text }) => `${text}元`
},
{
title: "备注",
dataIndex: "comments",
key: "comments",
hideInTable: true,
ellipsis: true
},
{
title: "状态",
dataIndex: "status",
sorter: true,
key: "status",
showSorterTooltip: false,
customRender: ({ text }) => ["上架", "下架"][text]
},
{
title: "排序",
dataIndex: "sortNumber",
sorter: true
},
{
title: "创建时间",
dataIndex: "createTime",
key: "createTime",
sorter: true,
ellipsis: true,
hideInTable: true,
customRender: ({ text }) => toDateString(text)
},
{
title: "操作",
key: "action",
width: 200,
align: "center",
hideInSetting: true
}
]);
// 表格选中数据
const selection = ref<Goods[]>([]);
// 当前编辑数据
const current = ref<Goods | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
const showPriceEdit = ref(false);
// 树形数据
const data = ref<Category[]>([]);
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
if (filters) {
where.brand = filters.brand;
}
return pageGoods({ ...where, ...orders, page, limit });
};
/* 查询 */
const query = () => {
loading.value = true;
listCategory()
.then((list) => {
loading.value = false;
const eks: number[] = [];
list.forEach((d) => {
d.key = d.categoryId;
d.value = d.categoryId;
if (typeof d.categoryId === "number") {
eks.push(d.categoryId);
}
});
expandedRowKeys.value = eks;
data.value = toTreeData({
data: list,
idField: "categoryId",
parentIdField: "parentId"
});
if (list.length) {
if (typeof list[0].categoryId === "number") {
selectedRowKeys.value = [list[0].categoryId];
}
current.value = list[0];
} else {
selectedRowKeys.value = [];
current.value = null;
}
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
/* 搜索 */
const reload = (where?: GoodsParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
// 搜索是否展开
const searchExpand = ref(false);
// 表格固定高度
const fixedHeight = ref(false);
// 表格高度
const tableHeight = computed(() => {
return fixedHeight.value
? searchExpand.value
? "calc(100vh - 618px)"
: "calc(100vh - 562px)"
: void 0;
});
/* 打开编辑弹窗 */
const openEdit = (row?: Goods) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 打开编辑弹窗 */
const openPriceEdit = (row?: Goods) => {
current.value = row ?? null;
showPriceEdit.value = true;
};
/* 删除单个 */
const remove = (row: Goods) => {
const hide = message.loading("请求中..", 0);
removeGoods(row.goodsId)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error("请至少选择一条数据");
return;
}
Modal.confirm({
title: "提示",
content: "确定要删除选中的记录吗?",
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading("请求中..", 0);
removeBatchGoods(selection.value.map((d) => d.goodsId))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
query();
</script>
<script lang="ts">
export default {
name: "GoodsIndex"
};
</script>
<style lang="less" scoped>
// 文字超出隐藏(两行)
// 需要设置文字容器的宽度
.twoline-hide {
width: 320px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
}
</style>
@@ -1,181 +0,0 @@
<!-- 服务编辑弹窗 -->
<template>
<ele-modal
:width="500"
:visible="visible"
:confirm-loading="loading"
:title="isUpdate ? '修改记录' : '添加记录'"
:body-style="{ paddingBottom: '8px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="styleResponsive ? { md: 5, sm: 5, xs: 24 } : { flex: '90px' }"
:wrapper-col="
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="名称" name="name">
<a-input
allow-clear
:maxlength="20"
placeholder="请输入服务承诺名称"
v-model:value="form.name"
/>
</a-form-item>
<a-form-item label="描述" name="summary">
<a-textarea
:rows="4"
:maxlength="200"
placeholder="请输入描述内容"
v-model:value="form.summary"
/>
</a-form-item>
<a-form-item label="排序" name="sortNumber">
<a-input
allow-clear
:maxlength="20"
placeholder="排序"
v-model:value="form.sortNumber"
/>
</a-form-item>
<a-form-item label="默认勾选" name="isDefault">
<a-radio-group v-model:value="form.isDefault">
<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 { ref, reactive, watch } from 'vue';
import { message } from 'ant-design-vue/es';
import type { FormInstance, Rule } from 'ant-design-vue/es/form';
import { storeToRefs } from 'pinia';
import { useThemeStore } from '@/store/modules/theme';
import useFormData from '@/utils/use-form-data';
import { addService, updateService } from '@/api/goods/service/index';
import type { Service } from '@/api/goods/service/model';
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: Service | null;
}>();
//
const formRef = ref<FormInstance | null>(null);
// 是否是修改
const isUpdate = ref(false);
// 提交状态
const loading = ref(false);
// 表单数据
const { form, resetFields, assignFields } = useFormData<Service>({
serviceId: undefined,
name: '',
summary: '',
isDefault: 1,
sortNumber: 100
});
// 表单验证规则
const rules = reactive<Record<string, Rule[]>>({
name: [
{
required: true,
message: '请输入服务承诺名称',
type: 'string',
trigger: 'blur'
}
],
summary: [
{
required: true,
message: '请输入服务承诺描述',
type: 'string',
trigger: 'blur'
}
],
isDefault: [
{
required: true,
message: '请设置是否勾选',
type: 'number',
trigger: 'blur'
}
],
sortNumber: [
{
required: true,
message: '请填写排序',
type: 'number',
trigger: 'blur'
}
]
});
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateService : addService;
saveOrUpdate(form)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
assignFields(props.data);
isUpdate.value = true;
} else {
isUpdate.value = false;
}
} else {
resetFields();
formRef.value?.clearValidate();
}
}
);
</script>
-212
View File
@@ -1,212 +0,0 @@
<template>
<div class="ele-body">
<a-card :bordered="false">
<!-- 表格 -->
<ele-pro-table
ref="tableRef"
row-key="serviceId"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 800 }"
cache-key="proShopServiceTable"
>
<template #toolbar>
<a-space>
<a-button type="primary" class="ele-btn-icon" @click="openEdit()">
<template #icon>
<plus-outlined />
</template>
<span>新建</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入关键词"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'isDefault'">
<!-- 是否默认 -->
<a-tag :color="record.isDefault ? 'green' : ''">
{{ record.isDefault ? '是' : '否' }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">修改</a>
<a-divider type="vertical" />
<a-popconfirm
placement="topRight"
title="确定要删除此服务承诺吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<service-edit v-model:visible="showEdit" :data="current" @done="reload" />
</div>
</template>
<script lang="ts" setup>
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue/es';
import {
PlusOutlined,
DeleteOutlined,
ExclamationCircleOutlined
} from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro/es';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import { messageLoading, toDateString } from 'ele-admin-pro/es';
import ServiceEdit from './components/service-edit.vue';
import {
pageServices,
removeService,
removeServices
} from '@/api/goods/service';
import type { Service, ServiceParam } from '@/api/goods/service/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格列配置
const columns = ref<ColumnItem[]>([
{
key: 'index',
width: 48,
align: 'center',
fixed: 'left',
hideInSetting: true,
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
},
{
title: '服务名称',
dataIndex: 'name',
showSorterTooltip: false
},
{
title: '描述',
dataIndex: 'summary',
showSorterTooltip: false
},
{
title: '是否默认',
dataIndex: 'isDefault',
key: 'isDefault',
width: 180
},
{
title: '创建时间',
dataIndex: 'createTime',
width: 180,
customRender: ({ text }) => toDateString(text)
},
{
title: '操作',
key: 'action',
width: 200,
align: 'center'
}
]);
// 表格选中数据
const selection = ref<Service[]>([]);
// 当前编辑数据
const current = ref<Service | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
const type = ref('goodsName');
const searchText = ref('');
const score = ref(0);
const handleTabs = (e) => {
score.value = e.target.value;
};
// 表格数据源
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
if (type.value == 'goodsName') {
where.goodsName = searchText.value;
}
if (type.value == 'goodsCode') {
where.goodsCode = searchText.value;
}
if (type.value == 'userId') {
where.userId = searchText.value;
}
return pageServices({ ...where, ...orders, page, limit });
};
/* 搜索 */
const reload = (where?: ServiceParam) => {
selection.value = [];
tableRef?.value?.reload({ page: 1, where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: Service) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 删除单个 */
const remove = (row: Service) => {
const hide = messageLoading('请求中..', 0);
removeService(row.serviceId)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的服务承诺吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = messageLoading('请求中..', 0);
removeServices(selection.value.map((d) => d.serviceId))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
</script>
<script lang="ts">
export default {
name: 'ShopService'
};
</script>
@@ -0,0 +1,343 @@
<template>
<a-drawer
placement="right"
width="78%"
:visible="visible"
:title="isUpdate ? '编辑基层团支部工作台帐' : '新增基层团支部工作台帐'"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<a-form :model="form" layout="vertical">
<a-tabs v-model:activeKey="activeKey" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="overview" tab="团支部简况">
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="单位">
<a-input v-model:value="form.unitName" placeholder="请输入单位" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="团支部书记姓名">
<a-input v-model:value="form.branchSecretaryName" placeholder="请输入团支部书记姓名" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="手机号码">
<a-input v-model:value="form.branchSecretaryPhone" placeholder="请输入手机号码" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8" v-for="item in overviewFields" :key="item.key">
<a-form-item :label="item.label">
<a-input v-model:value="form[item.key]" :placeholder="`请输入${item.label}`" />
</a-form-item>
</a-col>
</a-row>
</a-tab-pane>
<a-tab-pane key="branchMembers" tab="团支部班子成员名单">
<editable-table title="团支部班子成员名单" :rows="form.branchCommitteeMembers" :columns="branchCommitteeColumns" :create-row="createBranchCommitteeMember" :scroll-x="1100" />
</a-tab-pane>
<a-tab-pane key="leagueMembers" tab="团员登记表">
<editable-table title="团员登记表" :rows="form.leagueMemberRegisters" :columns="leagueMemberColumns" :create-row="createLeagueMemberRegister" :scroll-x="1380" />
</a-tab-pane>
<a-tab-pane key="youths" tab="青年登记表">
<editable-table title="青年登记表" :rows="form.youthRegisters" :columns="youthColumns" :create-row="createYouthRegister" :scroll-x="1180" />
</a-tab-pane>
<a-tab-pane key="honors" tab="荣誉登记表">
<editable-table title="荣誉登记表" :rows="form.branchHonorRecords" :columns="branchHonorColumns" :create-row="createBranchHonorRecord" :scroll-x="1120" />
</a-tab-pane>
<a-tab-pane key="fees" tab="团费收缴登记表">
<editable-table title="团员团费收缴登记表" :rows="form.memberFeeRecords" :columns="memberFeeColumns" :create-row="createMemberFeeRecord" :scroll-x="1750" />
</a-tab-pane>
<a-tab-pane key="meeting" tab="团支部会议记录">
<editable-table title="团支部有关会议记录" :rows="form.branchMeetingRecords" :columns="meetingColumns" :create-row="createBranchMeetingRecord" :scroll-x="1400" />
</a-tab-pane>
<a-tab-pane key="activity" tab="团支部活动记录">
<editable-table title="团支部有关活动记录" :rows="form.branchActivityRecords" :columns="activityColumns" :create-row="createBranchActivityRecord" :scroll-x="1320" />
</a-tab-pane>
<a-tab-pane key="evaluation" tab="教育评议结果">
<editable-table title="年度团员教育评议结果汇总表" :rows="form.branchEducationEvaluations" :columns="branchEducationColumns" :create-row="createBranchEducationEvaluation" :scroll-x="1080" />
</a-tab-pane>
<a-tab-pane key="summary" tab="年度工作总结">
<a-row :gutter="16">
<a-col :xs="24" :md="8">
<a-form-item label="编号发展团员智慧团建录入率">
<a-input v-model:value="form.smartLeagueEntryRate" placeholder="若无请填无" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="学社衔接率">
<a-input v-model:value="form.schoolSocietyConnectionRate" placeholder="若无请填无" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="对标定级等次">
<a-input v-model:value="form.standardLevel" placeholder="若无请填无" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="团支部年度工作总结">
<a-textarea v-model:value="form.branchAnnualSummary" :rows="18" placeholder="请输入团支部年度工作总结" />
</a-form-item>
</a-tab-pane>
</a-tabs>
</a-form>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save">保存</a-button>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { leagueBranchLedgerApi } from '@/api/gxmu/work-ledger';
import type { WorkLedger } from '@/api/gxmu/work-ledger/model';
import EditableTable from '../../workLedger/components/editable-table.vue';
import { useGxmuFormSelectOptions } from '@/views/gxmu/useFormSelectOptions';
const props = defineProps<{
visible: boolean;
data?: WorkLedger | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const loading = ref(false);
const isUpdate = ref(false);
const activeKey = ref('overview');
const { nationOptions, loadNationOptions } = useGxmuFormSelectOptions();
const rowKey = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const withKey = <T extends Record<string, any>>(row: T) => ({ ...row, __rowKey: rowKey() });
const createBranchCommitteeMember = () => withKey({ name: '', gender: '', birthMonth: '', politics: '', leagueDuty: '', partTimeWork: '' });
const createLeagueMemberRegister = () => withKey({ name: '', gender: '', birthMonth: '', nation: '', joinMonth: '', leagueDuty: '', workDuty: '', remark: '' });
const createYouthRegister = () => withKey({ name: '', gender: '', birthMonth: '', nation: '', workDuty: '', remark: '' });
const createBranchHonorRecord = () => withKey({ honorName: '', awardTime: '', winner: '', rewardSituation: '', remark: '' });
const createMemberFeeRecord = () => withKey({
name: '', januaryAmount: '', februaryAmount: '', marchAmount: '', aprilAmount: '', mayAmount: '', juneAmount: '',
julyAmount: '', augustAmount: '', septemberAmount: '', octoberAmount: '', novemberAmount: '', decemberAmount: '', totalAmount: ''
});
const createBranchMeetingRecord = () => withKey({ meetingName: '', meetingTheme: '', time: '', location: '', host: '', attendees: '', absentees: '', content: '', recorder: '' });
const createBranchActivityRecord = () => withKey({ activityName: '', activityTheme: '', activityTime: '', location: '', participants: '', content: '', recorder: '' });
const createBranchEducationEvaluation = () => withKey({ name: '', excellent: '', qualified: '', basicallyQualified: '', unqualified: '', remark: '' });
const overviewFields = [
{ label: '总人数', key: 'totalPeopleCount' },
{ label: '团员数', key: 'memberTotal' },
{ label: '党员数', key: 'partyMemberCount' },
{ label: '青年数', key: 'youthCount' },
{ label: '团青比例', key: 'leagueYouthRatio' },
{ label: '教职工团员数', key: 'facultyMemberCount' },
{ label: '学生团员数', key: 'studentMemberCount' },
{ label: '少数民族团员数', key: 'minorityMemberCount' },
{ label: '女团员数', key: 'femaleMemberCount' }
];
const branchCommitteeColumns = [
{ title: '姓名', key: 'name', width: 140 },
{ title: '性别', key: 'gender', width: 100 },
{ title: '出生年月', key: 'birthMonth', width: 140 },
{ title: '政治面貌', key: 'politics', width: 140 },
{ title: '团内职务', key: 'leagueDuty', width: 140 },
{ title: '兼任工作', key: 'partTimeWork', width: 220 },
{ title: '操作', key: 'action', width: 90 }
];
const leagueMemberColumns = computed(() => [
{ title: '姓名', key: 'name', width: 140 },
{ title: '性别', key: 'gender', width: 90 },
{ title: '出生年月', key: 'birthMonth', width: 130 },
{ title: '民族', key: 'nation', width: 100, options: nationOptions.value },
{ title: '入团年月', key: 'joinMonth', width: 130 },
{ title: '团内职务', key: 'leagueDuty', width: 160 },
{ title: '工作职务', key: 'workDuty', width: 160 },
{ title: '备注', key: 'remark', width: 160 },
{ title: '操作', key: 'action', width: 90 }
]);
const youthColumns = computed(() => [
{ title: '姓名', key: 'name', width: 140 },
{ title: '性别', key: 'gender', width: 90 },
{ title: '出生年月', key: 'birthMonth', width: 130 },
{ title: '民族', key: 'nation', width: 100, options: nationOptions.value },
{ title: '工作职务', key: 'workDuty', width: 180 },
{ title: '备注', key: 'remark', width: 180 },
{ title: '操作', key: 'action', width: 90 }
]);
const branchHonorColumns = [
{ title: '荣誉名称(校级及以上)', key: 'honorName', width: 280 },
{ title: '获奖时间', key: 'awardTime', width: 140 },
{ title: '获奖者(集体或个人)', key: 'winner', width: 220 },
{ title: '奖励情况', key: 'rewardSituation', width: 160 },
{ title: '备注', key: 'remark', width: 160 },
{ title: '操作', key: 'action', width: 90 }
];
const memberFeeColumns = [
{ title: '姓名', key: 'name', width: 140 },
{ title: '一月', key: 'januaryAmount', width: 90 },
{ title: '二月', key: 'februaryAmount', width: 90 },
{ title: '三月', key: 'marchAmount', width: 90 },
{ title: '四月', key: 'aprilAmount', width: 90 },
{ title: '五月', key: 'mayAmount', width: 90 },
{ title: '六月', key: 'juneAmount', width: 90 },
{ title: '七月', key: 'julyAmount', width: 90 },
{ title: '八月', key: 'augustAmount', width: 90 },
{ title: '九月', key: 'septemberAmount', width: 90 },
{ title: '十月', key: 'octoberAmount', width: 90 },
{ title: '十一月', key: 'novemberAmount', width: 90 },
{ title: '十二月', key: 'decemberAmount', width: 90 },
{ title: '小计', key: 'totalAmount', width: 100 },
{ title: '操作', key: 'action', width: 90 }
];
const meetingColumns = [
{ title: '会议名称', key: 'meetingName', width: 180 },
{ title: '会议主题', key: 'meetingTheme', width: 220 },
{ title: '时间', key: 'time', width: 150 },
{ title: '地点', key: 'location', width: 150 },
{ title: '主持人', key: 'host', width: 120 },
{ title: '与会人员', key: 'attendees', width: 220, textarea: true },
{ title: '缺席人员', key: 'absentees', width: 160 },
{ title: '会议记录', key: 'content', width: 320, textarea: true },
{ title: '记录人', key: 'recorder', width: 120 },
{ title: '操作', key: 'action', width: 90 }
];
const activityColumns = [
{ title: '活动名称', key: 'activityName', width: 180 },
{ title: '活动主题', key: 'activityTheme', width: 220 },
{ title: '活动时间', key: 'activityTime', width: 160 },
{ title: '活动地点', key: 'location', width: 160 },
{ title: '参与人员', key: 'participants', width: 220, textarea: true },
{ title: '活动内容', key: 'content', width: 420, textarea: true },
{ title: '记录人', key: 'recorder', width: 120 },
{ title: '操作', key: 'action', width: 90 }
];
const branchEducationColumns = [
{ title: '姓名', key: 'name', width: 180 },
{ title: '优秀', key: 'excellent', width: 120 },
{ title: '合格', key: 'qualified', width: 120 },
{ title: '基本合格', key: 'basicallyQualified', width: 140 },
{ title: '不合格', key: 'unqualified', width: 120 },
{ title: '备注', key: 'remark', width: 180 },
{ title: '操作', key: 'action', width: 90 }
];
const createDefaultForm = (): WorkLedger => ({
id: undefined,
unitName: '',
branchSecretaryName: '',
branchSecretaryPhone: '',
totalPeopleCount: '',
memberTotal: '',
partyMemberCount: '',
youthCount: '',
leagueYouthRatio: '',
facultyMemberCount: '',
studentMemberCount: '',
minorityMemberCount: '',
femaleMemberCount: '',
smartLeagueEntryRate: '',
schoolSocietyConnectionRate: '',
standardLevel: '',
branchAnnualSummary: '',
branchCommitteeMembers: [createBranchCommitteeMember()],
leagueMemberRegisters: [createLeagueMemberRegister()],
youthRegisters: [createYouthRegister()],
branchHonorRecords: [createBranchHonorRecord()],
memberFeeRecords: [createMemberFeeRecord()],
branchMeetingRecords: [createBranchMeetingRecord()],
branchActivityRecords: [createBranchActivityRecord()],
branchEducationEvaluations: [createBranchEducationEvaluation()]
});
const form = reactive<WorkLedger>(createDefaultForm());
const normalizeRows = (rows: any[] | undefined, createRow: () => Record<string, any>) =>
rows?.length ? rows.map((item) => ({ ...createRow(), ...item, __rowKey: rowKey() })) : [createRow()];
const assignForm = (record?: WorkLedger | null) => {
const defaults = createDefaultForm();
Object.assign(form, defaults, record || {});
form.branchCommitteeMembers = normalizeRows(record?.branchCommitteeMembers, createBranchCommitteeMember) as any;
form.leagueMemberRegisters = normalizeRows(record?.leagueMemberRegisters, createLeagueMemberRegister) as any;
form.youthRegisters = normalizeRows(record?.youthRegisters, createYouthRegister) as any;
form.branchHonorRecords = normalizeRows(record?.branchHonorRecords, createBranchHonorRecord) as any;
form.memberFeeRecords = normalizeRows(record?.memberFeeRecords, createMemberFeeRecord) as any;
form.branchMeetingRecords = normalizeRows(record?.branchMeetingRecords, createBranchMeetingRecord) as any;
form.branchActivityRecords = normalizeRows(record?.branchActivityRecords, createBranchActivityRecord) as any;
form.branchEducationEvaluations = normalizeRows(record?.branchEducationEvaluations, createBranchEducationEvaluation) as any;
};
const cleanRows = (rows?: any[]) =>
(rows || [])
.map(({ __rowKey, ...item }) => item)
.filter((item) => Object.values(item).some((value) => String(value || '').trim()));
const save = () => {
loading.value = true;
const payload: WorkLedger = {
...form,
branchCommitteeMembers: cleanRows(form.branchCommitteeMembers) as any,
leagueMemberRegisters: cleanRows(form.leagueMemberRegisters) as any,
youthRegisters: cleanRows(form.youthRegisters) as any,
branchHonorRecords: cleanRows(form.branchHonorRecords) as any,
memberFeeRecords: cleanRows(form.memberFeeRecords) as any,
branchMeetingRecords: cleanRows(form.branchMeetingRecords) as any,
branchActivityRecords: cleanRows(form.branchActivityRecords) as any,
branchEducationEvaluations: cleanRows(form.branchEducationEvaluations) as any
};
const saveOrUpdate = payload.id
? leagueBranchLedgerApi.update(payload)
: leagueBranchLedgerApi.add(payload);
saveOrUpdate
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
await loadNationOptions().catch(() => {});
isUpdate.value = !!props.data?.id;
assignForm(props.data);
} else {
assignForm();
}
activeKey.value = 'overview';
}
);
</script>
<style lang="less" scoped>
@import '../../tag-tabs.less';
.gxmu-tag-tabs();
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<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 wrap>
<a-button class="gxmu-toolbar-btn gxmu-add-btn" @click="openEdit()">
<template #icon>
<plus-outlined />
</template>
<span>添加</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入单位/团支部书记"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-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>
</div>
<branch-edit
v-model:visible="showEdit"
:data="current"
@done="reload"
/>
</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 type {
ColumnItem,
DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types';
import { leagueBranchLedgerApi } from '@/api/gxmu/work-ledger';
import type { WorkLedger, WorkLedgerParam } from '@/api/gxmu/work-ledger/model';
import BranchEdit from './components/edit.vue';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const current = ref<WorkLedger | null>(null);
const columns = ref<ColumnItem[]>([
{ title: '单位', dataIndex: 'unitName', key: 'unitName', align: 'center', width: 220, ellipsis: true },
{ title: '团支部书记', dataIndex: 'branchSecretaryName', key: 'branchSecretaryName', align: 'center', width: 120 },
{ title: '手机号码', dataIndex: 'branchSecretaryPhone', key: 'branchSecretaryPhone', align: 'center', width: 140 },
{ title: '团员数', dataIndex: 'memberTotal', key: 'memberTotal', align: 'center', width: 100 },
{ title: '党员数', dataIndex: 'partyMemberCount', key: 'partyMemberCount', align: 'center', width: 100 },
{ title: '青年数', dataIndex: 'youthCount', key: 'youthCount', align: 'center', width: 100 },
{ title: '团青比例', dataIndex: 'leagueYouthRatio', key: 'leagueYouthRatio', align: 'center', width: 120 },
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', align: 'center', width: 180 },
{ title: '操作', key: 'action', align: 'center', fixed: 'right', width: 160 }
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return leagueBranchLedgerApi
.userPage({
page,
limit,
keywords: searchText.value.trim() || undefined
} as WorkLedgerParam)
.catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const openEdit = (record?: WorkLedger) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as WorkLedger)
: null;
showEdit.value = true;
};
const remove = (record: WorkLedger) => {
const hide = message.loading('请求中..', 0);
leagueBranchLedgerApi
.remove(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
@@ -0,0 +1,8 @@
<template>
<work-ledger title="基层团委工作台账" :api="leagueCommitteeLedgerApi" />
</template>
<script lang="ts" setup>
import WorkLedger from '../workLedger/index.vue';
import { leagueCommitteeLedgerApi } from '@/api/gxmu/work-ledger';
</script>
+558
View File
@@ -0,0 +1,558 @@
<template>
<a-drawer
placement="right"
width="1180"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<a-form ref="formRef" :model="form">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 240px"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
<a-tabs v-model:activeKey="activeTab" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="base" tab="登记表">
<div class="sheet-wrap">
<!-- <div class="sheet-main-title"-->
<!-- >2025 年广西医科大学青年马克思主义者培养工程培训班学员登记表</div>-->
<table class="sheet-table">
<colgroup>
<col style="width: 14%" />
<col style="width: 15%" />
<col style="width: 10%" />
<col style="width: 14%" />
<col style="width: 8%" />
<col style="width: 17%" />
<col style="width: 22%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">姓名</td>
<td class="sheet-value">
<a-input
v-model:value="form.name"
placeholder="请输入姓名"
/>
</td>
<td class="sheet-label">性别</td>
<td class="sheet-value">
<a-select v-model:value="form.gender" placeholder="请选择" style="width: 120px">
<a-select-option value="男">男</a-select-option>
<a-select-option value="女">女</a-select-option>
</a-select>
</td>
<td class="sheet-label">民族</td>
<td class="sheet-value">
<a-select
allow-clear
show-search
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
v-model:value="form.nation"
style="width: 120px"
/>
</td>
<td class="sheet-photo-cell" rowspan="4">
<div class="sheet-photo-title">电子版照片</div>
<div class="sheet-photo-box">
<a-image
v-if="form.photo"
:src="form.photo"
:width="96"
/>
<div v-else class="sheet-photo-placeholder">未上传</div>
</div>
<upload-file
v-model:value="form.photo"
accept="image/*"
button-text="上传照片"
/>
</td>
</tr>
<tr>
<td class="sheet-label">出生年月</td>
<td class="sheet-value">
<a-date-picker
v-model:value="form.birthMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择出生年月"
/>
</td>
<td class="sheet-label">政治面貌</td>
<td class="sheet-value">
<a-select style="width: 120px"
allow-clear
show-search
v-model:value="form.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</td>
<td class="sheet-label">籍贯</td>
<td class="sheet-value">
<a-input style="width: 120px"
v-model:value="form.nativePlace"
placeholder="请输入籍贯"
/>
</td>
</tr>
<tr>
<td class="sheet-label">手机号码</td>
<td class="sheet-value" colspan="2">
<a-input
v-model:value="form.phone"
placeholder="请输入手机号码"
/>
</td>
<td class="sheet-label" colspan="2">微信号</td>
<td class="sheet-value">
<a-input
v-model:value="form.wechat"
placeholder="请输入微信号"
/>
</td>
</tr>
<tr>
<td class="sheet-label">电子邮箱</td>
<td class="sheet-value" colspan="2">
<a-input
v-model:value="form.email"
placeholder="请输入电子邮箱"
/>
</td>
<td class="sheet-label" colspan="2">QQ号</td>
<td class="sheet-value">
<a-input v-model:value="form.qq" placeholder="请输入QQ号" />
</td>
</tr>
<tr>
<td class="sheet-label">身份证号</td>
<td class="sheet-value" colspan="2">
<a-input
v-model:value="form.idCardNo"
placeholder="请输入身份证号"
/>
</td>
<td class="sheet-label" colspan="2">爱好特长</td>
<td class="sheet-value" colspan="2">
<a-input
v-model:value="form.hobby"
placeholder="请输入爱好特长"
/>
</td>
</tr>
<tr>
<td class="sheet-label" colspan="4">
毕业后是否有志报考广西选调生<br />
或志愿到我区基层一线和艰苦边远地区工作
</td>
<td class="sheet-value" colspan="3">
<a-radio-group v-model:value="form.willingToWorkInGuangxi">
<a-radio value="是">是</a-radio>
<a-radio value="否">否</a-radio>
</a-radio-group>
</td>
</tr>
<tr>
<td class="sheet-label">学校、院系、年级、专业</td>
<td class="sheet-value left" colspan="6">
<a-input
v-model:value="form.schoolInfo"
placeholder="请输入学校、院系、年级、专业"
/>
</td>
</tr>
<tr>
<td class="sheet-label">担任团学职务情况</td>
<td class="sheet-value left" colspan="6">
<a-input
v-model:value="form.leaguePosition"
placeholder="请输入担任团学职务情况"
/>
</td>
</tr>
<tr>
<td class="sheet-label">个人简历</td>
<td class="sheet-value left" colspan="6">
<a-textarea
v-model:value="form.resume"
:rows="8"
placeholder="请输入个人简历"
/>
</td>
</tr>
<tr>
<td class="sheet-label">奖惩情况</td>
<td class="sheet-value left" colspan="6">
<a-textarea
v-model:value="form.awards"
:rows="7"
placeholder="请输入奖惩情况"
/>
</td>
</tr>
</tbody>
</table>
</div>
</a-tab-pane>
<!-- <a-tab-pane key="review" tab="综合成绩与意见">-->
<!-- <div class="sheet-wrap">-->
<!-- <table class="sheet-table">-->
<!-- <colgroup>-->
<!-- <col style="width: 15%" />-->
<!-- <col style="width: 85%" />-->
<!-- </colgroup>-->
<!-- <tbody>-->
<!-- <tr>-->
<!-- <td class="sheet-label">-->
<!-- 综合成绩<br />-->
<!-- 情况<br />-->
<!-- (按最近一<br />-->
<!-- 学年填写)-->
<!-- </td>-->
<!-- <td class="sheet-value left">-->
<!-- <a-textarea-->
<!-- v-model:value="form.academicPerformance"-->
<!-- :rows="12"-->
<!-- placeholder="示例:最近学年专业综合排名、补考重修情况、思政课程成绩等"-->
<!-- />-->
<!-- </td>-->
<!-- </tr>-->
<!-- <tr>-->
<!-- <td class="sheet-label">二级团组织意见</td>-->
<!-- <td class="sheet-value left opinion-cell">-->
<!-- <a-textarea-->
<!-- v-model:value="form.secondaryLeagueOpinion"-->
<!-- :rows="6"-->
<!-- placeholder="请输入二级团组织意见"-->
<!-- />-->
<!-- <div class="opinion-date-row">-->
<!-- <span>(盖章)</span>-->
<!-- <a-input-->
<!-- v-model:value="form.secondaryLeagueOpinionDate"-->
<!-- placeholder="如:2025年5月1日"-->
<!-- class="date-input"-->
<!-- />-->
<!-- </div>-->
<!-- </td>-->
<!-- </tr>-->
<!-- <tr>-->
<!-- <td class="sheet-label">二级党组织意见</td>-->
<!-- <td class="sheet-value left opinion-cell">-->
<!-- <a-textarea-->
<!-- v-model:value="form.secondaryPartyOpinion"-->
<!-- :rows="6"-->
<!-- placeholder="请输入二级党组织意见"-->
<!-- />-->
<!-- <div class="opinion-date-row">-->
<!-- <span>(盖章)</span>-->
<!-- <a-input-->
<!-- v-model:value="form.secondaryPartyOpinionDate"-->
<!-- placeholder="如:2025年5月2日"-->
<!-- class="date-input"-->
<!-- />-->
<!-- </div>-->
<!-- </td>-->
<!-- </tr>-->
<!-- <tr>-->
<!-- <td class="sheet-label">学校团委意见</td>-->
<!-- <td class="sheet-value left opinion-cell">-->
<!-- <a-textarea-->
<!-- v-model:value="form.schoolLeagueOpinion"-->
<!-- :rows="6"-->
<!-- placeholder="请输入学校团委意见"-->
<!-- />-->
<!-- <div class="opinion-date-row">-->
<!-- <span>(盖章)</span>-->
<!-- <a-input-->
<!-- v-model:value="form.schoolLeagueOpinionDate"-->
<!-- placeholder="如:2025年5月3日"-->
<!-- class="date-input"-->
<!-- />-->
<!-- </div>-->
<!-- </td>-->
<!-- </tr>-->
<!-- </tbody>-->
<!-- </table>-->
<!-- </div>-->
<!-- </a-tab-pane>-->
</a-tabs>
</a-form>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save"
>保存</a-button
>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import UploadFile from '@/components/UploadFile/index.vue';
import { addQmgcForm, updateQmgcForm } from '@/api/gxmu/qmgc-form';
import type { QmgcForm } from '@/api/gxmu/qmgc-form/model';
import { listDictData } from '@/api/system/dict-data';
const NATION_DICT_ID = 173;
const POLITICS_DICT_ID = 172;
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: QmgcForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const activeTab = ref('base');
const nationOptions = ref<{ label: string; value: string }[]>([]);
const politicsOptions = ref<{ label: string; value: string }[]>([]);
const optionsLoaded = ref(false);
const createDefaultForm = (): QmgcForm => ({
id: undefined,
year: '',
name: '',
gender: '',
nation: '',
photo: '',
birthMonth: '',
politics: '',
nativePlace: '',
phone: '',
wechat: '',
email: '',
qq: '',
idCardNo: '',
hobby: '',
willingToWorkInGuangxi: '',
schoolInfo: '',
leaguePosition: '',
resume: '',
awards: '',
academicPerformance: '',
secondaryLeagueOpinion: '',
secondaryLeagueOpinionDate: '',
secondaryPartyOpinion: '',
secondaryPartyOpinionDate: '',
schoolLeagueOpinion: '',
schoolLeagueOpinionDate: ''
});
const form = reactive<QmgcForm>(createDefaultForm());
const filterSelectOption = (input: string, option: any) => {
return String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase());
};
const loadNationOptions = async () => {
const result = await listDictData({ dictId: NATION_DICT_ID });
nationOptions.value = result
.filter((item) => !!item.dictDataName)
.map((item) => ({
label: item.dictDataName as string,
value: item.dictDataName as string
}));
optionsLoaded.value = true;
};
const loadPoliticsOptions = async () => {
const result = await listDictData({ dictId: POLITICS_DICT_ID });
politicsOptions.value = result
.filter((item) => !!item.dictDataName)
.map((item) => ({
label: item.dictDataName as string,
value: item.dictDataName as string
}));
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
if (!optionsLoaded.value) {
try {
await Promise.all([loadNationOptions(), loadPoliticsOptions()]);
} catch (e: any) {
message.error(e.message || '民族字典加载失败');
}
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
activeTab.value = 'base';
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateQmgcForm : addQmgcForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
@import '../../tag-tabs.less';
.gxmu-field-theme();
.gxmu-tag-tabs();
.sheet-wrap {
border: 1px solid #d9d9d9;
padding: 12px;
background: #fff;
}
.sheet-main-title {
font-size: 28px;
font-weight: 700;
text-align: center;
line-height: 1.5;
margin-bottom: 16px;
}
.sheet-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.sheet-table td {
border: 1px solid #3b3b3b;
padding: 8px 6px;
font-size: 13px;
text-align: center;
vertical-align: middle;
}
.sheet-label {
font-weight: 600;
}
.sheet-value.left {
text-align: left;
}
.sheet-photo-cell {
vertical-align: top !important;
}
.sheet-photo-title {
writing-mode: vertical-rl;
text-orientation: upright;
margin: 0 auto 12px;
font-weight: 600;
letter-spacing: 4px;
}
.sheet-photo-box {
min-height: 108px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.sheet-photo-placeholder {
color: #999;
}
.sheet-table .sheet-value :deep(.ant-input),
.sheet-table .sheet-value :deep(.ant-select-selector),
.sheet-table .sheet-value :deep(.ant-radio-group),
.sheet-table .sheet-value :deep(.ant-input-textarea) {
border: 0;
box-shadow: none;
padding: 0;
background: transparent;
}
.sheet-table .sheet-value :deep(textarea.ant-input) {
resize: none;
}
.opinion-cell {
padding: 12px 12px 18px !important;
}
.opinion-date-row {
margin-top: 16px;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
}
.date-input {
width: 220px;
}
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
@media (max-width: 768px) {
.sheet-main-title {
font-size: 18px;
}
}
</style>
@@ -0,0 +1,108 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="青马工程"
file-name="青马工程导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addQmgcForm } from '@/api/gxmu/qmgc-form';
import type { QmgcForm } from '@/api/gxmu/qmgc-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '姓名', key: 'name', required: true },
{ header: '性别', key: 'gender' },
{ header: '民族', key: 'nation' },
{ header: '出生年月', key: 'birthMonth' },
{ header: '政治面貌', key: 'politics' },
{ header: '籍贯', key: 'nativePlace' },
{ header: '手机号', key: 'phone' },
{ header: '微信号', key: 'wechat' },
{ header: '邮箱', key: 'email' },
{ header: 'QQ', key: 'qq' },
{ header: '身份证号', key: 'idCardNo' },
{ header: '兴趣爱好', key: 'hobby' },
{ header: '是否愿意留桂工作', key: 'willingToWorkInGuangxi' },
{ header: '学校信息', key: 'schoolInfo' },
{ header: '团内职务', key: 'leaguePosition' },
{ header: '个人简历', key: 'resume' },
{ header: '获得奖励', key: 'awards' },
{ header: '学习成绩', key: 'academicPerformance' },
{ header: '二级团组织意见', key: 'secondaryLeagueOpinion' },
{ header: '二级团组织意见日期', key: 'secondaryLeagueOpinionDate' },
{ header: '二级党组织意见', key: 'secondaryPartyOpinion' },
{ header: '二级党组织意见日期', key: 'secondaryPartyOpinionDate' },
{ header: '学校团组织意见', key: 'schoolLeagueOpinion' },
{ header: '学校团组织意见日期', key: 'schoolLeagueOpinionDate' }
] as const;
const sampleRow = {
年份: '2025',
姓名: '孙七',
性别: '男',
民族: '汉族',
出生年月: '2001-01',
政治面貌: '中共党员',
籍贯: '广西南宁',
手机号: '13800138000',
微信号: 'sunqi2025',
邮箱: 'sunqi@example.com',
QQ: '12345678',
身份证号: '450101200101011234',
兴趣爱好: '篮球',
是否愿意留桂工作: '是',
学校信息: '广西医科大学 第一临床医学院',
团内职务: '学生会骨干',
个人简历: '2022年至今担任学生干部',
获得奖励: '校级奖学金',
学习成绩: '专业前10%',
二级团组织意见: '',
二级团组织意见日期: '',
二级党组织意见: '',
二级党组织意见日期: '',
学校团组织意见: '',
学校团组织意见日期: ''
};
const createPayload = (row: Record<string, any>): QmgcForm => ({
year: String(row['年份'] || '').trim(),
name: String(row['姓名'] || '').trim(),
gender: String(row['性别'] || '').trim(),
nation: String(row['民族'] || '').trim(),
birthMonth: String(row['出生年月'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
nativePlace: String(row['籍贯'] || '').trim(),
phone: String(row['手机号'] || '').trim(),
wechat: String(row['微信号'] || '').trim(),
email: String(row['邮箱'] || '').trim(),
qq: String(row['QQ'] || '').trim(),
idCardNo: String(row['身份证号'] || '').trim(),
hobby: String(row['兴趣爱好'] || '').trim(),
willingToWorkInGuangxi: String(row['是否愿意留桂工作'] || '').trim(),
schoolInfo: String(row['学校信息'] || '').trim(),
leaguePosition: String(row['团内职务'] || '').trim(),
resume: String(row['个人简历'] || '').trim(),
awards: String(row['获得奖励'] || '').trim(),
academicPerformance: String(row['学习成绩'] || '').trim(),
secondaryLeagueOpinion: String(row['二级团组织意见'] || '').trim(),
secondaryLeagueOpinionDate: String(row['二级团组织意见日期'] || '').trim(),
secondaryPartyOpinion: String(row['二级党组织意见'] || '').trim(),
secondaryPartyOpinionDate: String(row['二级党组织意见日期'] || '').trim(),
schoolLeagueOpinion: String(row['学校团组织意见'] || '').trim(),
schoolLeagueOpinionDate: String(row['学校团组织意见日期'] || '').trim()
});
const saveItem = (item: QmgcForm) => addQmgcForm(item);
</script>
+366
View File
@@ -0,0 +1,366 @@
<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: 2200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入姓名/手机号/学校院系"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'photo'">
<a-image
v-if="record.photo"
:src="record.photo"
:width="44"
:height="58"
style="object-fit: cover"
/>
<span v-else>-</span>
</template>
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(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>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="青马工程"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportQmgcForm,
userPageQmgcForm,
removeQmgcForm
} from '@/api/gxmu/qmgc-form';
import type { QmgcForm, QmgcFormParam } from '@/api/gxmu/qmgc-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<QmgcForm>();
const detailCurrent = ref<QmgcForm | null>(null);
const REVIEW_MODULE = 'gxmu_qmgc_form';
const showReviewHistory = ref(false);
const reviewCurrent = ref<QmgcForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '照片',
dataIndex: 'photo',
key: 'photo',
align: 'center',
width: 88
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
align: 'center',
width: 100
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
align: 'center',
width: 80
},
{
title: '民族',
dataIndex: 'nation',
key: 'nation',
align: 'center',
width: 90
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 120
},
{
title: '手机号码',
dataIndex: 'phone',
key: 'phone',
align: 'center',
width: 140
},
{
title: '微信号',
dataIndex: 'wechat',
key: 'wechat',
align: 'center',
width: 140
},
{
title: '学校、院系、年级、专业',
dataIndex: 'schoolInfo',
key: 'schoolInfo',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '担任团学职务情况',
dataIndex: 'leaguePosition',
key: 'leaguePosition',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '是否有志到基层一线工作',
dataIndex: 'willingToWorkInGuangxi',
key: 'willingToWorkInGuangxi',
align: 'center',
width: 190
},
{
title: '综合成绩情况',
dataIndex: 'academicPerformance',
key: 'academicPerformance',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '奖惩情况',
dataIndex: 'awards',
key: 'awards',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 200
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageQmgcForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as QmgcFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const buildQueryParams = (): QmgcFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as QmgcFormParam;
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(payload?.url || payload?.downloadUrl || payload?.path || '');
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportQmgcForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: QmgcForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as QmgcForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: QmgcForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: QmgcForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const remove = (record: QmgcForm) => {
const hide = message.loading('请求中..', 0);
removeQmgcForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'QMGCForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
+276
View File
@@ -0,0 +1,276 @@
<template>
<a-modal
:width="900"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 100%"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="申报类别"
name="applyType"
:rules="[{ required: true, message: '请输入申报类别' }]"
>
<a-input
v-model:value="form.applyType"
placeholder="请输入申报类别"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<a-input v-model:value="form.name" placeholder="请输入姓名" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="性别" name="gender">
<a-select
v-model:value="form.gender"
:options="genderOptions"
placeholder="请选择性别"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="出生年月" name="birthMonth">
<a-date-picker
v-model:value="form.birthMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择出生年月"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="民族" name="nation">
<a-select
allow-clear
show-search
v-model:value="form.nation"
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="政治面貌" name="politics">
<a-select
allow-clear
show-search
v-model:value="form.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="学历" name="education">
<a-input v-model:value="form.education" placeholder="请输入学历" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="职务" name="position">
<a-input v-model:value="form.position" placeholder="请输入职务" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="职称" name="title">
<a-input v-model:value="form.title" placeholder="请输入职称" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="所在单位" name="unit">
<a-input v-model:value="form.unit" placeholder="请输入所在单位" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="近三年曾获校级及以上奖励" name="awards">
<a-textarea
v-model:value="form.awards"
:rows="3"
placeholder="请输入近三年曾获校级及以上奖励"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="工作(学习)经历" name="experience">
<a-textarea
v-model:value="form.experience"
:rows="3"
placeholder="请输入工作(学习)经历"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="主要事迹" name="mainStory">
<a-textarea
v-model:value="form.mainStory"
:rows="3"
placeholder="请输入主要事迹"
/>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在二级团组织意见" name="leagueOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.leagueOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入所在二级团组织意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在单位党组织意见" name="partyOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.partyOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入所在单位党组织意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="校团委意见" name="schoolOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.schoolOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入校团委意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addSjqnForm, updateSjqnForm } from '@/api/gxmu/sjqn-form';
import type { SjqnForm } from '@/api/gxmu/sjqn-form/model';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: SjqnForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const { nationOptions, politicsOptions, genderOptions, loadNationOptions, loadPoliticsOptions } =
useGxmuFormSelectOptions();
const createDefaultForm = (): SjqnForm => ({
id: undefined,
year: '',
applyType: '',
name: '',
gender: '',
birthMonth: '',
nation: '',
politics: '',
education: '',
position: '',
title: '',
unit: '',
awards: '',
experience: '',
mainStory: '',
leagueOpinion: '',
partyOpinion: '',
schoolOpinion: ''
});
const form = reactive<SjqnForm>(createDefaultForm());
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await Promise.all([loadNationOptions(), loadPoliticsOptions()]);
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateSjqnForm : addSjqnForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,84 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="十佳青年"
file-name="十佳青年导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addSjqnForm } from '@/api/gxmu/sjqn-form';
import type { SjqnForm } from '@/api/gxmu/sjqn-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '申报类别', key: 'applyType', required: true },
{ header: '姓名', key: 'name', required: true },
{ header: '性别', key: 'gender' },
{ header: '出生年月', key: 'birthMonth' },
{ header: '民族', key: 'nation' },
{ header: '政治面貌', key: 'politics' },
{ header: '学历', key: 'education' },
{ header: '职务', key: 'position' },
{ header: '职称', key: 'title' },
{ header: '所在单位', key: 'unit' },
{ header: '近三年曾获校级及以上奖励', key: 'awards' },
{ header: '工作(学习)经历', key: 'experience' },
{ header: '主要事迹', key: 'mainStory' },
{ header: '所在二级团组织意见', key: 'leagueOpinion' },
{ header: '所在二级党组织意见', key: 'partyOpinion' },
{ header: '学校团委意见', key: 'schoolOpinion' }
] as const;
const sampleRow = {
年份: '2025',
申报类别: '教师',
姓名: '张三',
性别: '男',
出生年月: '1998-05',
民族: '汉族',
政治面貌: '中共党员',
学历: '本科',
职务: '团支书',
职称: '讲师',
所在单位: '第一临床医学院',
近三年曾获校级及以上奖励: '校级优秀团干',
'工作(学习)经历': '2020年至今任职',
主要事迹: '长期组织志愿服务活动',
所在二级团组织意见: '',
所在二级党组织意见: '',
学校团委意见: ''
};
const createPayload = (row: Record<string, any>): SjqnForm => ({
year: String(row['年份'] || '').trim(),
applyType: String(row['申报类别'] || '').trim(),
name: String(row['姓名'] || '').trim(),
gender: String(row['性别'] || '').trim(),
birthMonth: String(row['出生年月'] || '').trim(),
nation: String(row['民族'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
education: String(row['学历'] || '').trim(),
position: String(row['职务'] || '').trim(),
title: String(row['职称'] || '').trim(),
unit: String(row['所在单位'] || '').trim(),
awards: String(row['近三年曾获校级及以上奖励'] || '').trim(),
experience: String(row['工作(学习)经历'] || '').trim(),
mainStory: String(row['主要事迹'] || '').trim(),
leagueOpinion: String(row['所在二级团组织意见'] || '').trim(),
partyOpinion: String(row['所在二级党组织意见'] || '').trim(),
schoolOpinion: String(row['学校团委意见'] || '').trim()
});
const saveItem = (item: SjqnForm) => addSjqnForm(item);
</script>
+389
View File
@@ -0,0 +1,389 @@
<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: 2400 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入姓名/单位/申报类别"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(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>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="十佳青年岗位能手"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportSjqnForm,
userPageSjqnForm,
removeSjqnForm
} from '@/api/gxmu/sjqn-form';
import type { SjqnForm, SjqnFormParam } from '@/api/gxmu/sjqn-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<SjqnForm>();
const detailCurrent = ref<SjqnForm | null>(null);
const REVIEW_MODULE = 'gxmu_sjqn';
const showReviewHistory = ref(false);
const reviewCurrent = ref<SjqnForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '申报类别',
dataIndex: 'applyType',
key: 'applyType',
align: 'center',
width: 120
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
align: 'center',
width: 100
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
align: 'center',
width: 80
},
{
title: '出生年月',
dataIndex: 'birthMonth',
key: 'birthMonth',
align: 'center',
width: 120
},
{
title: '民族',
dataIndex: 'nation',
key: 'nation',
align: 'center',
width: 100
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 120
},
{
title: '学历',
dataIndex: 'education',
key: 'education',
align: 'center',
width: 100
},
{
title: '职务',
dataIndex: 'position',
key: 'position',
align: 'center',
width: 120
},
{
title: '职称',
dataIndex: 'title',
key: 'title',
align: 'center',
width: 120
},
{
title: '所在单位',
dataIndex: 'unit',
key: 'unit',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '近三年曾获校级及以上奖励',
dataIndex: 'awards',
key: 'awards',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '工作(学习)经历',
dataIndex: 'experience',
key: 'experience',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '主要事迹',
dataIndex: 'mainStory',
key: 'mainStory',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '所在二级团组织意见',
dataIndex: 'leagueOpinion',
key: 'leagueOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '所在单位党组织意见',
dataIndex: 'partyOpinion',
key: 'partyOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '校团委意见',
dataIndex: 'schoolOpinion',
key: 'schoolOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 220
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageSjqnForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as SjqnFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const buildQueryParams = (): SjqnFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as SjqnFormParam;
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(
payload?.url || payload?.downloadUrl || payload?.path || ''
);
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportSjqnForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: SjqnForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as SjqnForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: SjqnForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: SjqnForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const remove = (record: SjqnForm) => {
const hide = message.loading('请求中..', 0);
removeSjqnForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'Gxmu'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
@@ -0,0 +1,273 @@
<template>
<a-modal
:width="900"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 100%"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<a-input v-model:value="form.name" placeholder="请输入姓名" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="性别" name="gender">
<a-select
v-model:value="form.gender"
:options="genderOptions"
placeholder="请选择性别"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="出生年月" name="birthMonth">
<a-date-picker
v-model:value="form.birthMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择出生年月"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="民族" name="nation">
<a-select
allow-clear
show-search
v-model:value="form.nation"
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="政治面貌" name="politics">
<a-select
allow-clear
show-search
v-model:value="form.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="学院班级/单位科室" name="collegeClass">
<class-cascader
v-model:value="form.collegeClass"
mode="collegeClass"
placeholder="请选择学院班级/单位科室"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="所在团支部" name="branch">
<a-input
v-model:value="form.branch"
placeholder="请输入所在团支部"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度团员教育评议等次" name="eduEval">
<a-input
v-model:value="form.eduEval"
placeholder="请输入上一年度团员教育评议等次"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="近三年曾获校级及以上奖励" name="awards">
<a-textarea
v-model:value="form.awards"
:rows="3"
placeholder="请输入近三年曾获校级及以上奖励"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="工作(学习)经历" name="experience">
<a-textarea
v-model:value="form.experience"
:rows="3"
placeholder="请输入工作(学习)经历"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="主要事迹" name="mainStory">
<a-textarea
v-model:value="form.mainStory"
:rows="3"
placeholder="请输入主要事迹"
/>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在二级团组织意见" name="leagueOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.leagueOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入所在二级团组织意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在单位党组织意见" name="partyOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.partyOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入所在单位党组织意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="校团委意见" name="schoolOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.schoolOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入校团委意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import {
addSjtbzbsjForm,
updateSjtbzbsjForm
} from '@/api/gxmu/sjtbzbsj-form';
import type { SjtbzbsjForm } from '@/api/gxmu/sjtbzbsj-form/model';
import ClassCascader from '@/views/gxmu/components/class-cascader.vue';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: SjtbzbsjForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const { nationOptions, politicsOptions, genderOptions, loadAll } =
useGxmuFormSelectOptions();
const createDefaultForm = (): SjtbzbsjForm => ({
id: undefined,
year: '',
name: '',
gender: '',
birthMonth: '',
nation: '',
politics: '',
collegeClass: '',
branch: '',
eduEval: '',
awards: '',
experience: '',
mainStory: '',
leagueOpinion: '',
partyOpinion: '',
schoolOpinion: ''
});
const form = reactive<SjtbzbsjForm>(createDefaultForm());
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await loadAll();
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value
? updateSjtbzbsjForm
: addSjtbzbsjForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,78 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="十佳团支部书记"
file-name="十佳团支部书记导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addSjtbzbsjForm } from '@/api/gxmu/sjtbzbsj-form';
import type { SjtbzbsjForm } from '@/api/gxmu/sjtbzbsj-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '姓名', key: 'name', required: true },
{ header: '性别', key: 'gender' },
{ header: '出生年月', key: 'birthMonth' },
{ header: '民族', key: 'nation' },
{ header: '政治面貌', key: 'politics' },
{ header: '学院班级', key: 'collegeClass' },
{ header: '团支部名称', key: 'branch' },
{ header: '教育评议情况', key: 'eduEval' },
{ header: '近三年曾获校级及以上奖励', key: 'awards' },
{ header: '工作(学习)经历', key: 'experience' },
{ header: '主要事迹', key: 'mainStory' },
{ header: '所在二级团组织意见', key: 'leagueOpinion' },
{ header: '所在二级党组织意见', key: 'partyOpinion' },
{ header: '学校团委意见', key: 'schoolOpinion' }
] as const;
const sampleRow = {
年份: '2025',
姓名: '李四',
性别: '女',
出生年月: '2001-03',
民族: '汉族',
政治面貌: '共青团员',
学院班级: '护理学院2022级1班',
团支部名称: '护理学院2022级1班团支部',
教育评议情况: '优秀',
近三年曾获校级及以上奖励: '校级优秀团支书',
'工作(学习)经历': '2022年起任团支书',
主要事迹: '组织主题团日活动成效显著',
所在二级团组织意见: '',
所在二级党组织意见: '',
学校团委意见: ''
};
const createPayload = (row: Record<string, any>): SjtbzbsjForm => ({
year: String(row['年份'] || '').trim(),
name: String(row['姓名'] || '').trim(),
gender: String(row['性别'] || '').trim(),
birthMonth: String(row['出生年月'] || '').trim(),
nation: String(row['民族'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
collegeClass: String(row['学院班级'] || '').trim(),
branch: String(row['团支部名称'] || '').trim(),
eduEval: String(row['教育评议情况'] || '').trim(),
awards: String(row['近三年曾获校级及以上奖励'] || '').trim(),
experience: String(row['工作(学习)经历'] || '').trim(),
mainStory: String(row['主要事迹'] || '').trim(),
leagueOpinion: String(row['所在二级团组织意见'] || '').trim(),
partyOpinion: String(row['所在二级党组织意见'] || '').trim(),
schoolOpinion: String(row['学校团委意见'] || '').trim()
});
const saveItem = (item: SjtbzbsjForm) => addSjtbzbsjForm(item);
</script>
+379
View File
@@ -0,0 +1,379 @@
<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: 2600 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入姓名/学院班级/团支部"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(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>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="十佳团支部书记"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportSjtbzbsjForm,
userPageSjtbzbsjForm,
removeSjtbzbsjForm
} from '@/api/gxmu/sjtbzbsj-form';
import type {
SjtbzbsjForm,
SjtbzbsjFormParam
} from '@/api/gxmu/sjtbzbsj-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<SjtbzbsjForm>();
const detailCurrent = ref<SjtbzbsjForm | null>(null);
const REVIEW_MODULE = 'gxmu_sjtbzbsj';
const showReviewHistory = ref(false);
const reviewCurrent = ref<SjtbzbsjForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '姓名',
dataIndex: 'name',
key: 'name',
align: 'center',
width: 100
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
align: 'center',
width: 80
},
{
title: '出生年月',
dataIndex: 'birthMonth',
key: 'birthMonth',
align: 'center',
width: 120
},
{
title: '民族',
dataIndex: 'nation',
key: 'nation',
align: 'center',
width: 100
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 120
},
{
title: '学院班级/单位科室',
dataIndex: 'collegeClass',
key: 'collegeClass',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '所在团支部',
dataIndex: 'branch',
key: 'branch',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '上一年度团员教育评议等次',
dataIndex: 'eduEval',
key: 'eduEval',
align: 'center',
width: 220
},
{
title: '近三年曾获校级及以上奖励',
dataIndex: 'awards',
key: 'awards',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '工作(学习)经历',
dataIndex: 'experience',
key: 'experience',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '主要事迹',
dataIndex: 'mainStory',
key: 'mainStory',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '所在二级团组织意见',
dataIndex: 'leagueOpinion',
key: 'leagueOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '所在单位党组织意见',
dataIndex: 'partyOpinion',
key: 'partyOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '校团委意见',
dataIndex: 'schoolOpinion',
key: 'schoolOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 220
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageSjtbzbsjForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as SjtbzbsjFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const buildQueryParams = (): SjtbzbsjFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as SjtbzbsjFormParam;
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(
payload?.url || payload?.downloadUrl || payload?.path || ''
);
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportSjtbzbsjForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: SjtbzbsjForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as SjtbzbsjForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: SjtbzbsjForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: SjtbzbsjForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const remove = (record: SjtbzbsjForm) => {
const hide = message.loading('请求中..', 0);
removeSjtbzbsjForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'SJTBZBSJForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
+408
View File
@@ -0,0 +1,408 @@
<template>
<a-modal
:width="920"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<a-input v-model:value="form.name" placeholder="请输入姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="性别" name="gender">
<a-select
v-model:value="form.gender"
:options="genderOptions"
placeholder="请选择性别"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="民族" name="nation">
<a-select
allow-clear
show-search
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
v-model:value="form.nation"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="政治面貌"
name="politics"
:rules="[{ required: true, message: '请选择政治面貌' }]"
>
<a-select
allow-clear
show-search
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
v-model:value="form.politics"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="学院"
name="college"
:rules="[{ required: true, message: '请选择学院' }]"
>
<a-input
:value="form.college || ''"
readonly
placeholder="选择班级后自动带出"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="班级"
name="className"
:rules="[{ required: true, message: '请选择班级' }]"
>
<class-cascader
v-model:value="form.className"
mode="class"
placeholder="请选择班级"
@change="handleClassChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="手机号码" name="phone">
<a-input v-model:value="form.phone" placeholder="请输入手机号码" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="团内职务" name="leaguePosition">
<a-select
allow-clear
show-search
:options="leaguePositionOptions"
:filter-option="filterSelectOption"
placeholder="请选择团内职务"
v-model:value="form.leaguePosition"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="所属团支部" name="branchOrganizationId">
<a-tree-select
allow-clear
tree-default-expand-all
:tree-data="organizationOptions"
placeholder="请选择所属团支部"
:value="form.branchOrganizationId"
:dropdown-style="{ maxHeight: '360px', overflow: 'auto' }"
@update:value="handleBranchChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="身份证号"
name="idCardNo"
:rules="[{ required: true, message: '请输入身份证号' }]"
>
<a-input
v-model:value="form.idCardNo"
placeholder="请输入身份证号"
@blur="syncBirthDateFromIdCard"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="出生日期" name="birthDate">
<a-input
:value="form.birthDate || ''"
readonly
placeholder="将根据身份证号自动读取"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="团籍是否在本组织" name="archiveInCurrentOrg">
<a-select
v-model:value="form.archiveInCurrentOrg"
placeholder="请选择"
>
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="入团年月" name="joinMonth">
<a-date-picker
v-model:value="form.joinMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择入团年月"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import { toTreeData } from 'ele-admin-pro/es';
import { addTyglForm, updateTyglForm } from '@/api/gxmu/tygl-form';
import type { TyglForm } from '@/api/gxmu/tygl-form/model';
import { listDictData } from '@/api/system/dict-data';
import { listOrganizations } from '@/api/system/organization';
import type { Organization } from '@/api/system/organization/model';
import ClassCascader from '@/views/gxmu/components/class-cascader.vue';
import {
useGxmuFormSelectOptions,
type ClassCascadeSelection
} from '@/views/gxmu/useFormSelectOptions';
dayjs.extend(customParseFormat);
interface TreeOption {
label: string;
value: number;
children?: TreeOption[];
}
const POLITICS_DICT_ID = 172;
const NATION_DICT_ID = 173;
const LEAGUE_POSITION_DICT_ID = 174;
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: TyglForm | null;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const nationOptions = ref<{ label: string; value: string }[]>([]);
const politicsOptions = ref<{ label: string; value: string }[]>([]);
const leaguePositionOptions = ref<{ label: string; value: string }[]>([]);
const { genderOptions, loadClassCascaderOptions, findClassCascadeSelection } =
useGxmuFormSelectOptions();
const organizationOptions = ref<TreeOption[]>([]);
const organizationNameMap = new Map<number, string>();
const optionsLoaded = ref(false);
const createDefaultForm = (): TyglForm => ({
id: undefined,
serialNo: undefined,
name: '',
gender: '',
nation: '',
college: '',
className: '',
politics: '',
phone: '',
leaguePosition: '',
branchOrganizationId: undefined,
branchOrganizationName: '',
idCardNo: '',
birthDate: '',
archiveInCurrentOrg: '',
joinMonth: '',
memberRecords: [],
growthArchives: []
});
const form = reactive<TyglForm>(createDefaultForm());
const filterSelectOption = (input: string, option: any) => {
return String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase());
};
const normalizeDictOptions = async (dictId: number) => {
const result = await listDictData({ dictId });
return result
.filter((item) => !!item.dictDataName)
.map((item) => ({
label: item.dictDataName as string,
value: item.dictDataName as string
}));
};
const buildOrganizationOptions = (list: Organization[]): TreeOption[] => {
return list.map((item) => {
if (item.organizationId) {
organizationNameMap.set(
item.organizationId,
item.organizationName || ''
);
}
return {
label: item.organizationName || '',
value: item.organizationId as number,
children: item.children?.length
? buildOrganizationOptions(item.children)
: undefined
};
});
};
const loadOrganizations = async () => {
const result = await listOrganizations();
const tree = toTreeData({
data: result,
idField: 'organizationId',
parentIdField: 'parentId'
}) as Organization[];
organizationNameMap.clear();
organizationOptions.value = buildOrganizationOptions(tree);
};
const ensureOptionsLoaded = async () => {
if (optionsLoaded.value) {
return;
}
const [politicsList, nationList, leaguePositionList] = await Promise.all([
normalizeDictOptions(POLITICS_DICT_ID),
normalizeDictOptions(NATION_DICT_ID),
normalizeDictOptions(LEAGUE_POSITION_DICT_ID),
loadClassCascaderOptions(),
loadOrganizations()
]);
politicsOptions.value = politicsList;
nationOptions.value = nationList;
leaguePositionOptions.value = leaguePositionList;
optionsLoaded.value = true;
};
const parseBirthDateFromIdCard = (idCardNo?: string) => {
const value = String(idCardNo || '')
.trim()
.toUpperCase();
let rawBirth = '';
if (/^\d{17}[\dX]$/.test(value)) {
rawBirth = value.slice(6, 14);
} else if (/^\d{15}$/.test(value)) {
rawBirth = `19${value.slice(6, 12)}`;
} else {
return '';
}
const date = dayjs(rawBirth, 'YYYYMMDD', true);
return date.isValid() ? date.format('YYYY-MM-DD') : '';
};
const syncBirthDateFromIdCard = () => {
form.birthDate = parseBirthDateFromIdCard(form.idCardNo);
};
const handleBranchChange = (value?: string | number) => {
const organizationId = value ? Number(value) : undefined;
if (!organizationId) {
form.branchOrganizationId = undefined;
form.branchOrganizationName = '';
return;
}
form.branchOrganizationId = organizationId;
form.branchOrganizationName = organizationNameMap.get(organizationId) || '';
};
const handleClassChange = (value?: ClassCascadeSelection) => {
form.college = value?.collegeName || '';
};
watch(
() => form.idCardNo,
() => {
syncBirthDateFromIdCard();
}
);
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await ensureOptionsLoaded();
} catch (e: any) {
message.error(e.message || '表单选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (form.className) {
const classSelection = findClassCascadeSelection(
form.className,
'class'
);
if (classSelection) {
form.college = classSelection.collegeName;
}
}
syncBirthDateFromIdCard();
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
syncBirthDateFromIdCard();
if (!form.birthDate) {
message.error('身份证号格式不正确,无法识别出生日期');
return;
}
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateTyglForm : addTyglForm;
saveOrUpdate({
...form,
birthDate: form.birthDate
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,177 @@
<template>
<a-drawer
placement="right"
width="760"
:visible="visible"
title="成长档案"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<div class="archive-header">
<div class="archive-title">{{ props.data?.name || '未命名团员' }}</div>
<div class="archive-subtitle">
手机:{{ props.data?.phone || '-' }} | 入团年月:{{ props.data?.joinMonth || '-' }}
</div>
</div>
<div class="archive-card">
<div
v-for="(item, index) in growthArchives"
:key="`archive-${index}`"
class="archive-item"
>
<a-form layout="vertical">
<a-form-item label="时间">
<a-date-picker
v-model:value="item.archiveTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择时间"
/>
</a-form-item>
<a-form-item label="内容">
<a-textarea
v-model:value="item.content"
:rows="5"
placeholder="请输入成长档案内容"
/>
</a-form-item>
</a-form>
<a class="remove-link" @click="removeArchive(index)">删除</a>
</div>
<a-button block @click="addArchive">新增成长档案</a-button>
</div>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save">保存</a-button>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { updateTyglGrowthArchives } from '@/api/gxmu/tygl-form';
import type { TyglForm, TyglGrowthArchive } from '@/api/gxmu/tygl-form/model';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: TyglForm | null;
}>();
const loading = ref(false);
const growthArchives = ref<TyglGrowthArchive[]>([]);
const createArchive = (): TyglGrowthArchive => ({
content: '',
archiveTime: ''
});
watch(
() => props.visible,
(visible) => {
if (visible) {
growthArchives.value = (props.data?.growthArchives || []).map((item) => ({
content: item.content || '',
archiveTime: item.archiveTime || ''
}));
} else {
growthArchives.value = [];
}
},
{ immediate: true }
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const addArchive = () => {
growthArchives.value.push(createArchive());
};
const removeArchive = (index: number) => {
growthArchives.value.splice(index, 1);
};
const save = () => {
if (!props.data?.id) {
message.error('请先保存团员基础信息');
return;
}
loading.value = true;
updateTyglGrowthArchives(props.data.id, {
growthArchives: growthArchives.value.filter(
(item) => (item.content || '').trim() || (item.archiveTime || '').trim()
)
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
</script>
<style lang="less" scoped>
.archive-header {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: linear-gradient(135deg, #f7fbff 0%, #edf5ff 100%);
}
.archive-title {
font-size: 18px;
font-weight: 600;
}
.archive-subtitle {
margin-top: 8px;
color: rgba(0, 0, 0, 0.65);
}
.archive-card {
padding: 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fff;
}
.archive-item {
position: relative;
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: #fafafa;
}
.remove-link {
position: absolute;
top: 16px;
right: 16px;
color: #ff4d4f;
}
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
</style>
@@ -0,0 +1,293 @@
<template>
<ele-modal
:width="560"
:footer="null"
title="导入人员管理"
:visible="visible"
@update:visible="updateVisible"
>
<a-spin :spinning="loading">
<a-space direction="vertical" style="width: 100%" :size="16">
<a-alert
type="info"
show-icon
message="模板说明"
description="请先下载模板,按模板表头填写后上传。人员记录、成长档案不在本次导入范围内。"
/>
<a-button block @click="downloadTemplate">下载模板</a-button>
<a-upload-dragger
accept=".xls,.xlsx"
:show-upload-list="false"
:customRequest="doUpload"
style="padding: 24px 0"
>
<p class="ant-upload-drag-icon">
<cloud-upload-outlined />
</p>
<p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
</a-upload-dragger>
</a-space>
</a-spin>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { CloudUploadOutlined } from '@ant-design/icons-vue';
import { utils, writeFile, read } from 'xlsx';
import dayjs from 'dayjs';
import { toTreeData } from 'ele-admin-pro/es';
import { addTyglForm } from '@/api/gxmu/tygl-form';
import type { TyglForm } from '@/api/gxmu/tygl-form/model';
import { listOrganizations } from '@/api/system/organization';
import type { Organization } from '@/api/system/organization/model';
const TEMPLATE_HEADERS = [
'姓名',
'性别',
'民族',
'学院',
'班级',
'政治面貌',
'手机号码',
'团内职务',
'所属团支部',
'身份证号',
'出生日期',
'团籍是否在本组织',
'入团年月'
];
const TEMPLATE_SAMPLE = [
'张三',
'男',
'汉族',
'第一临床医学院',
'2022级临床1班',
'共青团员',
'13800138000',
'团支书',
'第一临床医学院团支部',
'450101200201011234',
'2002-01-01',
'是',
'2020-05'
];
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
defineProps<{
visible: boolean;
}>();
const loading = ref(false);
const readFileBuffer = (file: File) => {
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsArrayBuffer(file);
});
};
const parseBirthDateFromIdCard = (idCardNo?: string) => {
const value = String(idCardNo || '')
.trim()
.toUpperCase();
let rawBirth = '';
if (/^\d{17}[\dX]$/.test(value)) {
rawBirth = value.slice(6, 14);
} else if (/^\d{15}$/.test(value)) {
rawBirth = `19${value.slice(6, 12)}`;
} else {
return '';
}
return dayjs(rawBirth, 'YYYYMMDD', true).isValid()
? dayjs(rawBirth, 'YYYYMMDD').format('YYYY-MM-DD')
: '';
};
const downloadTemplate = () => {
const sheet = utils.aoa_to_sheet([
TEMPLATE_HEADERS,
TEMPLATE_SAMPLE
]);
sheet['!cols'] = TEMPLATE_HEADERS.map((title) => ({
wch: Math.max(title.length + 4, 16)
}));
writeFile(
{
SheetNames: ['人员管理导入模板'],
Sheets: {
人员管理导入模板: sheet
}
},
'人员管理导入模板.xlsx'
);
};
const buildOrganizationMap = async () => {
const list = await listOrganizations();
const tree = toTreeData({
data: list,
idField: 'organizationId',
parentIdField: 'parentId'
}) as Organization[];
const result = new Map<string, { id?: number; name?: string }>();
const walk = (items: Organization[]) => {
items.forEach((item) => {
const key = String(item.organizationName || '').trim();
if (key) {
result.set(key, {
id: item.organizationId,
name: item.organizationName
});
}
if (item.children?.length) {
walk(item.children);
}
});
};
walk(tree);
return result;
};
const toRowObject = (row: Record<string, any>): TyglForm => {
const idCardNo = String(row['身份证号'] || '').trim();
const birthDate =
String(row['出生日期'] || '').trim() || parseBirthDateFromIdCard(idCardNo);
return {
name: String(row['姓名'] || '').trim(),
gender: String(row['性别'] || '').trim(),
nation: String(row['民族'] || '').trim(),
college: String(row['学院'] || '').trim(),
className: String(row['班级'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
phone: String(row['手机号码'] || '').trim(),
leaguePosition: String(row['团内职务'] || '').trim(),
branchOrganizationName: String(row['所属团支部'] || '').trim(),
idCardNo,
birthDate,
archiveInCurrentOrg: String(row['团籍是否在本组织'] || '').trim(),
joinMonth: String(row['入团年月'] || '').trim(),
memberRecords: [],
growthArchives: []
};
};
const validateRows = (rows: TyglForm[]) => {
const errors: string[] = [];
rows.forEach((row, index) => {
const rowNo = index + 2;
if (!row.name) {
errors.push(`第 ${rowNo} 行缺少姓名`);
}
if (!row.college) {
errors.push(`第 ${rowNo} 行缺少学院`);
}
if (!row.className) {
errors.push(`第 ${rowNo} 行缺少班级`);
}
if (!row.politics) {
errors.push(`第 ${rowNo} 行缺少政治面貌`);
}
if (!row.idCardNo) {
errors.push(`第 ${rowNo} 行缺少身份证号`);
}
if (!row.birthDate) {
errors.push(`第 ${rowNo} 行身份证号格式不正确,无法识别出生日期`);
}
// if (
// row.branchOrganizationName &&
// !String(row.branchOrganizationName).trim()
// ) {
// errors.push(`第 ${rowNo} 行所属团支部格式不正确`);
// }
});
return errors;
};
const doUpload = async ({ file }: { file: File }) => {
if (
![
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
].includes(file.type) &&
!file.name.endsWith('.xls') &&
!file.name.endsWith('.xlsx')
) {
message.error('只能选择 Excel 文件');
return false;
}
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
return false;
}
loading.value = true;
try {
const [buffer, organizationMap] = await Promise.all([
readFileBuffer(file),
buildOrganizationMap()
]);
const workbook = read(buffer, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const rows = utils.sheet_to_json<Record<string, any>>(sheet, {
defval: ''
});
if (!rows.length) {
throw new Error('导入文件内容为空');
}
const data = rows
.map(toRowObject)
.filter((item) =>
[
item.name,
item.gender,
item.nation,
item.college,
item.className,
item.idCardNo
].some((value) => !!String(value || '').trim())
);
if (!data.length) {
throw new Error('未识别到有效数据');
}
const errors = validateRows(data);
if (errors.length) {
throw new Error(errors.slice(0, 5).join(';'));
}
for (let index = 0; index < data.length; index += 1) {
const item = data[index];
const branch = organizationMap.get(item.branchOrganizationName || '');
// if (item.branchOrganizationName && !branch?.id) {
// throw new Error(
// `第 ${index + 2} 行所属团支部“${item.branchOrganizationName}”未匹配到系统组织`
// );
// }
if (branch?.id) {
item.branchOrganizationId = branch.id;
item.branchOrganizationName = branch.name;
}
await addTyglForm(item);
}
message.success(`成功导入 ${data.length} 条人员数据`);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e.message || '导入失败');
} finally {
loading.value = false;
}
return false;
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
</script>
@@ -0,0 +1,270 @@
<template>
<a-drawer
placement="right"
width="780"
:visible="visible"
title="团员记录"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<div class="record-header">
<div class="record-title">{{ props.data?.name || '未命名团员' }}</div>
<div class="record-subtitle">
手机:{{ props.data?.phone || '-' }} | 团内职务:{{ props.data?.leaguePosition || '-' }}
</div>
</div>
<a-tabs v-model:activeKey="activeKey" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="团员转进" tab="团员转进">
<div class="section-card">
<div
v-for="(item, index) in transferInRecords"
:key="`transfer-in-${index}`"
class="record-item"
>
<a-form layout="vertical">
<a-form-item label="时间">
<a-date-picker
v-model:value="item.recordTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择时间"
/>
</a-form-item>
<a-form-item label="内容">
<a-textarea
v-model:value="item.content"
:rows="4"
placeholder="请输入团员转进记录内容"
/>
</a-form-item>
</a-form>
<a class="remove-link" @click="removeRecord('团员转进', index)">删除</a>
</div>
<a-button block @click="addRecord('团员转进')">新增转进记录</a-button>
</div>
</a-tab-pane>
<a-tab-pane key="团员转出" tab="团员转出">
<div class="section-card">
<div
v-for="(item, index) in transferOutRecords"
:key="`transfer-out-${index}`"
class="record-item"
>
<a-form layout="vertical">
<a-form-item label="时间">
<a-date-picker
v-model:value="item.recordTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择时间"
/>
</a-form-item>
<a-form-item label="内容">
<a-textarea
v-model:value="item.content"
:rows="4"
placeholder="请输入团员转出记录内容"
/>
</a-form-item>
</a-form>
<a class="remove-link" @click="removeRecord('团员转出', index)">删除</a>
</div>
<a-button block @click="addRecord('团员转出')">新增转出记录</a-button>
</div>
</a-tab-pane>
<a-tab-pane key="离校记录" tab="离校记录">
<div class="section-card">
<div
v-for="(item, index) in leaveSchoolRecords"
:key="`leave-school-${index}`"
class="record-item"
>
<a-form layout="vertical">
<a-form-item label="时间">
<a-date-picker
v-model:value="item.recordTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择时间"
/>
</a-form-item>
<a-form-item label="内容">
<a-textarea
v-model:value="item.content"
:rows="4"
placeholder="请输入离校记录内容"
/>
</a-form-item>
</a-form>
<a class="remove-link" @click="removeRecord('离校记录', index)">删除</a>
</div>
<a-button block @click="addRecord('离校记录')">新增离校记录</a-button>
</div>
</a-tab-pane>
</a-tabs>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save">保存</a-button>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { updateTyglMemberRecords } from '@/api/gxmu/tygl-form';
import type { TyglForm, TyglMemberRecord } from '@/api/gxmu/tygl-form/model';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: TyglForm | null;
}>();
const loading = ref(false);
const activeKey = ref('团员转进');
const memberRecords = ref<TyglMemberRecord[]>([]);
const createRecord = (type: string): TyglMemberRecord => ({
type,
content: '',
recordTime: ''
});
const normalizeRecord = (item?: TyglMemberRecord | null): TyglMemberRecord => ({
type: item?.type || '',
content: item?.content || '',
recordTime: item?.recordTime || ''
});
watch(
() => props.visible,
(visible) => {
if (visible) {
memberRecords.value = (props.data?.memberRecords || []).map((item) =>
normalizeRecord(item)
);
activeKey.value = '团员转进';
} else {
memberRecords.value = [];
}
},
{ immediate: true }
);
const transferInRecords = computed(() =>
memberRecords.value.filter((item) => item.type === '团员转进')
);
const transferOutRecords = computed(() =>
memberRecords.value.filter((item) => item.type === '团员转出')
);
const leaveSchoolRecords = computed(() =>
memberRecords.value.filter((item) => item.type === '离校记录')
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const addRecord = (type: string) => {
memberRecords.value.push(createRecord(type));
};
const removeRecord = (type: string, index: number) => {
const currentIndexes = memberRecords.value
.map((item, currentIndex) => ({ item, currentIndex }))
.filter(({ item }) => item.type === type);
const target = currentIndexes[index];
if (target) {
memberRecords.value.splice(target.currentIndex, 1);
}
};
const save = () => {
if (!props.data?.id) {
message.error('请先保存团员基础信息');
return;
}
loading.value = true;
updateTyglMemberRecords(props.data.id, {
memberRecords: memberRecords.value.filter(
(item) =>
(item.type || '').trim() ||
(item.content || '').trim() ||
(item.recordTime || '').trim()
)
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
</script>
<style lang="less" scoped>
@import '../../tag-tabs.less';
.gxmu-tag-tabs();
.record-header {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: linear-gradient(135deg, #f7fbff 0%, #edf5ff 100%);
}
.record-title {
font-size: 18px;
font-weight: 600;
}
.record-subtitle {
margin-top: 8px;
color: rgba(0, 0, 0, 0.65);
}
.section-card {
padding: 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fff;
}
.record-item {
position: relative;
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: #fafafa;
}
.remove-link {
position: absolute;
top: 16px;
right: 16px;
color: #ff4d4f;
}
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
</style>
+513
View File
@@ -0,0 +1,513 @@
<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: 1900 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space wrap>
<a-button
v-if="canShowAddButton"
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>添加</span>
</a-button>
<a-button
v-if="hasPermission('gxmu:TYGLForm:import')"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="hasPermission('gxmu:TYGLForm:import')"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入姓名/手机号/学院/班级/身份证号"
@search="reload"
@pressEnter="reload"
/>
<a-select
allow-clear
v-model:value="nationFilter"
:options="nationOptions"
placeholder="全部民族"
style="width: 160px"
@change="reload"
/>
<a-select
allow-clear
v-model:value="leaguePositionFilter"
:options="leaguePositionOptions"
placeholder="全部团内职务"
style="width: 180px"
@change="reload"
/>
<a-button @click="resetFilters">重置筛选</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<div class="action-links">
<a @click="openDetail(record)">详情</a>
<a
v-if="hasPermission('gxmu:TYGLForm:edit')"
@click="openEdit(record)"
>
编辑
</a>
<a @click="openMemberRecords(record)">团员记录</a>
<a @click="openGrowthArchives(record)">成长档案</a>
<a-popconfirm
v-if="hasPermission('gxmu:TYGLForm:remove')"
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</div>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal v-model:visible="showEdit" :data="current" @done="reload" />
<import-modal v-model:visible="showImport" @done="reload" />
<member-records-modal
v-model:visible="showMemberRecords"
:data="recordCurrent"
@done="reload"
/>
<growth-archives-modal
v-model:visible="showGrowthArchives"
:data="archiveCurrent"
@done="reload"
/>
<ModuleDataModal
v-model:visible="showDetail"
module="gxmu_tygl_form"
module-name="团员管理"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import MemberRecordsModal from './components/memberRecords.vue';
import GrowthArchivesModal from './components/growthArchives.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportTyglForm,
getTyglForm,
pageTyglForm,
removeTyglForm
} from '@/api/gxmu/tygl-form';
import type { TyglForm, TyglFormParam } from '@/api/gxmu/tygl-form/model';
import { listDictData } from '@/api/system/dict-data';
import { hasAnyRole, hasPermission } from '@/utils/permission';
import { useUserStore } from '@/store/modules/user';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const userStore = useUserStore();
const addRoleCodes = [
'superAdmin',
'SchoolYouthLeagueCommittee',
'PartyCommitteesOfSecondaryColleges',
'YouthLeagueCommitteeOfSecondaryColleges',
'YouthLeagueBranchesOfSecondaryColleges'
];
const studentRoleCodes = ['sutdent', 'student'];
const searchText = ref('');
const nationFilter = ref<string>();
const leaguePositionFilter = ref<string>();
const nationOptions = ref<{ label: string; value: string }[]>([]);
const leaguePositionOptions = ref<{ label: string; value: string }[]>([]);
const showEdit = ref(false);
const showImport = ref(false);
const current = ref<TyglForm>();
const showMemberRecords = ref(false);
const recordCurrent = ref<TyglForm>();
const showGrowthArchives = ref(false);
const archiveCurrent = ref<TyglForm>();
const showDetail = ref(false);
const detailCurrent = ref<TyglForm | null>(null);
const REVIEW_MODULE: string | undefined = undefined;
const showReviewHistory = ref(false);
const reviewCurrent = ref<TyglForm | null>(null);
const exporting = ref(false);
const studentRecordCount = ref(0);
const isStudentOnlyUser = computed(() => {
const roles = (userStore.roles ?? []).filter(
(role): role is string => !!role
);
return roles.length === 1 && studentRoleCodes.includes(roles[0]);
});
const canShowAddButton = computed(() => {
if (hasAnyRole(addRoleCodes)) {
return true;
}
return isStudentOnlyUser.value && studentRecordCount.value === 0;
});
const columns = ref<ColumnItem[]>([
{
title: '序号',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
align: 'center',
width: 120
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
align: 'center',
width: 80
},
{
title: '民族',
dataIndex: 'nation',
key: 'nation',
align: 'center',
width: 100
},
{
title: '学院',
dataIndex: 'college',
key: 'college',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '班级',
dataIndex: 'className',
key: 'className',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 130
},
{
title: '手机号码',
dataIndex: 'phone',
key: 'phone',
align: 'center',
width: 140
},
{
title: '团内职务',
dataIndex: 'leaguePosition',
key: 'leaguePosition',
align: 'center',
width: 160,
ellipsis: true
},
{
title: '所属团支部',
dataIndex: 'branchOrganizationName',
key: 'branchOrganizationName',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '出生日期',
dataIndex: 'birthDate',
key: 'birthDate',
align: 'center',
width: 120
},
{
title: '团籍是否在本组织',
dataIndex: 'archiveInCurrentOrg',
key: 'archiveInCurrentOrg',
align: 'center',
width: 170
},
{
title: '入团年月',
dataIndex: 'joinMonth',
key: 'joinMonth',
align: 'center',
width: 120
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 260
}
]);
const buildQueryParams = (): TyglFormParam => {
return {
keywords: searchText.value.trim() || undefined,
nation: nationFilter.value,
leaguePosition: leaguePositionFilter.value
} as TyglFormParam;
};
const datasource: DatasourceFunction = ({ page, limit }) => {
return pageTyglForm({
page,
limit,
...buildQueryParams()
} as TyglFormParam)
.then((result) => {
if (isStudentOnlyUser.value) {
studentRecordCount.value = Number(result?.count ?? 0);
}
return result;
})
.catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
loadStudentRecordCount();
tableRef.value?.reload();
};
const loadStudentRecordCount = async () => {
if (!isStudentOnlyUser.value) {
studentRecordCount.value = 0;
return;
}
try {
const result = await pageTyglForm({
page: 1,
limit: 1
} as TyglFormParam);
studentRecordCount.value = Number(result?.count ?? 0);
} catch (e: any) {
message.error(e.message || '获取团员数据失败');
}
};
const loadDictOptions = async (dictId: number) => {
const result = await listDictData({ dictId });
return result
.filter((item) => !!item.dictDataName)
.map((item) => ({
label: item.dictDataName as string,
value: item.dictDataName as string
}));
};
const loadFilterOptions = async () => {
try {
const [nationList, leaguePositionList] = await Promise.all([
loadDictOptions(173),
loadDictOptions(174)
]);
nationOptions.value = nationList;
leaguePositionOptions.value = leaguePositionList;
} catch (e: any) {
message.error(e.message || '筛选字典加载失败');
}
};
const resetFilters = () => {
searchText.value = '';
nationFilter.value = undefined;
leaguePositionFilter.value = undefined;
reload();
};
const resolveExportUrl = (payload: any) => {
return String(
payload?.data?.url ||
payload?.data?.downloadUrl ||
payload?.data?.path ||
payload?.url ||
payload?.downloadUrl ||
payload?.path ||
''
);
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportTyglForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openImport = () => {
showImport.value = true;
};
const openEdit = (record?: TyglForm) => {
if (!record && isStudentOnlyUser.value && studentRecordCount.value > 0) {
message.warning('当前学生用户已有团员数据,不能重复新增');
return;
}
current.value = record
? (JSON.parse(JSON.stringify(record)) as TyglForm)
: undefined;
showEdit.value = true;
};
const loadTyglDetail = (
record: TyglForm,
callback: (data: TyglForm) => void
) => {
if (!record.id) {
message.error('缺少团员记录ID');
return;
}
const hide = message.loading('加载中..', 0);
getTyglForm(record.id)
.then((data) => {
hide();
callback(JSON.parse(JSON.stringify(data)) as TyglForm);
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const openMemberRecords = (record: TyglForm) => {
loadTyglDetail(record, (data) => {
recordCurrent.value = data;
showMemberRecords.value = true;
});
};
const openGrowthArchives = (record: TyglForm) => {
loadTyglDetail(record, (data) => {
archiveCurrent.value = data;
showGrowthArchives.value = true;
});
};
const openDetail = (record: TyglForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const openReviewHistory = (record: TyglForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const remove = (record: TyglForm) => {
const hide = message.loading('请求中..', 0);
removeTyglForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
onMounted(() => {
loadStudentRecordCount();
loadFilterOptions();
});
</script>
<script lang="ts">
export default {
name: 'TYGLForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
.action-links {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px 12px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,930 @@
<template>
<a-drawer
placement="right"
width="80%"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<a-form ref="formRef" :model="form">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 240px"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
<a-tabs v-model:activeKey="activeTab" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="base" tab="项目申报表">
<div class="sheet-wrap">
<div class="sheet-header">
<div class="sheet-tag">附件 1</div>
<div class="sheet-main-title"
>{{ title || '' }}参赛项目申报表</div
>
</div>
<table class="sheet-table">
<colgroup>
<col style="width: 18%" />
<col style="width: 32%" />
<col style="width: 18%" />
<col style="width: 32%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">所在省<br />(区、市)</td>
<td class="sheet-value">
<a-input
v-model:value="form.provinceCity"
placeholder="请输入所在省/市"
/>
</td>
<td class="sheet-label">学校名称<br />(全称)</td>
<td class="sheet-value">
<a-input
v-model:value="form.schoolName"
placeholder="请输入学校名称"
/>
</td>
</tr>
<tr>
<td class="sheet-label">项目名称</td>
<td class="sheet-value left" colspan="3">
<a-input
v-model:value="form.projectName"
placeholder="请输入项目名称"
/>
</td>
</tr>
<tr>
<td class="sheet-label">项目类型</td>
<td class="sheet-value left" colspan="3">
<a-radio-group
v-model:value="form.projectType"
class="inline-radio"
>
<a-radio
v-for="item in formProjectTypeOptions"
:key="`base-type-${item.value}`"
:value="item.value"
>
{{ item.label }}
</a-radio>
</a-radio-group>
</td>
</tr>
<tr>
<td class="sheet-label">
项目分组
<div class="sheet-note"
>(仅可选择1个组别,须在此栏中删除其他组别内容)</div
>
</td>
<td class="sheet-value left" colspan="3">
<a-radio-group
v-model:value="form.projectGroup"
class="group-radio"
>
<a-radio
v-for="item in formProjectGroupOptions"
:key="`base-group-${item.value}`"
:value="item.value"
>
{{ item.label }}
</a-radio>
</a-radio-group>
</td>
</tr>
</tbody>
</table>
<table class="sheet-table">
<colgroup>
<col style="width: 14%" />
<col style="width: 12%" />
<col style="width: 10%" />
<col style="width: 16%" />
<col style="width: 18%" />
<col style="width: 12%" />
<col style="width: 18%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label" :rowspan="teamMembersCount + 1"
>团队成员<br />(最多15人)</td
>
<td class="sheet-label">姓名</td>
<td class="sheet-label">性别</td>
<td class="sheet-label">学院</td>
<td class="sheet-label">年级、专业</td>
<td class="sheet-label">手机</td>
<td class="sheet-label"
>备注(负责人及各成员学历-本/硕/博)</td
>
</tr>
<tr
v-for="(item, index) in form.teamMembers"
:key="`member-${index}`"
>
<td class="sheet-value"
><a-input v-model:value="item.name"
/></td>
<td class="sheet-value">
<a-select style="width: 120px"
v-model:value="item.gender"
:options="genderOptions"
placeholder="请选择"
/>
</td>
<td class="sheet-value">
<a-select style="width: 120px"
allow-clear
show-search
v-model:value="item.college"
:options="collegeOptions"
:filter-option="filterSelectOption"
placeholder="请选择"
/>
</td>
<td class="sheet-value"
><a-input v-model:value="item.gradeMajor"
/></td>
<td class="sheet-value"
><a-input v-model:value="item.phone"
/></td>
<td class="sheet-value"
><a-input v-model:value="item.remark"
/></td>
</tr>
</tbody>
</table>
<table class="sheet-table">
<colgroup>
<col style="width: 14%" />
<col style="width: 12%" />
<col style="width: 10%" />
<col style="width: 16%" />
<col style="width: 18%" />
<col style="width: 16%" />
<col style="width: 14%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label" :rowspan="advisorCount + 1"
>指导教师<br />(最多5人)</td
>
<td class="sheet-label">姓名</td>
<td class="sheet-label">性别</td>
<td class="sheet-label">学院</td>
<td class="sheet-label">职称</td>
<td class="sheet-label">职务</td>
<td class="sheet-label">手机</td>
</tr>
<tr
v-for="(item, index) in form.advisors"
:key="`advisor-${index}`"
>
<td class="sheet-value"
><a-input v-model:value="item.name"
/></td>
<td class="sheet-value">
<a-select style="width: 120px"
v-model:value="item.gender"
:options="genderOptions"
placeholder="请选择"
/>
</td>
<td class="sheet-value">
<a-select style="width: 120px"
allow-clear
show-search
v-model:value="item.college"
:options="collegeOptions"
:filter-option="filterSelectOption"
placeholder="请选择"
/>
</td>
<td class="sheet-value"
><a-input v-model:value="item.title"
/></td>
<td class="sheet-value"
><a-input v-model:value="item.duty"
/></td>
<td class="sheet-value"
><a-input v-model:value="item.phone"
/></td>
</tr>
</tbody>
</table>
<table class="sheet-table">
<colgroup>
<col style="width: 18%" />
<col style="width: 82%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">项目简介<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.projectBrief"
:rows="4"
placeholder="请输入项目简介"
/>
</td>
</tr>
</tbody>
</table>
</div>
</a-tab-pane>
<a-tab-pane key="detail" tab="项目说明">
<div class="sheet-wrap">
<table class="sheet-table">
<colgroup>
<col style="width: 18%" />
<col style="width: 82%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">社会价值<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.socialValue"
:rows="4"
placeholder="请输入社会价值"
/>
</td>
</tr>
<tr>
<td class="sheet-label">实践过程<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.practiceProcess"
:rows="4"
placeholder="请输入实践过程"
/>
</td>
</tr>
<tr>
<td class="sheet-label">创新意义<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.innovationMeaning"
:rows="4"
placeholder="请输入创新意义"
/>
</td>
</tr>
<tr>
<td class="sheet-label">发展前景<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.developmentProspect"
:rows="4"
placeholder="请输入发展前景"
/>
</td>
</tr>
<tr>
<td class="sheet-label">团队协作<br />(500字以内)</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.teamCooperation"
:rows="4"
placeholder="请输入团队协作"
/>
</td>
</tr>
<tr>
<td class="sheet-label">项目介绍材料</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.projectMaterials"
:rows="3"
placeholder="请输入项目介绍材料"
/>
</td>
</tr>
<tr>
<td class="sheet-label">其他相关证明材料</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.otherProofs"
:rows="3"
placeholder="选报,所有其他相关证明材料需扫描在同一 PDF 文档中"
/>
</td>
</tr>
</tbody>
</table>
</div>
</a-tab-pane>
<a-tab-pane key="public" tab="公开展示信息表">
<div class="sheet-wrap">
<div class="public-title">
<div class="public-tag">附件 2</div>
<div class="public-main-title"
>{{ title || '' }}参赛项目公开展示信息表</div
>
</div>
<table class="sheet-table">
<colgroup>
<col style="width: 18%" />
<col style="width: 82%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">项目名称</td>
<td class="sheet-value left">
<a-input
v-model:value="form.projectName"
placeholder="请输入项目名称"
/>
</td>
</tr>
<tr>
<td class="sheet-label">参赛学校</td>
<td class="sheet-value left">
<a-input
v-model:value="form.schoolName"
placeholder="请输入参赛学校"
/>
</td>
</tr>
<tr>
<td class="sheet-label">项目类型</td>
<td class="sheet-value left">
<a-radio-group
v-model:value="form.publicProjectType"
class="inline-radio"
>
<a-radio
v-for="item in publicProjectTypeOptions"
:key="`public-type-${item.value}`"
:value="item.value"
>
{{ item.label }}
</a-radio>
</a-radio-group>
</td>
</tr>
<tr>
<td class="sheet-label">项目分组</td>
<td class="sheet-value left">
<a-radio-group
v-model:value="form.publicProjectGroup"
class="group-radio"
>
<a-radio
v-for="item in publicProjectGroupOptions"
:key="`public-group-${item.value}`"
:value="item.value"
>
{{ item.label }}
</a-radio>
</a-radio-group>
</td>
</tr>
<tr>
<td class="sheet-label">我们的项目</td>
<td class="sheet-value left">
<a-textarea
v-model:value="form.projectSummary"
:rows="3"
placeholder="150字以内,阐述项目的实践来源、社会背景、基本情况等"
/>
</td>
</tr>
<tr>
<td class="sheet-label">我们的团队</td>
<td class="sheet-value left">
<div class="media-upload-block">
<div v-if="form.teamIntro" class="media-preview-wrap">
<img
class="media-image"
:src="form.teamIntro"
alt="团队图片"
/>
</div>
<div v-else class="media-empty">请上传团队图片</div>
<div class="media-actions">
<a-upload
:show-upload-list="false"
accept="image/*"
:customRequest="uploadTeamImage"
>
<a-button type="primary">上传图片</a-button>
</a-upload>
<a-button v-if="form.teamIntro" @click="form.teamIntro = ''">
移除
</a-button>
</div>
</div>
</td>
</tr>
<tr>
<td class="sheet-label">我们的口号</td>
<td class="sheet-value left">
<a-input
v-model:value="form.teamSlogan"
placeholder="20字以内"
/>
</td>
</tr>
<tr>
<td class="sheet-label">我们的实践日志(选填)</td>
<td class="sheet-value left">
<div class="media-upload-block">
<div v-if="form.practiceLog" class="media-preview-wrap media-preview-wrap--video">
<video class="media-video" :src="form.practiceLog" controls></video>
</div>
<div v-else class="media-empty">请上传实践日志视频</div>
<div class="media-actions">
<a-upload
:show-upload-list="false"
accept="video/*"
:customRequest="uploadPracticeVideo"
>
<a-button type="primary">上传视频</a-button>
</a-upload>
<a-button v-if="form.practiceLog" @click="form.practiceLog = ''">
移除
</a-button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</a-tab-pane>
</a-tabs>
</a-form>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save"
>保存</a-button
>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addTzbcyForm, updateTzbcyForm } from '@/api/gxmu/tzbcy-form';
import { uploadFile } from '@/api/system/file';
import type {
TzbcyAdvisor,
TzbcyForm,
TzbcyTeamMember
} from '@/api/gxmu/tzbcy-form/model';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
import {
TZBCY_PROJECT_GROUP_OPTIONS,
TZBCY_PROJECT_TYPE_OPTIONS
} from '../options';
interface ChoiceOption {
label: string;
value: string;
}
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: TzbcyForm | null;
title?: string;
fixedYear?: string | number;
declareFormProjectTypes?: string[];
declareFormProjectGroups?: string[];
declarePublicProjectTypes?: string[];
declarePublicProjectGroups?: string[];
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const activeTab = ref('base');
const { genderOptions, collegeOptions, loadCollegeOptions } =
useGxmuFormSelectOptions();
const createTeamMember = (): TzbcyTeamMember => ({
name: '',
gender: '',
college: '',
gradeMajor: '',
phone: '',
remark: ''
});
const createAdvisor = (): TzbcyAdvisor => ({
name: '',
gender: '',
college: '',
title: '',
duty: '',
phone: ''
});
const createDefaultForm = (): TzbcyForm => ({
id: undefined,
year: '',
provinceCity: '',
schoolName: '广西医科大学',
projectName: '',
projectType: '',
projectGroup: '',
publicProjectType: '',
publicProjectGroup: '',
leader: '',
phone: '',
teamMembers: Array.from({ length: 15 }).map(() => createTeamMember()),
advisors: Array.from({ length: 5 }).map(() => createAdvisor()),
projectBrief: '',
socialValue: '',
practiceProcess: '',
innovationMeaning: '',
developmentProspect: '',
teamCooperation: '',
projectMaterials: '',
otherProofs: '',
projectSummary: '',
teamIntro: '',
teamSlogan: '',
practiceLog: ''
});
const fillList = <T extends Record<string, unknown>>(
list: T[] | undefined,
count: number,
createItem: () => T
) => {
const source = list?.map((item) => ({ ...createItem(), ...item })) ?? [];
return Array.from({ length: count }).map(
(_, index) => source[index] ?? createItem()
);
};
const normalizeForm = (record?: TzbcyForm | null): TzbcyForm => {
const defaults = createDefaultForm();
if (!record) {
return defaults;
}
const normalizedProjectType = record.projectType || '';
const normalizedProjectGroup = record.projectGroup || '';
return {
...defaults,
...record,
projectType: normalizedProjectType,
projectGroup: normalizedProjectGroup,
publicProjectType: record.publicProjectType || normalizedProjectType,
publicProjectGroup: record.publicProjectGroup || normalizedProjectGroup,
teamMembers: fillList(record.teamMembers, 15, createTeamMember),
advisors: fillList(record.advisors, 5, createAdvisor)
};
};
const form = reactive<TzbcyForm>(createDefaultForm());
const teamMembersCount = computed(() => form.teamMembers?.length || 0);
const advisorCount = computed(() => form.advisors?.length || 0);
const buildChoiceOptions = (
customValues: string[] | undefined,
fallback: readonly ChoiceOption[],
currentValue?: string
): ChoiceOption[] => {
const values = (customValues?.length
? customValues
: fallback.map((item) => item.value)
)
.map((item) => item.trim())
.filter((item, index, arr) => !!item && arr.indexOf(item) === index);
if (currentValue?.trim() && !values.includes(currentValue.trim())) {
values.push(currentValue.trim());
}
return values.map((value) => {
const matched = fallback.find((item) => item.value === value);
return {
label: matched?.label ?? value,
value
};
});
};
const formProjectTypeOptions = computed(() =>
buildChoiceOptions(
props.declareFormProjectTypes,
TZBCY_PROJECT_TYPE_OPTIONS,
form.projectType
)
);
const formProjectGroupOptions = computed(() =>
buildChoiceOptions(
props.declareFormProjectGroups,
TZBCY_PROJECT_GROUP_OPTIONS,
form.projectGroup
)
);
const publicProjectTypeOptions = computed(() =>
buildChoiceOptions(
props.declarePublicProjectTypes,
TZBCY_PROJECT_TYPE_OPTIONS,
form.publicProjectType
)
);
const publicProjectGroupOptions = computed(() =>
buildChoiceOptions(
props.declarePublicProjectGroups,
TZBCY_PROJECT_GROUP_OPTIONS,
form.publicProjectGroup
)
);
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await loadCollegeOptions();
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, normalizeForm(props.data));
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
activeTab.value = 'base';
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const syncLeaderInfo = () => {
const firstMember = form.teamMembers?.find(
(item) => item.name?.trim() || item.phone?.trim()
);
form.leader = firstMember?.name?.trim() || '';
form.phone = firstMember?.phone?.trim() || '';
};
const save = () => {
syncLeaderInfo();
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateTzbcyForm : addTzbcyForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
const uploadTeamImage = (item) => {
const file = item.file as File;
if (!file.type.startsWith('image/')) {
message.error('请上传图片文件');
return;
}
uploadFile(file)
.then((data) => {
form.teamIntro = String(data.url || '');
message.success('图片上传成功');
})
.catch((e) => {
message.error(e.message);
});
};
const uploadPracticeVideo = (item) => {
const file = item.file as File;
if (!file.type.startsWith('video/')) {
message.error('请上传视频文件');
return;
}
uploadFile(file)
.then((data) => {
form.practiceLog = String(data.url || '');
message.success('视频上传成功');
})
.catch((e) => {
message.error(e.message);
});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
@import '../../tag-tabs.less';
.gxmu-field-theme();
.gxmu-tag-tabs();
.sheet-wrap {
border: 1px solid #d9d9d9;
padding: 12px;
background: #fff;
}
.sheet-header {
margin-bottom: 16px;
}
.sheet-tag {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.sheet-main-title {
font-size: 28px;
font-weight: 700;
text-align: center;
line-height: 1.4;
}
.sheet-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-bottom: 12px;
}
.sheet-table td {
border: 1px solid #3b3b3b;
padding: 8px 6px;
font-size: 13px;
text-align: center;
vertical-align: middle;
}
.sheet-label {
font-weight: 600;
text-align: center;
}
.sheet-note {
font-weight: 400;
font-size: 12px;
color: #444;
margin-top: 6px;
line-height: 1.8;
}
.sheet-value {
text-align: center;
}
.sheet-value.left {
text-align: left;
}
.sheet-table .sheet-value :deep(.ant-input),
.sheet-table .sheet-value :deep(.ant-select-selector),
.sheet-table .sheet-value :deep(.ant-radio-group),
.sheet-table .sheet-value :deep(.ant-input-textarea),
.sheet-table .sheet-value :deep(.ant-input-affix-wrapper) {
border: 0;
box-shadow: none;
padding: 0;
background: transparent;
}
.sheet-table .sheet-value :deep(.ant-input) {
text-align: center;
}
.sheet-table .sheet-value.left :deep(.ant-input),
.sheet-table .sheet-value.left :deep(.ant-input-textarea) {
text-align: left;
}
.sheet-table .sheet-value :deep(textarea.ant-input) {
resize: none;
}
.inline-radio :deep(.ant-radio) {
margin-right: 12px;
}
.group-radio {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.media-upload-block {
display: flex;
flex-direction: column;
gap: 12px;
}
.media-preview-wrap {
width: 100%;
border-radius: 8px;
overflow: hidden;
background: #f5f7fa;
}
.media-preview-wrap--video {
background: #111827;
}
.media-image {
width: 100%;
max-height: 220px;
object-fit: cover;
display: block;
}
.media-video {
width: 100%;
max-height: 260px;
display: block;
}
.media-empty {
padding: 24px 12px;
border-radius: 8px;
background: #f5f7fa;
color: #909399;
text-align: center;
}
.media-actions {
display: flex;
gap: 12px;
align-items: center;
}
.public-title {
margin-bottom: 16px;
}
.public-tag {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.public-main-title {
font-size: 28px;
font-weight: 700;
text-align: center;
line-height: 1.4;
}
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
@media (max-width: 768px) {
.sheet-main-title,
.public-main-title {
font-size: 16px;
}
}
</style>
@@ -0,0 +1,144 @@
<template>
<a-modal
:visible="visible"
:title="form.id ? '编辑材料' : '新增材料'"
:confirm-loading="loading"
:mask-closable="false"
@ok="save"
@cancel="close"
@update:visible="updateVisible"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 19 }"
>
<a-form-item label="材料类型" name="materialType">
<a-input
v-model:value="form.materialType"
allow-clear
placeholder="请输入材料类型"
/>
</a-form-item>
<a-form-item label="上传材料" name="fileId">
<a-upload
:show-upload-list="false"
:customRequest="handleUpload"
>
<a-button :loading="uploading">
<template #icon>
<UploadOutlined />
</template>
上传文件
</a-button>
</a-upload>
<div v-if="form.fileName" class="file-name">
{{ form.fileName }}
</div>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import type { FormInstance } from 'ant-design-vue';
import { uploadFile } from '@/api/system/file';
import { addTzbcyMaterial, updateTzbcyMaterial } from '@/api/gxmu/tzbcy-material';
import type { TzbcyMaterial } from '@/api/gxmu/tzbcy-material/model';
const props = defineProps<{
visible: boolean;
formId?: number;
data?: TzbcyMaterial | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'done'): void;
}>();
const formRef = ref<FormInstance>();
const loading = ref(false);
const uploading = ref(false);
const form = reactive<TzbcyMaterial>({});
const rules = {
materialType: [{ required: true, message: '请输入材料类型', trigger: 'blur' }],
fileId: [{ required: true, message: '请上传材料文件', trigger: 'change' }]
};
watch(
() => props.visible,
(visible) => {
if (!visible) {
return;
}
Object.assign(form, {
id: props.data?.id,
formId: props.data?.formId || props.formId,
materialType: props.data?.materialType,
fileId: props.data?.fileId,
fileName: props.data?.fileName
});
},
{ immediate: true }
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const close = () => {
emit('update:visible', false);
};
const handleUpload = async (options: any) => {
const rawFile = options.file as File;
uploading.value = true;
const hide = message.loading('上传中..', 0);
try {
const result = await uploadFile(rawFile);
form.fileId = result.id;
form.fileName = result.name || rawFile.name;
options.onSuccess?.(result);
formRef.value?.validateFields(['fileId']);
message.success('上传成功');
} catch (e: any) {
options.onError?.(e);
message.error(e.message || '上传失败');
} finally {
hide();
uploading.value = false;
}
};
const save = async () => {
await formRef.value?.validate();
loading.value = true;
try {
const submitData = { ...form, formId: form.formId || props.formId };
const msg = submitData.id
? await updateTzbcyMaterial(submitData)
: await addTzbcyMaterial(submitData);
message.success(msg);
emit('done');
close();
} catch (e: any) {
message.error(e.message || '提交失败');
} finally {
loading.value = false;
}
};
</script>
<style lang="less" scoped>
.file-name {
margin-top: 8px;
color: #4b5563;
word-break: break-all;
}
</style>
@@ -0,0 +1,326 @@
<template>
<a-modal
:visible="visible"
title="材料上传"
:footer="null"
:destroy-on-close="true"
:width="1000"
@cancel="close"
@update:visible="updateVisible"
>
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1100 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-select
v-model:value="materialStatus"
allow-clear
placeholder="请选择审核状态"
style="width: 160px"
@change="reload"
>
<a-select-option
v-for="item in statusOptions"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
<a-button type="primary" @click="openEdit()">
<template #icon>
<plus-outlined />
</template>
新增材料
</a-button>
<a-button :loading="exporting" @click="exportMaterials">
整体导出
</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="download(record)">下载</a>
<template v-if="record.status !== 1">
<a-divider type="vertical" />
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a @click="audit(record, 1)">通过</a>
<a-divider type="vertical" />
<a @click="openReject(record)">驳回</a>
<a-divider type="vertical" />
<a-popconfirm title="确定要删除此材料吗?" @confirm="remove(record)">
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</template>
</a-space>
</template>
</template>
</ele-pro-table>
<material-edit
v-model:visible="showEdit"
:form-id="record?.id"
:data="current"
@done="reload"
/>
<a-modal
v-model:visible="showReject"
title="驳回材料"
:confirm-loading="rejecting"
@ok="submitReject"
>
<a-textarea
v-model:value="rejectReason"
:rows="4"
placeholder="请输入驳回原因"
/>
</a-modal>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } 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,
DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types';
import MaterialEdit from './materialEdit.vue';
import {
auditTzbcyMaterial,
exportTzbcyMaterial,
pageTzbcyMaterial,
removeTzbcyMaterial
} from '@/api/gxmu/tzbcy-material';
import type { TzbcyMaterial } from '@/api/gxmu/tzbcy-material/model';
import type { TzbcyForm } from '@/api/gxmu/tzbcy-form/model';
const props = defineProps<{
visible: boolean;
record?: TzbcyForm | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const showEdit = ref(false);
const showReject = ref(false);
const rejecting = ref(false);
const exporting = ref(false);
const current = ref<TzbcyMaterial | null>(null);
const rejectCurrent = ref<TzbcyMaterial | null>(null);
const rejectReason = ref('');
const materialStatus = ref<number>();
const statusOptions = [
{ label: '待审核', value: 0 },
{ label: '通过', value: 1 },
{ label: '驳回', value: 2 }
];
const columns = ref<ColumnItem[]>([
{
title: '材料类型',
dataIndex: 'materialType',
key: 'materialType',
align: 'center',
width: 180
},
{
title: '文件名称',
dataIndex: 'fileName',
key: 'fileName',
align: 'center',
width: 260,
ellipsis: true
},
{
title: '审核状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 110
},
{
title: '驳回原因',
dataIndex: 'rejectReason',
key: 'rejectReason',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '上传时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 170
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 260
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
if (!props.record?.id) {
return Promise.resolve({ list: [], count: 0 });
}
return pageTzbcyMaterial({
page,
limit,
formId: props.record.id,
status: materialStatus.value
}).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
setTimeout(() => reload());
} else {
materialStatus.value = undefined;
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const close = () => {
emit('update:visible', false);
};
const reload = () => {
tableRef.value?.reload();
};
const getStatusText = (status?: number) => {
if (status === 1) {
return '通过';
}
if (status === 2) {
return '驳回';
}
return '待审核';
};
const getStatusColor = (status?: number) => {
if (status === 1) {
return 'success';
}
if (status === 2) {
return 'error';
}
return 'processing';
};
const openEdit = (record?: TzbcyMaterial) => {
current.value = record ? { ...record } : null;
showEdit.value = true;
};
const download = (record: TzbcyMaterial) => {
const url = record.downloadUrl || record.fileUrl;
if (!url) {
message.warning('暂无下载地址');
return;
}
window.open(url, '_blank');
};
const audit = async (record: TzbcyMaterial, status: number, reason?: string) => {
try {
const msg = await auditTzbcyMaterial({
id: record.id,
status,
rejectReason: reason
});
message.success(msg);
reload();
} catch (e: any) {
message.error(e.message || '审核失败');
}
};
const openReject = (record: TzbcyMaterial) => {
rejectCurrent.value = record;
rejectReason.value = record.rejectReason || '';
showReject.value = true;
};
const submitReject = async () => {
if (!rejectReason.value.trim()) {
message.warning('请输入驳回原因');
return;
}
if (!rejectCurrent.value) {
return;
}
rejecting.value = true;
try {
await audit(rejectCurrent.value, 2, rejectReason.value.trim());
showReject.value = false;
} finally {
rejecting.value = false;
}
};
const remove = async (record: TzbcyMaterial) => {
try {
const msg = await removeTzbcyMaterial(record.id);
message.success(msg);
reload();
} catch (e: any) {
message.error(e.message || '删除失败');
}
};
const exportMaterials = async () => {
if (!props.record?.id) {
return;
}
exporting.value = true;
try {
const result = await exportTzbcyMaterial({
formId: props.record.id,
status: materialStatus.value
});
const url = result.url || result.downloadUrl || result.path;
if (!url) {
message.warning('未获取到导出文件地址');
return;
}
window.open(url, '_blank');
} catch (e: any) {
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
</script>
+292
View File
@@ -0,0 +1,292 @@
<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: 1400 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入项目名称/负责人/学校"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openMaterials(record)">材料上传</a>
<a-divider type="vertical" />
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(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>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:title="title"
:fixed-year="props.fixedYear"
:declare-form-project-types="props.declareFormProjectTypes"
:declare-form-project-groups="props.declareFormProjectGroups"
:declare-public-project-types="props.declarePublicProjectTypes"
:declare-public-project-groups="props.declarePublicProjectGroups"
@done="reload"
/>
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="挑战杯"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
<materials-modal
v-model:visible="showMaterials"
:record="materialCurrent"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { UploadOutlined, PlusOutlined } from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import MaterialsModal from './components/materials.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import { userPageTzbcyForm, removeTzbcyForm } from '@/api/gxmu/tzbcy-form';
import type { TzbcyForm, TzbcyFormParam } from '@/api/gxmu/tzbcy-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
title?: string;
declareFormProjectTypes?: string[];
declareFormProjectGroups?: string[];
declarePublicProjectTypes?: string[];
declarePublicProjectGroups?: string[];
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showDetail = ref(false);
const current = ref<TzbcyForm>();
const detailCurrent = ref<TzbcyForm | null>(null);
const REVIEW_MODULE = 'gxmu_tzbcy_form';
const showReviewHistory = ref(false);
const reviewCurrent = ref<TzbcyForm | null>(null);
const showMaterials = ref(false);
const materialCurrent = ref<TzbcyForm | null>(null);
const getLeaderInfo = (record: TzbcyForm) => {
const firstMember = record.teamMembers?.find(
(item) => item.name?.trim() || item.phone?.trim()
);
return {
leader: record.leader || firstMember?.name || '',
phone: record.phone || firstMember?.phone || ''
};
};
const columns = ref<ColumnItem[]>([
{
title: '项目名称',
dataIndex: 'projectName',
key: 'projectName',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '项目类型',
dataIndex: 'projectType',
key: 'projectType',
align: 'center',
width: 140
},
{
title: '项目分组',
dataIndex: 'projectGroup',
key: 'projectGroup',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '学校名称',
dataIndex: 'schoolName',
key: 'schoolName',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '负责人',
dataIndex: 'leader',
key: 'leader',
align: 'center',
width: 120,
customRender: ({ record }) => getLeaderInfo(record as TzbcyForm).leader
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
align: 'center',
width: 140,
customRender: ({ record }) => getLeaderInfo(record as TzbcyForm).phone
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 280
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageTzbcyForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as TzbcyFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
message.info('已触发导入操作(请接入实际导入逻辑)');
};
const openEdit = (record?: TzbcyForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as TzbcyForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: TzbcyForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: TzbcyForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const openMaterials = (record: TzbcyForm) => {
materialCurrent.value = record;
showMaterials.value = true;
};
const remove = (record: TzbcyForm) => {
const hide = message.loading('请求中..', 0);
removeTzbcyForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'TZBCYForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
+13
View File
@@ -0,0 +1,13 @@
export const TZBCY_PROJECT_TYPE_OPTIONS = [
{ label: 'I. 普通高校', value: '普通高校' },
{ label: 'II. 职业院校', value: '职业院校' },
{ label: 'III. 东盟留学生专项', value: '东盟留学生专项' }
] as const;
export const TZBCY_PROJECT_GROUP_OPTIONS = [
{ label: 'A. 科技创新和未来产业', value: 'A. 科技创新和未来产业' },
{ label: 'B. 乡村振兴和农业农村现代化', value: 'B. 乡村振兴和农业农村现代化' },
{ label: 'C. 社会治理和公共服务', value: 'C. 社会治理和公共服务' },
{ label: 'D. 生态环保和可持续发展', value: 'D. 生态环保和可持续发展' },
{ label: 'E. 文化创意和区域合作', value: 'E. 文化创意和区域合作' }
] as const;
@@ -0,0 +1,343 @@
<template>
<a-modal
:width="1000"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 100%"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="二级团组织全称"
name="orgName"
:rules="[{ required: true, message: '请输入二级团组织全称' }]"
>
<a-input
v-model:value="form.orgName"
placeholder="请输入二级团组织全称"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="负责人" name="leader">
<a-input v-model:value="form.leader" placeholder="请输入负责人" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="form.phone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="现有团员总数" name="memberTotal">
<a-input
v-model:value="form.memberTotal"
placeholder="请输入现有团员总数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度发展团员人数" name="memberDeveloped2024">
<a-input
v-model:value="form.memberDeveloped2024"
placeholder="请输入上一年度发展团员人数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="是否已登录智慧团建系统" name="smartSystemLogin">
<a-select
v-model:value="form.smartSystemLogin"
placeholder="请选择"
>
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="团委委员人数" name="committeeCount">
<a-input
v-model:value="form.committeeCount"
placeholder="请输入团委委员人数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="专职团干部数" name="fulltimeCadreCount">
<a-input
v-model:value="form.fulltimeCadreCount"
placeholder="请输入专职团干部数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="兼职团干部数" name="parttimeCadreCount">
<a-input
v-model:value="form.parttimeCadreCount"
placeholder="请输入兼职团干部数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="团委最近一次换届时间" name="lastElectionTime">
<a-date-picker
v-model:value="form.lastElectionTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择团委最近一次换届时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度应收团费" name="feeReceivable2024">
<a-input
v-model:value="form.feeReceivable2024"
placeholder="请输入上一年度应收团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度实收团费" name="feeReceived2024">
<a-input
v-model:value="form.feeReceived2024"
placeholder="请输入上一年度实收团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度应上缴团费" name="feePayable2024">
<a-input
v-model:value="form.feePayable2024"
placeholder="请输入上一年度应上缴团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度实际上缴团费" name="feePaid2024">
<a-input
v-model:value="form.feePaid2024"
placeholder="请输入上一年度实际上缴团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="团支部数" name="branchCount">
<a-input
v-model:value="form.branchCount"
placeholder="请输入团支部数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="是否开展规范化建设工作"
name="standardizedWork2024"
>
<a-select
v-model:value="form.standardizedWork2024"
placeholder="请选择"
>
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="推荐入党积极分子人数"
name="recommendActivist2024"
>
<a-input
v-model:value="form.recommendActivist2024"
placeholder="请输入推荐入党积极分子人数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="确定为入党积极分子数" name="activistConfirmed">
<a-input
v-model:value="form.activistConfirmed"
placeholder="请输入确定为入党积极分子数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="推荐党的发展对象人数"
name="recommendDevTarget2024"
>
<a-input
v-model:value="form.recommendDevTarget2024"
placeholder="请输入推荐党的发展对象人数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="确定为党的发展对象数" name="devTargetConfirmed">
<a-input
v-model:value="form.devTargetConfirmed"
placeholder="请输入确定为党的发展对象数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="近五年获得校级及以上荣誉情况"
name="honorsFiveYears"
>
<a-textarea
v-model:value="form.honorsFiveYears"
:rows="3"
placeholder="请输入近五年获得校级及以上荣誉情况"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="近三年开展的主要工作及取得的效果"
name="workSummaryThreeYears"
>
<a-textarea
v-model:value="form.workSummaryThreeYears"
:rows="3"
placeholder="请输入近三年开展的主要工作及取得的效果"
/>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在单位党组织意见" name="partyOpinion">-->
<!-- <a-textarea v-model:value="form.partyOpinion" :rows="2" placeholder="请输入所在单位党组织意见" />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="校团委意见" name="schoolOpinion">-->
<!-- <a-textarea v-model:value="form.schoolOpinion" :rows="2" placeholder="请输入校团委意见" />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addWshqtwForm, updateWshqtwForm } from '@/api/gxmu/wshqtw-form';
import type { WshqtwForm } from '@/api/gxmu/wshqtw-form/model';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: WshqtwForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const createDefaultForm = (): WshqtwForm => ({
id: undefined,
year: '',
orgName: '',
leader: '',
phone: '',
memberTotal: '',
memberDeveloped2024: '',
smartSystemLogin: '',
committeeCount: '',
fulltimeCadreCount: '',
parttimeCadreCount: '',
lastElectionTime: '',
feeReceivable2024: '',
feeReceived2024: '',
feePayable2024: '',
feePaid2024: '',
branchCount: '',
standardizedWork2024: '',
recommendActivist2024: '',
activistConfirmed: '',
recommendDevTarget2024: '',
devTargetConfirmed: '',
honorsFiveYears: '',
workSummaryThreeYears: '',
partyOpinion: '',
schoolOpinion: ''
});
const form = reactive<WshqtwForm>(createDefaultForm());
watch(
() => props.visible,
(visible) => {
if (visible) {
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateWshqtwForm : addWshqtwForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,104 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="五四红旗团委"
file-name="五四红旗团委导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addWshqtwForm } from '@/api/gxmu/wshqtw-form';
import type { WshqtwForm } from '@/api/gxmu/wshqtw-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '二级团组织全称', key: 'orgName', required: true },
{ header: '负责人', key: 'leader' },
{ header: '联系电话', key: 'phone' },
{ header: '现有团员总数', key: 'memberTotal' },
{ header: '上一年度发展团员人数', key: 'memberDeveloped2024' },
{ header: '是否已登录智慧团建系统', key: 'smartSystemLogin' },
{ header: '团委委员人数', key: 'committeeCount' },
{ header: '专职团干部数', key: 'fulltimeCadreCount' },
{ header: '兼职团干部数', key: 'parttimeCadreCount' },
{ header: '团委最近一次换届时间', key: 'lastElectionTime' },
{ header: '上一年度应收团费', key: 'feeReceivable2024' },
{ header: '上一年度实收团费', key: 'feeReceived2024' },
{ header: '上一年度应上缴团费', key: 'feePayable2024' },
{ header: '上一年度实际上缴团费', key: 'feePaid2024' },
{ header: '团支部数', key: 'branchCount' },
{ header: '是否开展规范化建设工作', key: 'standardizedWork2024' },
{ header: '推荐入党积极分子人数', key: 'recommendActivist2024' },
{ header: '确定为入党积极分子数', key: 'activistConfirmed' },
{ header: '推荐党的发展对象人数', key: 'recommendDevTarget2024' },
{ header: '确定为党的发展对象数', key: 'devTargetConfirmed' },
{ header: '近五年获得校级及以上荣誉情况', key: 'honorsFiveYears' },
{ header: '近三年开展的主要工作及取得的效果', key: 'workSummaryThreeYears' }
] as const;
const sampleRow = {
年份: '2025',
二级团组织全称: '第一临床医学院团委',
负责人: '王老师',
联系电话: '13800138000',
现有团员总数: '560',
上一年度发展团员人数: '120',
是否已登录智慧团建系统: '是',
团委委员人数: '9',
专职团干部数: '2',
兼职团干部数: '8',
团委最近一次换届时间: '2024-06-01',
上一年度应收团费: '5000',
上一年度实收团费: '4800',
上一年度应上缴团费: '3000',
上一年度实际上缴团费: '3000',
团支部数: '24',
是否开展规范化建设工作: '是',
推荐入党积极分子人数: '30',
确定为入党积极分子数: '28',
推荐党的发展对象人数: '12',
确定为党的发展对象数: '10',
近五年获得校级及以上荣誉情况: '校五四红旗团委',
近三年开展的主要工作及取得的效果: '组织青年志愿活动成效显著'
};
const createPayload = (row: Record<string, any>): WshqtwForm => ({
year: String(row['年份'] || '').trim(),
orgName: String(row['二级团组织全称'] || '').trim(),
leader: String(row['负责人'] || '').trim(),
phone: String(row['联系电话'] || '').trim(),
memberTotal: String(row['现有团员总数'] || '').trim(),
memberDeveloped2024: String(row['上一年度发展团员人数'] || '').trim(),
smartSystemLogin: String(row['是否已登录智慧团建系统'] || '').trim(),
committeeCount: String(row['团委委员人数'] || '').trim(),
fulltimeCadreCount: String(row['专职团干部数'] || '').trim(),
parttimeCadreCount: String(row['兼职团干部数'] || '').trim(),
lastElectionTime: String(row['团委最近一次换届时间'] || '').trim(),
feeReceivable2024: String(row['上一年度应收团费'] || '').trim(),
feeReceived2024: String(row['上一年度实收团费'] || '').trim(),
feePayable2024: String(row['上一年度应上缴团费'] || '').trim(),
feePaid2024: String(row['上一年度实际上缴团费'] || '').trim(),
branchCount: String(row['团支部数'] || '').trim(),
standardizedWork2024: String(row['是否开展规范化建设工作'] || '').trim(),
recommendActivist2024: String(row['推荐入党积极分子人数'] || '').trim(),
activistConfirmed: String(row['确定为入党积极分子数'] || '').trim(),
recommendDevTarget2024: String(row['推荐党的发展对象人数'] || '').trim(),
devTargetConfirmed: String(row['确定为党的发展对象数'] || '').trim(),
honorsFiveYears: String(row['近五年获得校级及以上荣誉情况'] || '').trim(),
workSummaryThreeYears: String(
row['近三年开展的主要工作及取得的效果'] || ''
).trim()
});
const saveItem = (item: WshqtwForm) => addWshqtwForm(item);
</script>
+447
View File
@@ -0,0 +1,447 @@
<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: 3200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入团组织/负责人/联系电话"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(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>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="五四红旗团委"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportWshqtwForm,
userPageWshqtwForm,
removeWshqtwForm
} from '@/api/gxmu/wshqtw-form';
import type {
WshqtwForm,
WshqtwFormParam
} from '@/api/gxmu/wshqtw-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<WshqtwForm>();
const detailCurrent = ref<WshqtwForm | null>(null);
const REVIEW_MODULE = 'gxmu_wshqtw';
const showReviewHistory = ref(false);
const reviewCurrent = ref<WshqtwForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '二级团组织全称',
dataIndex: 'orgName',
key: 'orgName',
align: 'center',
width: 180
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '负责人',
dataIndex: 'leader',
key: 'leader',
align: 'center',
width: 100
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
align: 'center',
width: 140
},
{
title: '现有团员总数',
dataIndex: 'memberTotal',
key: 'memberTotal',
align: 'center',
width: 130
},
{
title: '上一年度发展团员人数',
dataIndex: 'memberDeveloped2024',
key: 'memberDeveloped2024',
align: 'center',
width: 170
},
{
title: '本级是否已登录“智慧团建”系统',
dataIndex: 'smartSystemLogin',
key: 'smartSystemLogin',
align: 'center',
width: 200
},
{
title: '团委委员人数',
dataIndex: 'committeeCount',
key: 'committeeCount',
align: 'center',
width: 130
},
{
title: '专职团干部数(其中教师/学生)',
dataIndex: 'fulltimeCadreCount',
key: 'fulltimeCadreCount',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '兼职团干部数(其中教师/学生)',
dataIndex: 'parttimeCadreCount',
key: 'parttimeCadreCount',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '团委最近一次换届时间',
dataIndex: 'lastElectionTime',
key: 'lastElectionTime',
align: 'center',
width: 170
},
{
title: '上一年度应收团费',
dataIndex: 'feeReceivable2024',
key: 'feeReceivable2024',
align: 'center',
width: 130
},
{
title: '上一年度实收团费',
dataIndex: 'feeReceived2024',
key: 'feeReceived2024',
align: 'center',
width: 130
},
{
title: '上一年度应上缴团费',
dataIndex: 'feePayable2024',
key: 'feePayable2024',
align: 'center',
width: 130
},
{
title: '上一年度实际上缴团费',
dataIndex: 'feePaid2024',
key: 'feePaid2024',
align: 'center',
width: 150
},
{
title: '团支部数',
dataIndex: 'branchCount',
key: 'branchCount',
align: 'center',
width: 110
},
{
title: '上一年度是否开展基层团组织规范化建设工作',
dataIndex: 'standardizedWork2024',
key: 'standardizedWork2024',
align: 'center',
width: 240
},
{
title: '上一年度推荐优秀团员作为入党积极分子人数',
dataIndex: 'recommendActivist2024',
key: 'recommendActivist2024',
align: 'center',
width: 240
},
{
title: '其中被党组织确定为入党积极分子数',
dataIndex: 'activistConfirmed',
key: 'activistConfirmed',
align: 'center',
width: 220
},
{
title: '上一年度推荐优秀团员作为党的发展对象人数',
dataIndex: 'recommendDevTarget2024',
key: 'recommendDevTarget2024',
align: 'center',
width: 240
},
{
title: '其中被党组织确定为党的发展对象数',
dataIndex: 'devTargetConfirmed',
key: 'devTargetConfirmed',
align: 'center',
width: 220
},
{
title: '近五年获得校级及以上荣誉情况',
dataIndex: 'honorsFiveYears',
key: 'honorsFiveYears',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '近三年开展的主要工作及取得的效果',
dataIndex: 'workSummaryThreeYears',
key: 'workSummaryThreeYears',
align: 'center',
width: 260,
ellipsis: true
},
{
title: '所在单位党组织意见',
dataIndex: 'partyOpinion',
key: 'partyOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '校团委意见',
dataIndex: 'schoolOpinion',
key: 'schoolOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 220
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageWshqtwForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WshqtwFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const buildQueryParams = (): WshqtwFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WshqtwFormParam;
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(
payload?.url || payload?.downloadUrl || payload?.path || ''
);
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportWshqtwForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: WshqtwForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as WshqtwForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: WshqtwForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: WshqtwForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const remove = (record: WshqtwForm) => {
const hide = message.loading('请求中..', 0);
removeWshqtwForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'WSHQTWForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
@@ -0,0 +1,409 @@
<template>
<a-modal
:width="1000"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 100%"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="团支部全称"
name="branchName"
:rules="[{ required: true, message: '请输入团支部全称' }]"
>
<a-input
v-model:value="form.branchName"
placeholder="请输入团支部全称"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="所属二级团组织" name="secondOrg">
<a-input
v-model:value="form.secondOrg"
placeholder="请输入所属二级团组织"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="团支部书记" name="secretary">
<a-input
v-model:value="form.secretary"
placeholder="请输入团支部书记"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="政治面貌" name="politics">
<a-select
allow-clear
show-search
v-model:value="form.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="联系方式" name="contact">
<a-input
v-model:value="form.contact"
placeholder="请输入联系方式"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="成立时间" name="establishTime">
<a-date-picker
v-model:value="form.establishTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择成立时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="最近一次换届时间" name="lastElectionTime">
<a-date-picker
v-model:value="form.lastElectionTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择最近一次换届时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="是否已登录智慧团建系统" name="smartSystemLogin">
<a-select
v-model:value="form.smartSystemLogin"
placeholder="请选择"
>
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="现有团员总数" name="memberTotal">
<a-input
v-model:value="form.memberTotal"
placeholder="请输入现有团员总数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度发展团员数" name="memberDeveloped2024">
<a-input
v-model:value="form.memberDeveloped2024"
placeholder="请输入上一年度发展团员数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度应收团费" name="feeReceivable2024">
<a-input
v-model:value="form.feeReceivable2024"
placeholder="请输入上一年度应收团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度实收团费" name="feeReceived2024">
<a-input
v-model:value="form.feeReceived2024"
placeholder="请输入上一年度实收团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度应上缴团费" name="feePayable2024">
<a-input
v-model:value="form.feePayable2024"
placeholder="请输入上一年度应上缴团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度实际上缴团费" name="feePaid2024">
<a-input
v-model:value="form.feePaid2024"
placeholder="请输入上一年度实际上缴团费"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="推荐入党积极分子人数"
name="recommendActivist2024"
>
<a-input
v-model:value="form.recommendActivist2024"
placeholder="请输入推荐入党积极分子人数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="确定为入党积极分子数" name="activistConfirmed">
<a-input
v-model:value="form.activistConfirmed"
placeholder="请输入确定为入党积极分子数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="推荐党的发展对象人数"
name="recommendDevTarget2024"
>
<a-input
v-model:value="form.recommendDevTarget2024"
placeholder="请输入推荐党的发展对象人数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="确定为党的发展对象数" name="devTargetConfirmed">
<a-input
v-model:value="form.devTargetConfirmed"
placeholder="请输入确定为党的发展对象数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="团支部委员会会议召开次数"
name="branchCommitteeMeetingCount"
>
<a-input
v-model:value="form.branchCommitteeMeetingCount"
placeholder="请输入团支部委员会会议召开次数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="团支部团员大会召开次数"
name="branchMemberMeetingCount"
>
<a-input
v-model:value="form.branchMemberMeetingCount"
placeholder="请输入团支部团员大会召开次数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="是否开展团员教育评议" name="eduEvalDone">
<a-select v-model:value="form.eduEvalDone" placeholder="请选择">
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="是否开展团员年度团籍注册" name="annualRegDone">
<a-select v-model:value="form.annualRegDone" placeholder="请选择">
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="开展团课次数" name="classCount">
<a-input
v-model:value="form.classCount"
placeholder="请输入开展团课次数"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="是否100%录入智慧团建" name="smartSystem100">
<a-select v-model:value="form.smartSystem100" placeholder="请选择">
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="近五年获得院级及以上荣誉情况"
name="honorsFiveYears"
>
<a-textarea
v-model:value="form.honorsFiveYears"
:rows="3"
placeholder="请输入近五年获得院级及以上荣誉情况"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="近三年开展的主要工作及取得的效果"
name="workSummaryThreeYears"
>
<a-textarea
v-model:value="form.workSummaryThreeYears"
:rows="3"
placeholder="请输入近三年开展的主要工作及取得的效果"
/>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在二级团组织意见" name="leagueOpinion">-->
<!-- <a-textarea v-model:value="form.leagueOpinion" :rows="2" placeholder="请输入所在二级团组织意见" />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在单位党组织意见" name="partyOpinion">-->
<!-- <a-textarea v-model:value="form.partyOpinion" :rows="2" placeholder="请输入所在单位党组织意见" />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="校团委意见" name="schoolOpinion">-->
<!-- <a-textarea v-model:value="form.schoolOpinion" :rows="2" placeholder="请输入校团委意见" />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addWshqtzbForm, updateWshqtzbForm } from '@/api/gxmu/wshqtzb-form';
import type { WshqtzbForm } from '@/api/gxmu/wshqtzb-form/model';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: WshqtzbForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const { politicsOptions, loadPoliticsOptions } = useGxmuFormSelectOptions();
const createDefaultForm = (): WshqtzbForm => ({
id: undefined,
year: '',
branchName: '',
secondOrg: '',
secretary: '',
politics: '',
contact: '',
establishTime: '',
lastElectionTime: '',
smartSystemLogin: '',
memberTotal: '',
memberDeveloped2024: '',
feeReceivable2024: '',
feeReceived2024: '',
feePayable2024: '',
feePaid2024: '',
recommendActivist2024: '',
activistConfirmed: '',
recommendDevTarget2024: '',
devTargetConfirmed: '',
branchCommitteeMeetingCount: '',
branchMemberMeetingCount: '',
eduEvalDone: '',
annualRegDone: '',
classCount: '',
smartSystem100: '',
honorsFiveYears: '',
workSummaryThreeYears: '',
leagueOpinion: '',
partyOpinion: '',
schoolOpinion: ''
});
const form = reactive<WshqtzbForm>(createDefaultForm());
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await loadPoliticsOptions();
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value
? updateWshqtzbForm
: addWshqtzbForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,116 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="五四红旗团支部"
file-name="五四红旗团支部导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addWshqtzbForm } from '@/api/gxmu/wshqtzb-form';
import type { WshqtzbForm } from '@/api/gxmu/wshqtzb-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '团支部全称', key: 'branchName', required: true },
{ header: '所属二级党组织', key: 'secondOrg' },
{ header: '团支部书记', key: 'secretary' },
{ header: '政治面貌', key: 'politics' },
{ header: '联系方式', key: 'contact' },
{ header: '成立时间', key: 'establishTime' },
{ header: '最近一次换届时间', key: 'lastElectionTime' },
{ header: '智慧团建系统登录情况', key: 'smartSystemLogin' },
{ header: '现有团员总数', key: 'memberTotal' },
{ header: '上一年度发展团员人数', key: 'memberDeveloped2024' },
{ header: '上一年度应收团费', key: 'feeReceivable2024' },
{ header: '上一年度实收团费', key: 'feeReceived2024' },
{ header: '上一年度应上缴团费', key: 'feePayable2024' },
{ header: '上一年度实际上缴团费', key: 'feePaid2024' },
{ header: '推荐入党积极分子人数', key: 'recommendActivist2024' },
{ header: '确定为入党积极分子数', key: 'activistConfirmed' },
{ header: '推荐党的发展对象人数', key: 'recommendDevTarget2024' },
{ header: '确定为党的发展对象数', key: 'devTargetConfirmed' },
{ header: '支委会次数', key: 'branchCommitteeMeetingCount' },
{ header: '支部大会次数', key: 'branchMemberMeetingCount' },
{ header: '教育评议是否完成', key: 'eduEvalDone' },
{ header: '年度团籍注册是否完成', key: 'annualRegDone' },
{ header: '班级数', key: 'classCount' },
{ header: '智慧团建系统覆盖率', key: 'smartSystem100' },
{ header: '近五年获得校级及以上荣誉情况', key: 'honorsFiveYears' },
{ header: '近三年开展的主要工作及取得的效果', key: 'workSummaryThreeYears' }
] as const;
const sampleRow = {
年份: '2025',
团支部全称: '护理学院2022级1班团支部',
所属二级党组织: '护理学院党委',
团支部书记: '张同学',
政治面貌: '共青团员',
联系方式: '13800138000',
成立时间: '2022-09-01',
最近一次换届时间: '2024-09-01',
智慧团建系统登录情况: '是',
现有团员总数: '38',
上一年度发展团员人数: '12',
上一年度应收团费: '380',
上一年度实收团费: '380',
上一年度应上缴团费: '200',
上一年度实际上缴团费: '200',
推荐入党积极分子人数: '8',
确定为入党积极分子数: '7',
推荐党的发展对象人数: '3',
确定为党的发展对象数: '2',
支委会次数: '12',
支部大会次数: '8',
教育评议是否完成: '是',
年度团籍注册是否完成: '是',
班级数: '1',
智慧团建系统覆盖率: '100%',
近五年获得校级及以上荣誉情况: '校五四红旗团支部',
近三年开展的主要工作及取得的效果: '主题团日活动开展扎实'
};
const createPayload = (row: Record<string, any>): WshqtzbForm => ({
year: String(row['年份'] || '').trim(),
branchName: String(row['团支部全称'] || '').trim(),
secondOrg: String(row['所属二级党组织'] || '').trim(),
secretary: String(row['团支部书记'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
contact: String(row['联系方式'] || '').trim(),
establishTime: String(row['成立时间'] || '').trim(),
lastElectionTime: String(row['最近一次换届时间'] || '').trim(),
smartSystemLogin: String(row['智慧团建系统登录情况'] || '').trim(),
memberTotal: String(row['现有团员总数'] || '').trim(),
memberDeveloped2024: String(row['上一年度发展团员人数'] || '').trim(),
feeReceivable2024: String(row['上一年度应收团费'] || '').trim(),
feeReceived2024: String(row['上一年度实收团费'] || '').trim(),
feePayable2024: String(row['上一年度应上缴团费'] || '').trim(),
feePaid2024: String(row['上一年度实际上缴团费'] || '').trim(),
recommendActivist2024: String(row['推荐入党积极分子人数'] || '').trim(),
activistConfirmed: String(row['确定为入党积极分子数'] || '').trim(),
recommendDevTarget2024: String(row['推荐党的发展对象人数'] || '').trim(),
devTargetConfirmed: String(row['确定为党的发展对象数'] || '').trim(),
branchCommitteeMeetingCount: String(row['支委会次数'] || '').trim(),
branchMemberMeetingCount: String(row['支部大会次数'] || '').trim(),
eduEvalDone: String(row['教育评议是否完成'] || '').trim(),
annualRegDone: String(row['年度团籍注册是否完成'] || '').trim(),
classCount: String(row['班级数'] || '').trim(),
smartSystem100: String(row['智慧团建系统覆盖率'] || '').trim(),
honorsFiveYears: String(row['近五年获得校级及以上荣誉情况'] || '').trim(),
workSummaryThreeYears: String(
row['近三年开展的主要工作及取得的效果'] || ''
).trim()
});
const saveItem = (item: WshqtzbForm) => addWshqtzbForm(item);
</script>
+481
View File
@@ -0,0 +1,481 @@
<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: 3600 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入团支部/书记/联系方式"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(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>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="五四红旗团支部"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportWshqtzbForm,
userPageWshqtzbForm,
removeWshqtzbForm
} from '@/api/gxmu/wshqtzb-form';
import type {
WshqtzbForm,
WshqtzbFormParam
} from '@/api/gxmu/wshqtzb-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<WshqtzbForm>();
const detailCurrent = ref<WshqtzbForm | null>(null);
const REVIEW_MODULE = 'gxmu_wshqtzb';
const showReviewHistory = ref(false);
const reviewCurrent = ref<WshqtzbForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '团支部全称',
dataIndex: 'branchName',
key: 'branchName',
align: 'center',
width: 180
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '所属二级团组织',
dataIndex: 'secondOrg',
key: 'secondOrg',
align: 'center',
width: 180
},
{
title: '团支部书记',
dataIndex: 'secretary',
key: 'secretary',
align: 'center',
width: 120
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 120
},
{
title: '联系方式',
dataIndex: 'contact',
key: 'contact',
align: 'center',
width: 140
},
{
title: '成立时间',
dataIndex: 'establishTime',
key: 'establishTime',
align: 'center',
width: 120
},
{
title: '最近一次换届时间',
dataIndex: 'lastElectionTime',
key: 'lastElectionTime',
align: 'center',
width: 160
},
{
title: '本级是否已登录“智慧团建”系统',
dataIndex: 'smartSystemLogin',
key: 'smartSystemLogin',
align: 'center',
width: 200
},
{
title: '现有团员总数',
dataIndex: 'memberTotal',
key: 'memberTotal',
align: 'center',
width: 130
},
{
title: '上一年度发展团员数',
dataIndex: 'memberDeveloped2024',
key: 'memberDeveloped2024',
align: 'center',
width: 150
},
{
title: '上一年度应收团费',
dataIndex: 'feeReceivable2024',
key: 'feeReceivable2024',
align: 'center',
width: 130
},
{
title: '上一年度实收团费',
dataIndex: 'feeReceived2024',
key: 'feeReceived2024',
align: 'center',
width: 130
},
{
title: '上一年度应上缴团费',
dataIndex: 'feePayable2024',
key: 'feePayable2024',
align: 'center',
width: 140
},
{
title: '上一年度实际上缴团费',
dataIndex: 'feePaid2024',
key: 'feePaid2024',
align: 'center',
width: 150
},
{
title: '推荐优秀团员作入党积极分子人数(上一年度)',
dataIndex: 'recommendActivist2024',
key: 'recommendActivist2024',
align: 'center',
width: 240
},
{
title: '其中被党组织确定为入党积极分子数',
dataIndex: 'activistConfirmed',
key: 'activistConfirmed',
align: 'center',
width: 220
},
{
title: '推荐优秀团员作党的发展对象人数(上一年度)',
dataIndex: 'recommendDevTarget2024',
key: 'recommendDevTarget2024',
align: 'center',
width: 240
},
{
title: '其中被党组织确定为党的发展对象数',
dataIndex: 'devTargetConfirmed',
key: 'devTargetConfirmed',
align: 'center',
width: 220
},
{
title: '团支部委员会会议召开次数',
dataIndex: 'branchCommitteeMeetingCount',
key: 'branchCommitteeMeetingCount',
align: 'center',
width: 200
},
{
title: '团支部团员大会召开次数',
dataIndex: 'branchMemberMeetingCount',
key: 'branchMemberMeetingCount',
align: 'center',
width: 200
},
{
title: '是否开展团员教育评议',
dataIndex: 'eduEvalDone',
key: 'eduEvalDone',
align: 'center',
width: 170
},
{
title: '是否开展团员年度团籍注册',
dataIndex: 'annualRegDone',
key: 'annualRegDone',
align: 'center',
width: 200
},
{
title: '开展团课次数',
dataIndex: 'classCount',
key: 'classCount',
align: 'center',
width: 130
},
{
title: '是否100%录入“智慧团建”',
dataIndex: 'smartSystem100',
key: 'smartSystem100',
align: 'center',
width: 200
},
{
title: '近五年获得院级及以上荣誉情况',
dataIndex: 'honorsFiveYears',
key: 'honorsFiveYears',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '近三年开展的主要工作及取得的效果',
dataIndex: 'workSummaryThreeYears',
key: 'workSummaryThreeYears',
align: 'center',
width: 260,
ellipsis: true
},
{
title: '所在二级团组织意见',
dataIndex: 'leagueOpinion',
key: 'leagueOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '所在单位党组织意见',
dataIndex: 'partyOpinion',
key: 'partyOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '校团委意见',
dataIndex: 'schoolOpinion',
key: 'schoolOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 220
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageWshqtzbForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WshqtzbFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const buildQueryParams = (): WshqtzbFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WshqtzbFormParam;
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(
payload?.url || payload?.downloadUrl || payload?.path || ''
);
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportWshqtzbForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: WshqtzbForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as WshqtzbForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: WshqtzbForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: WshqtzbForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const remove = (record: WshqtzbForm) => {
const hide = message.loading('请求中..', 0);
removeWshqtzbForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'WSHQTZBForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
@@ -0,0 +1,706 @@
<template>
<ele-modal
:width="960"
:footer="null"
title="项目申报书文件校验"
:visible="visible"
@update:visible="updateVisible"
>
<a-spin :spinning="loading">
<div class="validator-layout">
<div class="upload-panel">
<div class="validator-intro">
上传项目申报书后,系统将调用项目书质量初审模型,从创新性、可行性、应用前景、文本规范性等维度生成初审报告。
</div>
<div class="upload-label">项目申报书</div>
<a-upload-dragger
accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
:file-list="projectFileList"
:before-upload="beforeProjectUpload"
:max-count="1"
@remove="removeProjectFile"
>
<p class="ant-upload-drag-icon">
<cloud-upload-outlined />
</p>
<p class="ant-upload-text">点击或拖拽上传待审项目申报书</p>
<p class="ant-upload-hint">支持 PDF、DOC、DOCX,大小不超过 50MB</p>
</a-upload-dragger>
<div class="upload-label optional-label">自定义评分细则</div>
<a-upload
accept=".pdf,.doc,.docx,.csv,.xls,.xlsx,.txt,.md"
:file-list="rubricFileList"
:before-upload="beforeRubricUpload"
:max-count="1"
@remove="removeRubricFile"
>
<a-button>
<template #icon>
<upload-outlined />
</template>
上传评分细则
</a-button>
</a-upload>
<div class="action-row">
<a-button
type="primary"
:disabled="!projectFile || loading"
@click="startReview"
>
开始校验
</a-button>
<a-button :disabled="!loading" @click="stopReview">停止</a-button>
<a-button :disabled="loading && !reportMarkdown" @click="clearResult">
清空
</a-button>
</div>
</div>
<div class="result-panel">
<div class="result-toolbar">
<div>
<div class="result-title">AI 初审报告</div>
<div class="result-subtitle">
{{ statusText || '报告生成后将在此展示' }}
</div>
</div>
<a-space>
<a-tag :color="loading ? 'processing' : reportMarkdown ? 'success' : 'default'">
{{ loading ? '生成中' : reportMarkdown ? '已完成' : '待开始' }}
</a-tag>
<a-button size="small" :disabled="!reportMarkdown" @click="exportReport">
导出
</a-button>
<a-button
size="small"
:disabled="!reportMarkdown"
@click="fullscreenVisible = true"
>
全屏查看
</a-button>
<a-button size="small" :disabled="!reportMarkdown" @click="copyReport">
复制
</a-button>
</a-space>
</div>
<div ref="reportShellRef" class="report-shell">
<div v-if="loading && !reportMarkdown" class="report-streaming">
AI 正在分析项目申报书...
</div>
<ByteMdViewer
v-else-if="reportMarkdown"
class="report-viewer"
:value="reportMarkdown"
:plugins="plugins"
/>
<a-empty v-else description="请先上传项目申报书并开始校验" />
</div>
</div>
</div>
</a-spin>
</ele-modal>
<ele-modal
:width="'92vw'"
:footer="null"
title="AI 初审报告"
:visible="fullscreenVisible"
@update:visible="fullscreenVisible = $event"
>
<div class="fullscreen-toolbar">
<a-space>
<a-button :disabled="!reportMarkdown" @click="exportReport">
导出
</a-button>
<a-button :disabled="!reportMarkdown" @click="copyReport">
复制
</a-button>
</a-space>
</div>
<div class="fullscreen-report-shell">
<ByteMdViewer
v-if="reportMarkdown"
class="report-viewer"
:value="reportMarkdown"
:plugins="plugins"
/>
<a-empty v-else description="暂无初审报告" />
</div>
</ele-modal>
</template>
<script lang="ts" setup>
import 'github-markdown-css/github-markdown-light.css';
import gfm from '@bytemd/plugin-gfm';
import zhHansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
import highlight from '@bytemd/plugin-highlight-ssr';
import 'highlight.js/styles/default.css';
import { computed, ref, watch, onBeforeUnmount, nextTick } from 'vue';
import { message } from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import { CloudUploadOutlined, UploadOutlined } from '@ant-design/icons-vue';
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
import { reviewWxxzxProjectBookQuality } from '@/api/gxmu/wxxzx-form';
import { stopMessage } from '@/api/oa/chatgpt';
import { API_BASE_URL } from '@/config/setting';
import { useUserStore } from '@/store/modules/user';
import { copyText } from '@/utils/common';
import type { WxxzxProjectQualityReviewResult } from '@/api/gxmu/wxxzx-form/model';
const STREAM_END_TEXT = '__END__';
const SOCKET_CONNECTED_TEXT = '连接成功';
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
}>();
const loginUser = computed(() => useUserStore().info ?? {});
const plugins = ref([
gfm({
locale: zhHansGfm
}),
highlight()
]);
const loading = ref(false);
const statusText = ref('');
const projectFile = ref<File>();
const rubricFile = ref<File>();
const reportMarkdown = ref('');
const activeTaskId = ref<string>();
const streamStarted = ref(false);
const fullscreenVisible = ref(false);
const reportShellRef = ref<HTMLElement | null>(null);
const webSocketRef = ref<WebSocket | null>(null);
const wsConnectPromiseRef = ref<Promise<void> | null>(null);
const streamResolverRef = ref<{
resolve: () => void;
reject: (error: Error) => void;
} | null>(null);
const projectFileList = computed(() =>
projectFile.value
? [
{
uid: 'project',
name: projectFile.value.name,
status: 'done' as const
}
]
: []
);
const rubricFileList = computed(() =>
rubricFile.value
? [
{
uid: 'rubric',
name: rubricFile.value.name,
status: 'done' as const
}
]
: []
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const beforeProjectUpload: UploadProps['beforeUpload'] = (file) => {
if (file.size / 1024 / 1024 > 50) {
message.error('项目申报书不能超过 50MB');
return false;
}
projectFile.value = file as File;
return false;
};
const beforeRubricUpload: UploadProps['beforeUpload'] = (file) => {
if (file.size / 1024 / 1024 > 20) {
message.error('评分细则不能超过 20MB');
return false;
}
rubricFile.value = file as File;
return false;
};
const removeProjectFile = () => {
projectFile.value = undefined;
return true;
};
const removeRubricFile = () => {
rubricFile.value = undefined;
return true;
};
const scrollReportToBottom = () => {
nextTick(() => {
const el = reportShellRef.value;
if (el) {
el.scrollTop = el.scrollHeight;
}
});
};
const buildWebSocketUrl = (userId: number) => {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = url.pathname.replace(/\/api\/?$/, '');
return `${url.toString().replace(/\/$/, '')}/chat/${userId}`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/chat/${userId}`;
};
const clearStreamResolver = () => {
streamResolverRef.value = null;
};
const resolveStream = () => {
streamResolverRef.value?.resolve();
clearStreamResolver();
};
const rejectStream = (error: Error) => {
streamResolverRef.value?.reject(error);
clearStreamResolver();
};
const createStreamWaiter = () => {
streamStarted.value = false;
return new Promise<void>((resolve, reject) => {
streamResolverRef.value = { resolve, reject };
});
};
const disconnectWebSocket = () => {
wsConnectPromiseRef.value = null;
const socket = webSocketRef.value;
webSocketRef.value = null;
if (
socket &&
(socket.readyState === WebSocket.OPEN ||
socket.readyState === WebSocket.CONNECTING)
) {
socket.close();
}
};
const pickReportText = (payload: any): string => {
if (!payload) {
return '';
}
if (typeof payload === 'string') {
return payload;
}
const outputs = payload.outputs || payload.data?.outputs || payload.raw?.data?.outputs;
const direct =
payload.answer ||
payload.result ||
payload.output ||
payload.content ||
outputs?.answer ||
outputs?.result ||
outputs?.output ||
outputs?.content ||
outputs?.report ||
outputs?.markdown;
if (typeof direct === 'string') {
return direct;
}
if (outputs) {
const firstText = Object.values(outputs).find(
(item) => typeof item === 'string' && item.trim()
);
if (typeof firstText === 'string') {
return firstText;
}
}
return '';
};
const appendReportChunk = (chunk: string, taskId?: string) => {
if (!chunk) {
return;
}
if (taskId && taskId !== '0') {
activeTaskId.value = taskId;
}
if (!streamStarted.value) {
streamStarted.value = true;
reportMarkdown.value = '';
}
reportMarkdown.value += chunk;
statusText.value = '正在通过 WebSocket 接收流式报告';
scrollReportToBottom();
};
const finishStream = (taskId?: string) => {
if (taskId && taskId !== '0') {
activeTaskId.value = taskId;
}
loading.value = false;
statusText.value = '项目书质量初审完成';
scrollReportToBottom();
resolveStream();
disconnectWebSocket();
};
const handleSocketMessage = (raw: string) => {
if (!raw) {
return;
}
try {
const data = JSON.parse(raw);
if (data?.stopType && data.stopType !== 'projectQualityReview') {
return;
}
if (data?.answer === STREAM_END_TEXT) {
finishStream(data?.taskId);
return;
}
if (data?.taskId && data.taskId !== '0') {
activeTaskId.value = data.taskId;
}
if (data?.loading === 'true' || data?.loading === true) {
statusText.value = 'AI 正在检索历史案例并组织评审意见...';
return;
}
if (typeof data?.answer === 'string') {
appendReportChunk(data.answer, data?.taskId);
}
} catch (e) {
appendReportChunk(raw);
}
};
const connectWebSocket = async () => {
const userId = loginUser.value.userId;
if (!userId) {
return Promise.reject(new Error('未获取到当前登录用户'));
}
const currentSocket = webSocketRef.value;
if (currentSocket?.readyState === WebSocket.OPEN) {
return;
}
if (
(currentSocket?.readyState === WebSocket.OPEN ||
currentSocket?.readyState === WebSocket.CONNECTING) &&
wsConnectPromiseRef.value
) {
return wsConnectPromiseRef.value;
}
let cleanupPending = () => undefined;
const connectPromise = new Promise<void>((resolve, reject) => {
let settled = false;
const socket = new WebSocket(buildWebSocketUrl(userId));
cleanupPending = () => {
if (wsConnectPromiseRef.value === connectPromise) {
wsConnectPromiseRef.value = null;
}
};
socket.onmessage = (event) => {
const raw = String(event.data || '');
if (raw === SOCKET_CONNECTED_TEXT) {
if (!settled) {
settled = true;
cleanupPending();
resolve();
}
return;
}
handleSocketMessage(raw);
};
socket.onclose = () => {
if (webSocketRef.value === socket) {
webSocketRef.value = null;
}
if (!settled) {
settled = true;
cleanupPending();
reject(new Error('项目书质量初审连接已关闭'));
return;
}
if (loading.value) {
rejectStream(new Error('项目书质量初审连接已关闭'));
loading.value = false;
}
cleanupPending();
};
socket.onerror = () => {
if (!settled) {
settled = true;
cleanupPending();
reject(new Error('项目书质量初审连接失败'));
return;
}
if (loading.value) {
rejectStream(new Error('项目书质量初审连接失败'));
loading.value = false;
}
};
webSocketRef.value = socket;
});
wsConnectPromiseRef.value = connectPromise;
return connectPromise;
};
const clearResult = () => {
reportMarkdown.value = '';
statusText.value = '';
activeTaskId.value = undefined;
streamStarted.value = false;
};
const startReview = async () => {
if (!projectFile.value) {
message.warning('请先上传项目申报书');
return;
}
loading.value = true;
clearResult();
statusText.value = '正在建立 WebSocket 连接...';
try {
await connectWebSocket();
statusText.value = '连接成功,已提交项目书质量初审任务...';
const waitStream = createStreamWaiter();
const result = (await reviewWxxzxProjectBookQuality(
projectFile.value,
rubricFile.value
)) as WxxzxProjectQualityReviewResult;
const directReport = pickReportText(result);
if (!streamStarted.value && directReport) {
reportMarkdown.value = directReport;
loading.value = false;
statusText.value = '项目书质量初审完成';
scrollReportToBottom();
resolveStream();
disconnectWebSocket();
} else {
await waitStream;
}
if (!reportMarkdown.value.trim()) {
message.warning('接口已返回,但未解析到初审报告内容');
return;
}
message.success('项目书质量初审完成');
} catch (e: any) {
disconnectWebSocket();
clearStreamResolver();
loading.value = false;
statusText.value = '';
message.error(e.message || '项目书质量初审失败');
}
};
const stopReview = async () => {
try {
await stopMessage({
taskId: activeTaskId.value,
mode: 'projectQualityReview' as any
});
loading.value = false;
statusText.value = '已停止项目书质量初审';
resolveStream();
disconnectWebSocket();
message.success('已停止');
} catch (e: any) {
message.error(e.message || '停止失败');
}
};
const copyReport = () => {
if (!reportMarkdown.value.trim()) {
message.warning('暂无可复制内容');
return;
}
copyText(reportMarkdown.value);
};
const exportReport = () => {
if (!reportMarkdown.value.trim()) {
message.warning('暂无可导出内容');
return;
}
const baseName =
projectFile.value?.name?.replace(/\.[^.]+$/, '') || '项目书质量初审报告';
const safeName = baseName.replace(/[\\/:*?"<>|]/g, '_');
const blob = new Blob([reportMarkdown.value], {
type: 'text/markdown;charset=utf-8'
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${safeName}-AI初审报告.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('导出成功');
};
watch(
() => props.visible,
(value) => {
if (value) {
clearResult();
} else {
disconnectWebSocket();
clearStreamResolver();
loading.value = false;
}
}
);
onBeforeUnmount(() => {
disconnectWebSocket();
clearStreamResolver();
});
</script>
<style lang="less" scoped>
.validator-layout {
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
align-items: start;
gap: 20px;
max-height: calc(100vh - 220px);
overflow: auto;
}
.upload-panel {
min-width: 0;
align-self: start;
}
.validator-intro {
margin-bottom: 16px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.7;
}
.upload-label {
margin: 14px 0 8px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.optional-label {
margin-top: 16px;
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 20px;
}
.result-panel {
min-width: 0;
align-self: start;
border: 1px solid #edf0f5;
border-radius: 8px;
background: #fff;
}
.result-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-bottom: 1px solid #edf0f5;
}
.result-title {
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.result-subtitle {
margin-top: 4px;
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
.report-shell {
height: 360px;
overflow: auto;
padding: 18px;
}
.report-streaming {
color: rgba(0, 0, 0, 0.65);
line-height: 1.8;
white-space: pre-wrap;
}
.report-viewer :deep(.markdown-body) {
font-size: 14px;
line-height: 1.8;
}
.fullscreen-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.fullscreen-report-shell {
height: calc(100vh - 210px);
overflow: auto;
padding: 20px 24px;
border: 1px solid #edf0f5;
border-radius: 8px;
background: #fff;
}
.fullscreen-report-shell :deep(.markdown-body) {
max-width: 980px;
margin: 0 auto;
font-size: 15px;
line-height: 1.9;
}
@media (max-width: 860px) {
.validator-layout {
grid-template-columns: 1fr;
max-height: calc(100vh - 180px);
}
.report-shell {
height: 320px;
}
}
:deep(.ant-upload.ant-upload-drag) {
height: 180px;
}
:deep(.ant-upload.ant-upload-drag .ant-upload) {
padding: 24px 16px;
}
:deep(.ant-upload.ant-upload-drag p.ant-upload-drag-icon) {
margin-bottom: 10px;
}
:deep(.ant-upload.ant-upload-drag p.ant-upload-text) {
margin-bottom: 6px;
font-size: 15px;
}
:deep(.ant-upload-list) {
max-height: 74px;
overflow: auto;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="未来学术之星"
file-name="未来学术之星导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:import-description="description"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addWxxzxForm } from '@/api/gxmu/wxxzx-form';
import type { WxxzxForm } from '@/api/gxmu/wxxzx-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const description =
'本模板仅导入未来学术之星主表字段。指导教师、申请人、预算、评审成员、过程材料与归档记录请在详情中继续维护。';
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '课题名称', key: 'topicName', required: true },
{ header: '课题负责人', key: 'leader' },
{ header: '学院、年级、班别', key: 'collegeGradeClass' },
{ header: '联系电话', key: 'phone' },
{ header: '指导老师', key: 'advisor' },
{ header: '课题类别', key: 'topicType' },
{ header: '立项依据', key: 'rationale' },
{ header: '指导教师意见', key: 'teacherOpinion' },
{ header: '学院意见', key: 'collegeOpinion' },
{ header: '学校意见', key: 'schoolOpinion' },
{ header: '承诺签字人', key: 'promiseSigner' },
{ header: '评审名单-学院', key: 'reviewCollege' },
{ header: '评审名单-汇报人', key: 'reviewReporter' },
{ header: '评审名单-日期', key: 'reviewDate' }
] as const;
const sampleRow = {
年份: '2025',
课题名称: '医学生科研素养提升路径研究',
课题负责人: '周八',
'学院、年级、班别': '第一临床医学院 2022级 1班',
联系电话: '13800138000',
指导老师: '李教授',
课题类别: '重点课题',
立项依据: '围绕医学生科研训练现状展开研究',
指导教师意见: '',
学院意见: '',
学校意见: '',
承诺签字人: '周八',
'评审名单-学院': '',
'评审名单-汇报人': '',
'评审名单-日期': ''
};
const createPayload = (row: Record<string, any>): WxxzxForm => ({
year: String(row['年份'] || '').trim(),
topicName: String(row['课题名称'] || '').trim(),
leader: String(row['课题负责人'] || '').trim(),
collegeGradeClass: String(row['学院、年级、班别'] || '').trim(),
phone: String(row['联系电话'] || '').trim(),
advisor: String(row['指导老师'] || '').trim(),
topicType: String(row['课题类别'] || '').trim(),
rationale: String(row['立项依据'] || '').trim(),
teacherOpinion: String(row['指导教师意见'] || '').trim(),
collegeOpinion: String(row['学院意见'] || '').trim(),
schoolOpinion: String(row['学校意见'] || '').trim(),
promiseSigner: String(row['承诺签字人'] || '').trim(),
reviewCollege: String(row['评审名单-学院'] || '').trim(),
reviewReporter: String(row['评审名单-汇报人'] || '').trim(),
reviewDate: String(row['评审名单-日期'] || '').trim(),
guidanceTeachers: [],
applicants: [],
budgets: [],
reviewMembers: [],
archiveRecords: []
});
const saveItem = (item: WxxzxForm) => addWxxzxForm(item);
</script>
@@ -0,0 +1,383 @@
<template>
<a-drawer
placement="right"
width="760"
:visible="visible"
title="项目过程材料"
:body-style="{ paddingBottom: '88px' }"
@update:visible="updateVisible"
>
<div class="material-header">
<div class="material-title">{{ props.data?.topicName || '未命名课题' }}</div>
<div class="material-subtitle">
负责人:{{ props.data?.leader || '-' }} | 指导老师:{{ props.data?.advisor || '-' }}
</div>
</div>
<a-tabs v-model:activeKey="activeKey" type="card" class="gxmu-tag-tabs">
<a-tab-pane key="funding" tab="经费管理">
<div class="stage-card">
<a-alert
type="info"
show-icon
message="请分别上传经费预算表和经费划拨明细表,支持覆盖更新。"
/>
<a-form layout="vertical" class="stage-form">
<a-form-item label="经费预算表">
<stage-file-upload
v-model:value="form.fundingBudgetFile"
button-text="上传经费预算表"
:multiple="false"
/>
</a-form-item>
<a-form-item label="经费划拨明细表">
<stage-file-upload
v-model:value="form.fundingAllocationFile"
button-text="上传经费划拨明细表"
:multiple="false"
/>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<a-tab-pane key="midterm" tab="中期检查">
<div class="stage-card">
<a-alert
type="info"
show-icon
message="学生团队可在此提交中期检查所需的阶段性材料。"
/>
<a-form layout="vertical" class="stage-form">
<a-form-item label="补充说明">
<a-textarea
v-model:value="form.midtermRemark"
:rows="4"
placeholder="请输入中期检查说明,如材料清单、阶段进展等"
/>
</a-form-item>
<a-form-item label="中期检查材料">
<stage-file-upload
v-model:value="form.midtermMaterials"
button-text="上传中期检查材料"
:multiple="true"
/>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<a-tab-pane key="final" tab="结题验收">
<div class="stage-card">
<a-alert
type="info"
show-icon
message="学生团队可上传结题验收阶段的最终材料。"
/>
<a-form layout="vertical" class="stage-form">
<a-form-item label="补充说明">
<a-textarea
v-model:value="form.finalRemark"
:rows="4"
placeholder="请输入结题说明,如成果概述、材料目录等"
/>
</a-form-item>
<a-form-item label="结题材料">
<stage-file-upload
v-model:value="form.finalMaterials"
button-text="上传结题材料"
:multiple="true"
/>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<a-tab-pane key="archive" tab="成果归档">
<div class="stage-card">
<a-alert
type="info"
show-icon
message="请以表格形式维护成果归档,可添加多条获奖记录并分别上传凭证。"
/>
<div class="archive-toolbar">
<a-button type="dashed" @click="addArchiveRecord">新增一条</a-button>
</div>
<a-table
row-key="__rowKey"
:columns="archiveColumns"
:data-source="form.archiveRecords"
:pagination="false"
:scroll="{ x: 980 }"
class="archive-table"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'awardName'">
<a-input
v-model:value="form.archiveRecords[index].awardName"
placeholder="请输入获奖名称"
/>
</template>
<template v-else-if="column.key === 'awardTime'">
<a-date-picker
v-model:value="form.archiveRecords[index].awardTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择获奖时间"
/>
</template>
<template v-else-if="column.key === 'remark'">
<a-textarea
v-model:value="form.archiveRecords[index].remark"
:rows="2"
placeholder="请输入备注说明"
/>
</template>
<template v-else-if="column.key === 'proofFile'">
<stage-file-upload
v-model:value="form.archiveRecords[index].proofFile"
button-text="上传凭证"
:multiple="false"
:limit="1"
/>
</template>
<template v-else-if="column.key === 'action'">
<a class="ele-text-danger" @click="removeArchiveRecord(index)">
删除
</a>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
<div class="drawer-footer">
<a-space>
<a-button @click="updateVisible(false)">取消</a-button>
<a-button type="primary" :loading="loading" @click="save">保存</a-button>
</a-space>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { updateWxxzxMaterials } from '@/api/gxmu/wxxzx-form';
import type {
WxxzxArchiveRecord,
WxxzxForm
} from '@/api/gxmu/wxxzx-form/model';
import StageFileUpload from './stageFileUpload.vue';
type MaterialStageKey = 'funding' | 'midterm' | 'final' | 'archive';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = withDefaults(
defineProps<{
visible: boolean;
data?: WxxzxForm | null;
initialStage?: MaterialStageKey;
}>(),
{
initialStage: 'funding'
}
);
const loading = ref(false);
const activeKey = ref<MaterialStageKey>('funding');
const createArchiveRecord = (): WxxzxArchiveRecord & { __rowKey: string } => ({
id: undefined,
year: '',
awardName: '',
awardTime: '',
remark: '',
proofFile: '',
__rowKey: `${Date.now()}-${Math.random().toString(16).slice(2)}`
});
const normalizeArchiveRecord = (record?: WxxzxArchiveRecord | null) => ({
...createArchiveRecord(),
...record,
__rowKey:
(record as any)?.__rowKey ||
`${Date.now()}-${Math.random().toString(16).slice(2)}`
});
const archiveColumns = [
{
title: '获奖名称',
dataIndex: 'awardName',
key: 'awardName',
width: 180
},
{
title: '获奖时间',
dataIndex: 'awardTime',
key: 'awardTime',
width: 160
},
{
title: '备注说明',
dataIndex: 'remark',
key: 'remark',
width: 280
},
{
title: '上传凭证',
dataIndex: 'proofFile',
key: 'proofFile',
width: 280
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right'
}
];
const createDefaultForm = () => ({
fundingBudgetFile: '',
fundingAllocationFile: '',
midtermRemark: '',
midtermMaterials: '',
finalRemark: '',
finalMaterials: '',
archiveAwards: '',
archiveProofs: '',
archiveRecords: [createArchiveRecord()]
});
const form = reactive(createDefaultForm());
watch(
() => props.visible,
(visible) => {
if (visible) {
Object.assign(form, createDefaultForm(), {
fundingBudgetFile: props.data?.fundingBudgetFile || '',
fundingAllocationFile: props.data?.fundingAllocationFile || '',
midtermRemark: props.data?.midtermRemark || '',
midtermMaterials: props.data?.midtermMaterials || '',
finalRemark: props.data?.finalRemark || '',
finalMaterials: props.data?.finalMaterials || '',
archiveAwards: props.data?.archiveAwards || '',
archiveProofs: props.data?.archiveProofs || '',
archiveRecords:
props.data?.archiveRecords?.length
? props.data.archiveRecords.map((item) =>
normalizeArchiveRecord(item)
)
: [createArchiveRecord()]
});
activeKey.value = props.initialStage;
} else {
Object.assign(form, createDefaultForm());
}
},
{ immediate: true }
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
if (!props.data?.id) {
message.error('请先保存课题申报信息');
return;
}
loading.value = true;
updateWxxzxMaterials(props.data.id, {
...form,
archiveRecords: form.archiveRecords
.map(({ __rowKey, ...item }: any) => item)
.filter(
(item) =>
item.awardName ||
item.awardTime ||
item.remark ||
item.proofFile
)
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
};
const addArchiveRecord = () => {
form.archiveRecords.push(createArchiveRecord());
};
const removeArchiveRecord = (index: number) => {
form.archiveRecords.splice(index, 1);
if (!form.archiveRecords.length) {
form.archiveRecords.push(createArchiveRecord());
}
};
</script>
<style lang="less" scoped>
@import '../../tag-tabs.less';
.gxmu-tag-tabs();
.material-header {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: linear-gradient(135deg, #f7fbff 0%, #edf5ff 100%);
}
.material-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.material-subtitle {
margin-top: 8px;
color: rgba(0, 0, 0, 0.65);
}
.stage-card {
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
background: #fff;
}
.stage-form {
margin-top: 16px;
}
.archive-toolbar {
display: flex;
justify-content: flex-end;
margin: 16px 0 12px;
}
.archive-table :deep(.ant-table-cell) {
vertical-align: top;
}
.drawer-footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
border-top: 1px solid #f0f0f0;
padding: 12px 16px;
background: #fff;
text-align: right;
}
</style>
@@ -0,0 +1,224 @@
<template>
<div class="stage-file-upload">
<a-upload
:accept="accept"
:multiple="multiple"
:showUploadList="false"
:customRequest="handleUpload"
>
<a-button class="ele-btn-icon">
<template #icon>
<UploadOutlined />
</template>
<span>{{ buttonText }}</span>
</a-button>
</a-upload>
<div v-if="fileItems.length" class="file-list">
<div
v-for="(item, index) in fileItems"
:key="`${item.url}-${index}`"
class="file-item"
>
<a :href="item.url" target="_blank" rel="noreferrer">
{{ item.name || getFileName(item.url) }}
</a>
<a class="file-remove" @click="removeFile(index)">删除</a>
</div>
</div>
<div v-else class="empty-text">{{ placeholder }}</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { uploadFile } from '@/api/system/file';
interface StageUploadFileItem {
name: string;
url: string;
}
const emit = defineEmits<{
(e: 'update:value', value: string): void;
}>();
const props = withDefaults(
defineProps<{
value?: string;
multiple?: boolean;
accept?: string;
buttonText?: string;
placeholder?: string;
limit?: number;
}>(),
{
value: '',
multiple: false,
accept:
'.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar,.jpg,.jpeg,.png',
buttonText: '上传文件',
placeholder: '暂未上传材料',
limit: 20
}
);
const fileItems = ref<StageUploadFileItem[]>([]);
const getFileName = (url?: string) => {
if (!url) {
return '未命名文件';
}
const cleanUrl = url.split('?')[0];
const fileName = cleanUrl.split('/').pop() || cleanUrl;
return decodeURIComponent(fileName);
};
const normalizeItem = (item: unknown) => {
if (typeof item === 'string') {
const url = item.trim();
if (!url) {
return null;
}
return {
name: getFileName(url),
url
};
}
if (
item &&
typeof item === 'object' &&
typeof (item as StageUploadFileItem).url === 'string'
) {
const url = (item as StageUploadFileItem).url.trim();
if (!url) {
return null;
}
return {
name:
(item as StageUploadFileItem).name?.trim() || getFileName(url),
url
};
}
return null;
};
const parseValue = (value?: string) => {
const text = value?.trim();
if (!text) {
return [];
}
if (!props.multiple) {
return normalizeItem(text) ? [normalizeItem(text)!] : [];
}
try {
const parsed = JSON.parse(text);
if (!Array.isArray(parsed)) {
return normalizeItem(text) ? [normalizeItem(text)!] : [];
}
return parsed
.map((item) => normalizeItem(item))
.filter((item): item is StageUploadFileItem => !!item);
} catch (e) {
return normalizeItem(text) ? [normalizeItem(text)!] : [];
}
};
const emitValue = (list: StageUploadFileItem[]) => {
if (!props.multiple) {
emit('update:value', list[0]?.url || '');
return;
}
emit('update:value', list.length ? JSON.stringify(list) : '');
};
const syncFileItems = (value?: string) => {
fileItems.value = parseValue(value);
};
watch(
() => props.value,
(value) => {
syncFileItems(value);
},
{ immediate: true }
);
const handleUpload = async (options: any) => {
const rawFile = options.file as File;
if (rawFile.size / 1024 / 1024 > 100) {
message.error('大小不能超过 100MB');
options.onError?.(new Error('文件过大'));
return;
}
if (props.multiple && fileItems.value.length >= props.limit) {
message.warning(`最多上传 ${props.limit} 个文件`);
options.onError?.(new Error('超出上传数量限制'));
return;
}
const hide = message.loading('上传中..', 0);
try {
const result = await uploadFile(rawFile);
const nextItem = {
name: result.name || rawFile.name,
url: String(result.url || result.downloadUrl || result.path || '')
};
const nextList = props.multiple ? [...fileItems.value, nextItem] : [nextItem];
fileItems.value = nextList;
emitValue(nextList);
message.success('上传成功');
options.onSuccess?.(result);
} catch (e: any) {
message.error(e.message || '上传失败');
options.onError?.(e);
} finally {
hide();
}
};
const removeFile = (index: number) => {
const nextList = fileItems.value.filter((_, currentIndex) => currentIndex !== index);
fileItems.value = nextList;
emitValue(nextList);
};
</script>
<style lang="less" scoped>
.file-list {
margin-top: 12px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #f0f0f0;
border-radius: 6px;
background: #fafafa;
}
.file-item + .file-item {
margin-top: 8px;
}
.file-item a:first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-remove {
color: #ff4d4f;
flex-shrink: 0;
}
.empty-text {
margin-top: 8px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+378
View File
@@ -0,0 +1,378 @@
<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>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openDocValidate"
>
<template #icon>
<FileSearchOutlined />
</template>
<span>文件校验</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入课题名称/负责人/学院"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<div class="action-links">
<a @click="openDetail(record)">详情</a>
<a v-if="!shouldHideEditByReviewList(record.reviewList)" @click="openEdit(record)">编辑</a>
<a @click="openMaterial(record, 'funding')">经费管理</a>
<a @click="openMaterial(record, 'midterm')">中期检查</a>
<a @click="openMaterial(record, 'final')">结题验收</a>
<a @click="openMaterial(record, 'archive')">成果归档</a>
<a @click="openReviewHistory(record)">审核历史</a>
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</div>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<materials-modal
v-model:visible="showMaterials"
:data="materialCurrent"
:initial-stage="materialStage"
@done="reload"
/>
<doc-validate-modal v-model:visible="showDocValidate" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="未来学术之星"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined,
FileSearchOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import DocValidateModal from './components/docValidate.vue';
import MaterialsModal from './components/materials.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportWxxzxForm,
getWxxzxForm,
userPageWxxzxForm,
removeWxxzxForm
} from '@/api/gxmu/wxxzx-form';
import type { WxxzxForm, WxxzxFormParam } from '@/api/gxmu/wxxzx-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const current = ref<WxxzxForm>();
const showDetail = ref(false);
const detailCurrent = ref<WxxzxForm | null>(null);
const showMaterials = ref(false);
const materialCurrent = ref<WxxzxForm>();
const materialStage = ref<'funding' | 'midterm' | 'final' | 'archive'>(
'funding'
);
const showDocValidate = ref(false);
const REVIEW_MODULE = 'gxmu_wxxzx_project';
const showReviewHistory = ref(false);
const reviewCurrent = ref<WxxzxForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '课题名称',
dataIndex: 'topicName',
key: 'topicName',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '课题负责人',
dataIndex: 'leader',
key: 'leader',
align: 'center',
width: 120
},
{
title: '学院、年级、班别',
dataIndex: 'collegeGradeClass',
key: 'collegeGradeClass',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
align: 'center',
width: 140
},
{
title: '指导老师',
dataIndex: 'advisor',
key: 'advisor',
align: 'center',
width: 140
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 420
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageWxxzxForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WxxzxFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const buildQueryParams = (): WxxzxFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as WxxzxFormParam;
};
const openImport = () => {
showImport.value = true;
};
const openDocValidate = () => {
showDocValidate.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(payload?.url || payload?.downloadUrl || payload?.path || '');
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportWxxzxForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: WxxzxForm) => {
if (!record?.id) {
current.value = undefined;
showEdit.value = true;
return;
}
const hide = message.loading('加载中..', 0);
getWxxzxForm(record.id)
.then((data) => {
hide();
current.value = JSON.parse(JSON.stringify(data)) as WxxzxForm;
showEdit.value = true;
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const openReviewHistory = (record: WxxzxForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: WxxzxForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const openMaterial = (
record: WxxzxForm,
stage: 'funding' | 'midterm' | 'final' | 'archive'
) => {
if (!record.id) {
message.error('缺少课题记录ID');
return;
}
materialStage.value = stage;
const hide = message.loading('加载中..', 0);
getWxxzxForm(record.id)
.then((data) => {
hide();
materialCurrent.value = JSON.parse(JSON.stringify(data)) as WxxzxForm;
showMaterials.value = true;
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const remove = (record: WxxzxForm) => {
const hide = message.loading('请求中..', 0);
removeWxxzxForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'WXXZXForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
.action-links {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px 12px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,318 @@
<template>
<a-modal
:width="900"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 100%"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<a-input v-model:value="form.name" placeholder="请输入姓名" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="性别" name="gender">
<a-select
v-model:value="form.gender"
:options="genderOptions"
placeholder="请选择性别"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="民族" name="nation">
<a-select
allow-clear
show-search
v-model:value="form.nation"
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="出生年月" name="birthMonth">
<a-date-picker
v-model:value="form.birthMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择出生年月"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="政治面貌" name="politics">
<a-select
allow-clear
show-search
v-model:value="form.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="职务" name="position">
<a-input v-model:value="form.position" placeholder="请输入职务" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="身份" name="identity">
<a-input v-model:value="form.identity" placeholder="请输入身份" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="所在团组织" name="organization">
<a-input
v-model:value="form.organization"
placeholder="请输入所在团组织"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="联系方式" name="contact">
<a-input
v-model:value="form.contact"
placeholder="请输入联系方式"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="发展团员编号" name="memberNo">
<a-input
v-model:value="form.memberNo"
placeholder="请输入发展团员编号"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="任现团内职务时间" name="currentDutyTime">
<a-date-picker
v-model:value="form.currentDutyTime"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择任现团内职务时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="担任团干部年限" name="cadreYears">
<a-input
v-model:value="form.cadreYears"
placeholder="请输入担任团干部年限"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度工作考核结果" name="assessment2024">
<a-input
v-model:value="form.assessment2024"
placeholder="请输入上一年度工作考核结果"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="成为注册志愿者时间" name="volunteerRegTime">
<a-date-picker
v-model:value="form.volunteerRegTime"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择成为注册志愿者时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="从事团干部经历" name="cadreExperience">
<a-textarea
v-model:value="form.cadreExperience"
:rows="3"
placeholder="请输入从事团干部经历"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="近五年获得校级及以上荣誉情况"
name="honorsFiveYears"
>
<a-textarea
v-model:value="form.honorsFiveYears"
:rows="3"
placeholder="请输入近五年获得校级及以上荣誉情况"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="主要事迹" name="mainStory">
<a-textarea
v-model:value="form.mainStory"
:rows="3"
placeholder="请输入主要事迹"
/>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在单位团组织意见" name="leagueOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.leagueOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入所在单位团组织意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="所在单位党组织意见" name="partyOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.partyOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入所在单位党组织意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import {
addYxgqtdgbForm,
updateYxgqtdgbForm
} from '@/api/gxmu/yxgqtdgb-form';
import type { YxgqtdgbForm } from '@/api/gxmu/yxgqtdgb-form/model';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: YxgqtdgbForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const { nationOptions, politicsOptions, genderOptions, loadNationOptions, loadPoliticsOptions } =
useGxmuFormSelectOptions();
const createDefaultForm = (): YxgqtdgbForm => ({
id: undefined,
year: '',
name: '',
gender: '',
nation: '',
birthMonth: '',
politics: '',
position: '',
identity: '',
organization: '',
contact: '',
memberNo: '',
currentDutyTime: '',
cadreYears: '',
assessment2024: '',
volunteerRegTime: '',
cadreExperience: '',
honorsFiveYears: '',
mainStory: '',
leagueOpinion: '',
partyOpinion: ''
});
const form = reactive<YxgqtdgbForm>(createDefaultForm());
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await Promise.all([loadNationOptions(), loadPoliticsOptions()]);
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value
? updateYxgqtdgbForm
: addYxgqtdgbForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,93 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="优秀共青团干部"
file-name="优秀共青团干部导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addYxgqtdgbForm } from '@/api/gxmu/yxgqtdgb-form';
import type { YxgqtdgbForm } from '@/api/gxmu/yxgqtdgb-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '姓名', key: 'name', required: true },
{ header: '性别', key: 'gender' },
{ header: '民族', key: 'nation' },
{ header: '出生年月', key: 'birthMonth' },
{ header: '政治面貌', key: 'politics' },
{ header: '职务', key: 'position' },
{ header: '身份类别', key: 'identity' },
{ header: '所在团组织', key: 'organization' },
{ header: '联系方式', key: 'contact' },
{ header: '团员编号', key: 'memberNo' },
{ header: '现任职务时间', key: 'currentDutyTime' },
{ header: '从事团的工作年限', key: 'cadreYears' },
{ header: '2024年度考核等次', key: 'assessment2024' },
{ header: '志愿汇注册时间', key: 'volunteerRegTime' },
{ header: '团干部经历', key: 'cadreExperience' },
{ header: '近五年获得校级及以上荣誉情况', key: 'honorsFiveYears' },
{ header: '主要事迹', key: 'mainStory' },
{ header: '二级团组织意见', key: 'leagueOpinion' },
{ header: '二级党组织意见', key: 'partyOpinion' }
] as const;
const sampleRow = {
年份: '2025',
姓名: '王五',
性别: '男',
民族: '汉族',
出生年月: '1999-04',
政治面貌: '中共党员',
职务: '团委副书记',
身份类别: '学生',
所在团组织: '第一临床医学院团委',
联系方式: '13800138000',
团员编号: 'TW2025001',
现任职务时间: '2023-09',
从事团的工作年限: '2',
'2024年度考核等次': '优秀',
志愿汇注册时间: '2021-03',
团干部经历: '连续两年担任学院团学骨干',
近五年获得校级及以上荣誉情况: '校优秀团干部',
主要事迹: '组织多场品牌志愿服务活动',
二级团组织意见: '',
二级党组织意见: ''
};
const createPayload = (row: Record<string, any>): YxgqtdgbForm => ({
year: String(row['年份'] || '').trim(),
name: String(row['姓名'] || '').trim(),
gender: String(row['性别'] || '').trim(),
nation: String(row['民族'] || '').trim(),
birthMonth: String(row['出生年月'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
position: String(row['职务'] || '').trim(),
identity: String(row['身份类别'] || '').trim(),
organization: String(row['所在团组织'] || '').trim(),
contact: String(row['联系方式'] || '').trim(),
memberNo: String(row['团员编号'] || '').trim(),
currentDutyTime: String(row['现任职务时间'] || '').trim(),
cadreYears: String(row['从事团的工作年限'] || '').trim(),
assessment2024: String(row['2024年度考核等次'] || '').trim(),
volunteerRegTime: String(row['志愿汇注册时间'] || '').trim(),
cadreExperience: String(row['团干部经历'] || '').trim(),
honorsFiveYears: String(row['近五年获得校级及以上荣誉情况'] || '').trim(),
mainStory: String(row['主要事迹'] || '').trim(),
leagueOpinion: String(row['二级团组织意见'] || '').trim(),
partyOpinion: String(row['二级党组织意见'] || '').trim()
});
const saveItem = (item: YxgqtdgbForm) => addYxgqtdgbForm(item);
</script>
+412
View File
@@ -0,0 +1,412 @@
<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: 3000 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入姓名/所在团组织/联系方式"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<template v-if="!shouldHideEditByReviewList(record.reviewList)">
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a @click="openReviewHistory(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>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="优秀共青团干部"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
exportYxgqtdgbForm,
userPageYxgqtdgbForm,
removeYxgqtdgbForm
} from '@/api/gxmu/yxgqtdgb-form';
import type {
YxgqtdgbForm,
YxgqtdgbFormParam
} from '@/api/gxmu/yxgqtdgb-form/model';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const current = ref<YxgqtdgbForm>();
const showDetail = ref(false);
const detailCurrent = ref<YxgqtdgbForm | null>(null);
const REVIEW_MODULE = 'gxmu_yxgqtdgb';
const showReviewHistory = ref(false);
const reviewCurrent = ref<YxgqtdgbForm | null>(null);
const exporting = ref(false);
const columns = ref<ColumnItem[]>([
{
title: '姓名',
dataIndex: 'name',
key: 'name',
align: 'center',
width: 100
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
align: 'center',
width: 80
},
{
title: '民族',
dataIndex: 'nation',
key: 'nation',
align: 'center',
width: 100
},
{
title: '出生年月',
dataIndex: 'birthMonth',
key: 'birthMonth',
align: 'center',
width: 120
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 120
},
{
title: '职务',
dataIndex: 'position',
key: 'position',
align: 'center',
width: 120
},
{
title: '身份',
dataIndex: 'identity',
key: 'identity',
align: 'center',
width: 120
},
{
title: '所在团组织',
dataIndex: 'organization',
key: 'organization',
align: 'center',
width: 180,
ellipsis: true
},
{
title: '联系方式',
dataIndex: 'contact',
key: 'contact',
align: 'center',
width: 140
},
{
title: '发展团员编号',
dataIndex: 'memberNo',
key: 'memberNo',
align: 'center',
width: 160
},
{
title: '任现团内职务时间',
dataIndex: 'currentDutyTime',
key: 'currentDutyTime',
align: 'center',
width: 160
},
{
title: '担任团干部年限',
dataIndex: 'cadreYears',
key: 'cadreYears',
align: 'center',
width: 140
},
{
title: '上一年度工作考核结果',
dataIndex: 'assessment2024',
key: 'assessment2024',
align: 'center',
width: 180
},
{
title: '成为注册志愿者时间',
dataIndex: 'volunteerRegTime',
key: 'volunteerRegTime',
align: 'center',
width: 180
},
{
title: '从事团干部经历',
dataIndex: 'cadreExperience',
key: 'cadreExperience',
align: 'center',
width: 260,
ellipsis: true
},
{
title: '近五年获得校级及以上荣誉情况',
dataIndex: 'honorsFiveYears',
key: 'honorsFiveYears',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '主要事迹',
dataIndex: 'mainStory',
key: 'mainStory',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '所在单位团组织意见',
dataIndex: 'leagueOpinion',
key: 'leagueOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '所在单位党组织意见',
dataIndex: 'partyOpinion',
key: 'partyOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 220
}
]);
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageYxgqtdgbForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as YxgqtdgbFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const buildQueryParams = (): YxgqtdgbFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as YxgqtdgbFormParam;
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(
payload?.url || payload?.downloadUrl || payload?.path || ''
);
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportYxgqtdgbForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openEdit = (record?: YxgqtdgbForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as YxgqtdgbForm)
: undefined;
showEdit.value = true;
};
const openReviewHistory = (record: YxgqtdgbForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: YxgqtdgbForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const remove = (record: YxgqtdgbForm) => {
const hide = message.loading('请求中..', 0);
removeYxgqtdgbForm(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'YXGQTDGBForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
</style>
@@ -0,0 +1,330 @@
<template>
<a-modal
:width="980"
:visible="visible"
:title="isUpdate ? '编辑' : '新增'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="24">
<a-form-item
label="年份"
name="year"
:rules="[{ required: true, message: '请选择年份' }]"
>
<a-date-picker
v-model:value="form.year"
picker="year"
value-format="YYYY"
style="width: 100%"
:disabled="!!props.fixedYear"
placeholder="请选择年份"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<a-input v-model:value="form.name" placeholder="请输入姓名" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="性别" name="gender">
<a-select
v-model:value="form.gender"
:options="genderOptions"
placeholder="请选择性别"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="民族" name="nation">
<a-select
allow-clear
show-search
v-model:value="form.nation"
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="政治面貌" name="politics">
<a-select
allow-clear
show-search
v-model:value="form.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="出生年月" name="birthMonth">
<a-date-picker
v-model:value="form.birthMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择出生年月"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="入团时间" name="joinTime">
<a-date-picker
v-model:value="form.joinTime"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择入团时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="所在学院、专业、班级" name="collegeMajorClass">
<class-cascader
v-model:value="form.collegeMajorClass"
mode="collegeClass"
placeholder="请先选择学院,再选择班级"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="职务" name="position">
<a-input v-model:value="form.position" placeholder="请输入职务" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="成为注册志愿者时间" name="volunteerRegTime">
<a-date-picker
v-model:value="form.volunteerRegTime"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择成为注册志愿者时间"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度团员教育评议等次" name="eduEval2024">
<a-input
v-model:value="form.eduEval2024"
placeholder="请输入上一年度团员教育评议等次"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="是否已登录智慧团建系统" name="smartSystem">
<a-select v-model:value="form.smartSystem" placeholder="请选择">
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="联系电话" name="contact">
<a-input
v-model:value="form.contact"
placeholder="请输入联系电话"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="累计志愿服务时长" name="totalVolunteerHours">
<a-input
v-model:value="form.totalVolunteerHours"
placeholder="请输入累计志愿服务时长"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="上一年度志愿服务时长" name="volunteerHours2024">
<a-input
v-model:value="form.volunteerHours2024"
placeholder="请输入上一年度志愿服务时长"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="发展团员编号" name="memberNo">
<a-input
v-model:value="form.memberNo"
placeholder="请输入发展团员编号"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="近五年获得荣誉情况" name="honorsFiveYears">
<a-textarea
v-model:value="form.honorsFiveYears"
:rows="3"
placeholder="请输入近五年获得荣誉情况"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="主要事迹" name="mainStory">
<a-textarea
v-model:value="form.mainStory"
:rows="3"
placeholder="请输入主要事迹"
/>
</a-form-item>
</a-col>
<!-- <a-col :span="24">-->
<!-- <a-form-item-->
<!-- label="学生社团指导教师意见/所在单位团委"-->
<!-- name="leagueOpinion"-->
<!-- >-->
<!-- <a-textarea-->
<!-- v-model:value="form.leagueOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item-->
<!-- label="业务指导单位意见/所在单位党组织"-->
<!-- name="partyOpinion"-->
<!-- >-->
<!-- <a-textarea-->
<!-- v-model:value="form.partyOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
<!-- <a-col :span="24">-->
<!-- <a-form-item label="校团委意见" name="schoolOpinion">-->
<!-- <a-textarea-->
<!-- v-model:value="form.schoolOpinion"-->
<!-- :rows="2"-->
<!-- placeholder="请输入校团委意见"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- </a-col>-->
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addYxgqtyForm, updateYxgqtyForm } from '@/api/gxmu/yxgqty-form';
import type { YxgqtyForm } from '@/api/gxmu/yxgqty-form/model';
import ClassCascader from '@/views/gxmu/components/class-cascader.vue';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: YxgqtyForm | null;
fixedYear?: string | number;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const { nationOptions, politicsOptions, genderOptions, loadAll } =
useGxmuFormSelectOptions();
const createDefaultForm = (): YxgqtyForm => ({
id: undefined,
year: '',
name: '',
gender: '',
nation: '',
politics: '',
birthMonth: '',
joinTime: '',
collegeMajorClass: '',
position: '',
volunteerRegTime: '',
eduEval2024: '',
smartSystem: '',
contact: '',
totalVolunteerHours: '',
volunteerHours2024: '',
memberNo: '',
honorsFiveYears: '',
mainStory: '',
leagueOpinion: '',
partyOpinion: '',
schoolOpinion: ''
});
const form = reactive<YxgqtyForm>(createDefaultForm());
watch(
() => props.visible,
async (visible) => {
if (visible) {
try {
await loadAll();
} catch (e: any) {
message.error(e.message || '下拉选项加载失败');
}
isUpdate.value = !!props.data?.id;
Object.assign(form, createDefaultForm(), props.data ?? {});
if (props.fixedYear) {
form.year = String(props.fixedYear);
}
} else {
formRef.value?.resetFields?.();
Object.assign(form, createDefaultForm());
}
}
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
formRef.value
?.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateYxgqtyForm : addYxgqtyForm;
saveOrUpdate({
...form,
year: props.fixedYear ? String(props.fixedYear) : form.year
})
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
</script>
<style lang="less" scoped>
@import '../../form-theme.less';
.gxmu-field-theme();
</style>
@@ -0,0 +1,96 @@
<template>
<common-import
v-bind="$props"
@done="emit('done')"
@update:visible="emit('update:visible', $event)"
module-name="优秀共青团员"
file-name="优秀共青团员导入模板.xlsx"
:template-fields="templateFields"
:sample-row="sampleRow"
:create-payload="createPayload"
:save-item="saveItem"
/>
</template>
<script lang="ts" setup>
import CommonImport from '../../components/common-import.vue';
import { addYxgqtyForm } from '@/api/gxmu/yxgqty-form';
import type { YxgqtyForm } from '@/api/gxmu/yxgqty-form/model';
defineProps<{ visible: boolean }>();
const emit = defineEmits(['done', 'update:visible']);
const templateFields = [
{ header: '年份', key: 'year', required: true },
{ header: '姓名', key: 'name', required: true },
{ header: '性别', key: 'gender' },
{ header: '民族', key: 'nation' },
{ header: '政治面貌', key: 'politics' },
{ header: '出生年月', key: 'birthMonth' },
{ header: '入团时间', key: 'joinTime' },
{ header: '所在学院、专业、班级', key: 'collegeMajorClass' },
{ header: '职务', key: 'position' },
{ header: '志愿汇注册时间', key: 'volunteerRegTime' },
{ header: '2024年度教育评议等次', key: 'eduEval2024' },
{ header: '智慧团建系统录入情况', key: 'smartSystem' },
{ header: '联系方式', key: 'contact' },
{ header: '累计志愿服务时长', key: 'totalVolunteerHours' },
{ header: '2024年度志愿服务时长', key: 'volunteerHours2024' },
{ header: '团员编号', key: 'memberNo' },
{ header: '近五年获得校级及以上荣誉情况', key: 'honorsFiveYears' },
{ header: '主要事迹', key: 'mainStory' },
{ header: '二级团组织意见', key: 'leagueOpinion' },
{ header: '二级党组织意见', key: 'partyOpinion' },
{ header: '学校团委意见', key: 'schoolOpinion' }
] as const;
const sampleRow = {
年份: '2025',
姓名: '赵六',
性别: '女',
民族: '汉族',
政治面貌: '共青团员',
出生年月: '2002-06',
入团时间: '2018-05',
'所在学院、专业、班级': '护理学院 护理学 2022级1班',
职务: '班长',
志愿汇注册时间: '2020-09',
'2024年度教育评议等次': '优秀',
智慧团建系统录入情况: '是',
联系方式: '13800138000',
累计志愿服务时长: '120',
'2024年度志愿服务时长': '40',
团员编号: 'TY2025001',
近五年获得校级及以上荣誉情况: '校优秀共青团员',
主要事迹: '积极参与志愿服务和学风建设',
二级团组织意见: '',
二级党组织意见: '',
学校团委意见: ''
};
const createPayload = (row: Record<string, any>): YxgqtyForm => ({
year: String(row['年份'] || '').trim(),
name: String(row['姓名'] || '').trim(),
gender: String(row['性别'] || '').trim(),
nation: String(row['民族'] || '').trim(),
politics: String(row['政治面貌'] || '').trim(),
birthMonth: String(row['出生年月'] || '').trim(),
joinTime: String(row['入团时间'] || '').trim(),
collegeMajorClass: String(row['所在学院、专业、班级'] || '').trim(),
position: String(row['职务'] || '').trim(),
volunteerRegTime: String(row['志愿汇注册时间'] || '').trim(),
eduEval2024: String(row['2024年度教育评议等次'] || '').trim(),
smartSystem: String(row['智慧团建系统录入情况'] || '').trim(),
contact: String(row['联系方式'] || '').trim(),
totalVolunteerHours: String(row['累计志愿服务时长'] || '').trim(),
volunteerHours2024: String(row['2024年度志愿服务时长'] || '').trim(),
memberNo: String(row['团员编号'] || '').trim(),
honorsFiveYears: String(row['近五年获得校级及以上荣誉情况'] || '').trim(),
mainStory: String(row['主要事迹'] || '').trim(),
leagueOpinion: String(row['二级团组织意见'] || '').trim(),
partyOpinion: String(row['二级党组织意见'] || '').trim(),
schoolOpinion: String(row['学校团委意见'] || '').trim()
});
const saveItem = (item: YxgqtyForm) => addYxgqtyForm(item);
</script>
+870
View File
@@ -0,0 +1,870 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<a-tabs v-model:activeKey="activeKey">
<a-tab-pane key="list" tab="名单列表">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 3800 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-button
class="gxmu-toolbar-btn gxmu-add-btn"
@click="openEdit()"
>
<template #icon>
<plus-outlined />
</template>
<span>{{ props.popup ? '立即申报' : '添加' }}</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn gxmu-import-btn"
@click="openImport"
>
<template #icon>
<UploadOutlined />
</template>
<span>导入</span>
</a-button>
<a-button
v-if="!props.popup"
class="gxmu-toolbar-btn"
:loading="exporting"
@click="exportData"
>
<template #icon>
<DownloadOutlined />
</template>
<span>导出</span>
</a-button>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="请输入姓名/学院/联系方式"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'currentReviewStatus'">
<ReviewStatusTag :review-list="record.reviewList" />
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openDetail(record)">详情</a>
<a-divider type="vertical" />
<a @click="openSheet(record)">申报表</a>
<a-divider type="vertical" />
<a @click="openReviewHistory(record)">审核历史</a>
<a-divider type="vertical" />
<template
v-if="!shouldHideEditByReviewList(record.reviewList)"
>
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
</template>
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-tab-pane>
<a-tab-pane key="form" tab="申报表">
<div class="sheet-wrap">
<div class="sheet-title">
广西医科大学2025年“优秀共青团员”申报表
</div>
<a-form layout="vertical">
<div class="sheet-table-wrap">
<table class="sheet-table">
<colgroup>
<col style="width: 10%" />
<col style="width: 15%" />
<col style="width: 10%" />
<col style="width: 15%" />
<col style="width: 12%" />
<col style="width: 18%" />
<col style="width: 10%" />
<col style="width: 10%" />
</colgroup>
<tbody>
<tr>
<td class="sheet-label">姓名</td>
<td class="sheet-value">
<a-input
v-model:value="sheetForm.name"
placeholder="请输入姓名"
/>
</td>
<td class="sheet-label">性别</td>
<td class="sheet-value">
<a-select
v-model:value="sheetForm.gender"
:options="genderOptions"
placeholder="请选择"
/>
</td>
<td class="sheet-label">民族</td>
<td class="sheet-value">
<a-select
allow-clear
show-search
v-model:value="sheetForm.nation"
:options="nationOptions"
:filter-option="filterSelectOption"
placeholder="请选择民族"
/>
</td>
<td class="sheet-label">政治面貌</td>
<td class="sheet-value">
<a-select
allow-clear
show-search
v-model:value="sheetForm.politics"
:options="politicsOptions"
:filter-option="filterSelectOption"
placeholder="请选择政治面貌"
/>
</td>
</tr>
<tr>
<td class="sheet-label">出生年月</td>
<td class="sheet-value">
<a-date-picker
v-model:value="sheetForm.birthMonth"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择出生年月"
/>
</td>
<td class="sheet-label">入团时间</td>
<td class="sheet-value">
<a-date-picker
v-model:value="sheetForm.joinTime"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择入团时间"
/>
</td>
<td class="sheet-label">所在学院、专业、班级</td>
<td class="sheet-value left" colspan="2">
<class-cascader
v-model:value="sheetForm.collegeMajorClass"
mode="collegeClass"
placeholder="请先选择学院,再选择班级"
/>
</td>
<td class="sheet-label">职务</td>
<td class="sheet-value">
<a-input
v-model:value="sheetForm.position"
placeholder="请输入职务"
/>
</td>
</tr>
<tr>
<td class="sheet-label">成为注册志愿者时间</td>
<td class="sheet-value">
<a-date-picker
v-model:value="sheetForm.volunteerRegTime"
picker="month"
value-format="YYYY-MM"
style="width: 100%"
placeholder="请选择成为注册志愿者时间"
/>
</td>
<td class="sheet-label">上一年度团员教育评议等次</td>
<td class="sheet-value">
<a-input
v-model:value="sheetForm.eduEval2024"
placeholder="如:优秀"
/>
</td>
<td class="sheet-label">是否已登录“智慧团建”系统</td>
<td class="sheet-value">
<a-select
v-model:value="sheetForm.smartSystem"
placeholder="请选择"
>
<a-select-option value="是">是</a-select-option>
<a-select-option value="否">否</a-select-option>
</a-select>
</td>
<td class="sheet-label">联系电话</td>
<td class="sheet-value">
<a-input
v-model:value="sheetForm.contact"
placeholder="请输入联系电话"
/>
</td>
</tr>
<tr>
<td class="sheet-label">累计志愿服务时长</td>
<td class="sheet-value">
<a-input
v-model:value="sheetForm.totalVolunteerHours"
placeholder="如:200小时"
/>
</td>
<td class="sheet-label">上一年度志愿服务时长</td>
<td class="sheet-value">
<a-input
v-model:value="sheetForm.volunteerHours2024"
placeholder="如:40小时"
/>
</td>
<td class="sheet-label">发展团员编号</td>
<td class="sheet-value left" colspan="3">
<a-input
v-model:value="sheetForm.memberNo"
placeholder="请输入发展团员编号"
/>
</td>
</tr>
<tr>
<td class="sheet-label">近五年获得荣誉情况</td>
<td class="sheet-value sheet-textarea" colspan="7">
<div class="sheet-tip"
>(2020年1月1日以后,所获荣誉填3-5项)</div
>
<a-textarea
v-model:value="sheetForm.honorsFiveYears"
:rows="4"
placeholder="如:2021年10月 被评为校级优秀团员"
/>
</td>
</tr>
<tr>
<td class="sheet-label">主要事迹</td>
<td class="sheet-value sheet-textarea" colspan="7">
<div class="sheet-tip"
>(围绕参评条件,突出重点,简明扼要,不超过300字)</div
>
<a-textarea
v-model:value="sheetForm.mainStory"
:rows="5"
placeholder="请输入主要事迹"
/>
</td>
</tr>
<tr>
<td class="sheet-label">意见栏</td>
<td class="sheet-value" colspan="7">
<div class="opinion-grid">
<div class="opinion-box">
<div class="opinion-title"
>学生社团指导教师意见 / 所在单位团委</div
>
<a-textarea
v-model:value="sheetForm.leagueOpinion"
:rows="5"
placeholder="请输入意见"
/>
<div class="opinion-sign">(签章) 年 月 日</div>
</div>
<div class="opinion-box">
<div class="opinion-title"
>业务指导单位意见 / 所在单位党组织</div
>
<a-textarea
v-model:value="sheetForm.partyOpinion"
:rows="5"
placeholder="请输入意见"
/>
<div class="opinion-sign">(签章) 年 月 日</div>
</div>
</div>
</td>
</tr>
<tr>
<td class="sheet-label">校团委意见</td>
<td class="sheet-value sheet-textarea" colspan="7">
<a-textarea
v-model:value="sheetForm.schoolOpinion"
:rows="4"
placeholder="请输入校团委意见"
/>
<div class="opinion-sign align-right"
>(盖章) 年 月 日</div
>
</td>
</tr>
</tbody>
</table>
</div>
<div class="sheet-actions">
<a-space>
<a-button type="primary" @click="saveSheet"
>保存表单</a-button
>
<a-button @click="resetSheet">重置</a-button>
</a-space>
</div>
</a-form>
</div>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
<edit-modal
v-model:visible="showEdit"
:data="current"
:fixed-year="props.fixedYear"
@done="reload"
/>
<import-modal v-model:visible="showImport" @done="reload" />
<ModuleDataModal
v-model:visible="showDetail"
:module="REVIEW_MODULE"
module-name="优秀共青团员"
:pk="detailCurrent?.id"
/>
<ReviewHistoryModal
v-model:visible="showReviewHistory"
:module="REVIEW_MODULE"
:pk="reviewCurrent?.id"
:review-list="reviewCurrent?.reviewList"
/>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
DownloadOutlined,
UploadOutlined,
PlusOutlined
} from '@ant-design/icons-vue';
import EditModal from './components/edit.vue';
import ImportModal from './components/import.vue';
import ReviewHistoryModal from '../components/reviewHistoryModal.vue';
import ModuleDataModal from '../reviewList/components/moduleDataModal.vue';
import ReviewStatusTag from '../components/reviewStatusTag.vue';
import ClassCascader from '@/views/gxmu/components/class-cascader.vue';
import { shouldHideEditByReviewList } from '../utils/review';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
addYxgqtyForm,
exportYxgqtyForm,
userPageYxgqtyForm,
removeYxgqtyForm,
updateYxgqtyForm
} from '@/api/gxmu/yxgqty-form';
import type {
YxgqtyForm,
YxgqtyFormParam
} from '@/api/gxmu/yxgqty-form/model';
import {
filterSelectOption,
useGxmuFormSelectOptions
} from '@/views/gxmu/useFormSelectOptions';
const props = withDefaults(
defineProps<{
popup?: boolean;
fixedYear?: string | number;
}>(),
{
popup: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const showEdit = ref(false);
const showImport = ref(false);
const showDetail = ref(false);
const current = ref<YxgqtyForm>();
const detailCurrent = ref<YxgqtyForm | null>(null);
const activeKey = ref('list');
const REVIEW_MODULE = 'gxmu_yxgqty';
const showReviewHistory = ref(false);
const reviewCurrent = ref<YxgqtyForm | null>(null);
const exporting = ref(false);
const { genderOptions, nationOptions, politicsOptions, loadAll } =
useGxmuFormSelectOptions();
const columns = ref<ColumnItem[]>([
{
title: '姓名',
dataIndex: 'name',
key: 'name',
align: 'center',
width: 100
},
{
title: '年份',
dataIndex: 'year',
key: 'year',
align: 'center',
width: 100
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
align: 'center',
width: 80
},
{
title: '民族',
dataIndex: 'nation',
key: 'nation',
align: 'center',
width: 100
},
{
title: '政治面貌',
dataIndex: 'politics',
key: 'politics',
align: 'center',
width: 120
},
{
title: '出生年月',
dataIndex: 'birthMonth',
key: 'birthMonth',
align: 'center',
width: 120
},
{
title: '入团时间',
dataIndex: 'joinTime',
key: 'joinTime',
align: 'center',
width: 120
},
{
title: '所在学院、专业、班级',
dataIndex: 'collegeMajorClass',
key: 'collegeMajorClass',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '职务',
dataIndex: 'position',
key: 'position',
align: 'center',
width: 120
},
{
title: '成为注册志愿者时间',
dataIndex: 'volunteerRegTime',
key: 'volunteerRegTime',
align: 'center',
width: 180
},
{
title: '上一年度团员教育评议等次',
dataIndex: 'eduEval2024',
key: 'eduEval2024',
align: 'center',
width: 200
},
{
title: '是否已登录智慧团建系统',
dataIndex: 'smartSystem',
key: 'smartSystem',
align: 'center',
width: 200
},
{
title: '联系电话',
dataIndex: 'contact',
key: 'contact',
align: 'center',
width: 140
},
{
title: '累计志愿服务时长',
dataIndex: 'totalVolunteerHours',
key: 'totalVolunteerHours',
align: 'center',
width: 160
},
{
title: '上一年度志愿服务时长',
dataIndex: 'volunteerHours2024',
key: 'volunteerHours2024',
align: 'center',
width: 180
},
{
title: '发展团员编号',
dataIndex: 'memberNo',
key: 'memberNo',
align: 'center',
width: 160
},
{
title: '近五年获得荣誉情况',
dataIndex: 'honorsFiveYears',
key: 'honorsFiveYears',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '主要事迹',
dataIndex: 'mainStory',
key: 'mainStory',
align: 'center',
width: 220,
ellipsis: true
},
{
title: '学生社团指导教师意见/所在单位团委',
dataIndex: 'leagueOpinion',
key: 'leagueOpinion',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '业务指导单位意见/所在单位党组织',
dataIndex: 'partyOpinion',
key: 'partyOpinion',
align: 'center',
width: 240,
ellipsis: true
},
{
title: '校团委意见',
dataIndex: 'schoolOpinion',
key: 'schoolOpinion',
align: 'center',
width: 200,
ellipsis: true
},
{
title: '当前审核状态',
dataIndex: 'reviewList',
key: 'currentReviewStatus',
align: 'center',
width: 140
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 260
}
]);
const createSheetDefault = (): YxgqtyForm => ({
id: undefined,
name: '',
gender: '',
nation: '',
politics: '',
birthMonth: '',
joinTime: '',
collegeMajorClass: '',
position: '',
volunteerRegTime: '',
eduEval2024: '',
smartSystem: '',
contact: '',
totalVolunteerHours: '',
volunteerHours2024: '',
memberNo: '',
honorsFiveYears: '',
mainStory: '',
leagueOpinion: '',
partyOpinion: '',
schoolOpinion: ''
});
const sheetForm = reactive<YxgqtyForm>(createSheetDefault());
const datasource: DatasourceFunction = ({ page, limit }) => {
return userPageYxgqtyForm({
page,
limit,
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as YxgqtyFormParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const buildQueryParams = (): YxgqtyFormParam => {
return {
year: props.fixedYear ? String(props.fixedYear) : undefined,
keywords: searchText.value.trim() || undefined
} as YxgqtyFormParam;
};
const reload = () => {
tableRef.value?.reload();
};
const openImport = () => {
showImport.value = true;
};
const resolveExportUrl = (payload: any) => {
return String(payload?.url || payload?.downloadUrl || payload?.path || '');
};
const exportData = async () => {
if (exporting.value) {
return;
}
exporting.value = true;
const hide = message.loading('导出中..', 0);
try {
const result = await exportYxgqtyForm(buildQueryParams());
const url = resolveExportUrl(result);
if (!url) {
throw new Error('导出地址缺失');
}
window.open(url, '_blank');
hide();
message.success('导出成功');
} catch (e: any) {
hide();
message.error(e.message || '导出失败');
} finally {
exporting.value = false;
}
};
const openReviewHistory = (record: YxgqtyForm) => {
reviewCurrent.value = record;
showReviewHistory.value = true;
};
const openDetail = (record: YxgqtyForm) => {
detailCurrent.value = record;
showDetail.value = true;
};
const openEdit = (record?: YxgqtyForm) => {
current.value = record
? (JSON.parse(JSON.stringify(record)) as YxgqtyForm)
: undefined;
showEdit.value = true;
};
const openSheet = (record: YxgqtyForm) => {
loadAll().catch((e: any) => {
message.error(e.message || '下拉选项加载失败');
});
current.value = JSON.parse(JSON.stringify(record)) as YxgqtyForm;
Object.assign(sheetForm, createSheetDefault(), current.value);
activeKey.value = 'form';
};
const remove = (record: YxgqtyForm) => {
const hide = message.loading('请求中..', 0);
removeYxgqtyForm(record.id)
.then((msg) => {
hide();
message.success(msg);
if (sheetForm.id === record.id) {
Object.assign(sheetForm, createSheetDefault());
activeKey.value = 'list';
}
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const saveSheet = () => {
const hide = message.loading('请求中..', 0);
const saveOrUpdate = sheetForm.id ? updateYxgqtyForm : addYxgqtyForm;
saveOrUpdate(sheetForm)
.then((msg) => {
hide();
message.success(msg);
activeKey.value = 'list';
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const resetSheet = () => {
Object.assign(sheetForm, createSheetDefault(), current.value ?? {});
};
</script>
<script lang="ts">
export default {
name: 'YXGQTYForm'
};
</script>
<style lang="less" scoped>
@import '../toolbar.less';
.sheet-wrap {
background: #fff;
border: 1px solid #d9d9d9;
padding: 16px 18px 20px;
}
.sheet-title {
text-align: center;
font-size: 20px;
font-weight: 600;
letter-spacing: 1px;
margin-bottom: 12px;
}
.sheet-table-wrap {
overflow-x: auto;
}
.sheet-table {
width: 100%;
min-width: 980px;
border-collapse: collapse;
table-layout: fixed;
}
.sheet-table td {
border: 1px solid #3b3b3b;
padding: 8px 6px;
font-size: 13px;
line-height: 1.4;
vertical-align: middle;
}
.sheet-label {
text-align: center;
font-weight: 600;
}
.sheet-value {
text-align: center;
}
.sheet-value.left {
text-align: left;
}
.sheet-value :deep(.ant-input),
.sheet-value :deep(.ant-select-selector) {
border: 0;
box-shadow: none;
padding: 0;
background: transparent;
}
.sheet-value :deep(.ant-input) {
text-align: center;
}
.sheet-value.left :deep(.ant-input) {
text-align: left;
}
.sheet-value :deep(.ant-select-selector) {
height: 24px;
min-height: 24px;
align-items: center;
}
.sheet-value :deep(.ant-select-selection-item),
.sheet-value :deep(.ant-select-selection-placeholder) {
text-align: center;
}
.sheet-textarea {
text-align: left;
}
.sheet-textarea :deep(.ant-input) {
border: 0;
box-shadow: none;
padding: 4px 6px;
background: transparent;
resize: none;
}
.sheet-tip {
color: #606266;
font-size: 12px;
margin-bottom: 6px;
}
.opinion-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.opinion-box {
border: 1px dashed #6b6b6b;
padding: 8px;
min-height: 180px;
display: flex;
flex-direction: column;
}
.opinion-title {
font-weight: 600;
margin-bottom: 8px;
}
.opinion-sign {
text-align: right;
margin-top: 8px;
color: #444;
}
.opinion-sign.align-right {
text-align: right;
}
.sheet-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
@media (max-width: 768px) {
.sheet-wrap {
padding: 12px;
}
.sheet-title {
font-size: 18px;
}
.opinion-grid {
grid-template-columns: 1fr;
}
}
</style>
File diff suppressed because it is too large Load Diff
+564
View File
@@ -0,0 +1,564 @@
<template>
<div class="challenge-stats-page">
<a-card :bordered="false" class="hero-card">
<div class="hero-wrap">
<div>
<div class="hero-kicker">CHALLENGE CUP ANALYTICS</div>
<div class="hero-title">挑战杯数据统计</div>
<div class="hero-desc">
基于挑战杯申报表汇总申报作品、参与人数、指导老师和审核通过作品等关键指标,支持按年份查看趋势与结构分布。
</div>
</div>
<a-space wrap>
<a-select
v-model:value="selectedYear"
allow-clear
style="width: 180px"
placeholder="全部年份"
@change="loadStatistics"
>
<a-select-option
v-for="item in yearOptions"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
<a-button :loading="loading" @click="loadStatistics"
>刷新数据</a-button
>
</a-space>
</div>
</a-card>
<a-spin :spinning="loading">
<a-alert
v-if="stats.awardCountDescription"
class="summary-alert"
type="info"
show-icon
:message="stats.awardCountDescription"
/>
<a-row :gutter="[16, 16]" class="stats-row">
<a-col :xs="24" :sm="12" :xl="6">
<div class="metric-card">
<div class="metric-label">申报作品数量</div>
<div class="metric-value">{{ summary.declarationCount }}</div>
<div class="metric-foot">当前筛选范围内的项目总数</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="6">
<div class="metric-card accent">
<div class="metric-label">参与申报人数</div>
<div class="metric-value">{{ summary.participantCount }}</div>
<div class="metric-foot">按团队成员去重统计</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="6">
<div class="metric-card warm">
<div class="metric-label">指导老师数量</div>
<div class="metric-value">{{ summary.advisorCount }}</div>
<div class="metric-foot">按指导老师去重统计</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="6">
<div class="metric-card success">
<div class="metric-label">获奖数量</div>
<div class="metric-value">{{ summary.awardCount }}</div>
<div class="metric-foot">当前按审核通过作品数统计</div>
</div>
</a-col>
</a-row>
<a-row :gutter="[16, 16]" class="stats-row secondary-row">
<a-col :xs="24" :sm="12" :xl="8">
<div class="mini-metric-card">
<span>申报学校数量</span>
<b>{{ summary.schoolCount }}</b>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="8">
<div class="mini-metric-card">
<span>项目类型数</span>
<b>{{ stats.typeDistribution.length }}</b>
</div>
</a-col>
<a-col :xs="24" :sm="12" :xl="8">
<div class="mini-metric-card">
<span>项目分组数</span>
<b>{{ stats.groupDistribution.length }}</b>
</div>
</a-col>
</a-row>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :xl="14">
<a-card :bordered="false" class="section-card" title="年度申报趋势">
<v-chart :option="yearChartOption" class="chart-box" autoresize />
</a-card>
</a-col>
<a-col :xs="24" :xl="10">
<a-card :bordered="false" class="section-card" title="项目类型分布">
<v-chart :option="typeChartOption" class="chart-box" autoresize />
</a-card>
</a-col>
<a-col :xs="24" :xl="12">
<a-card :bordered="false" class="section-card" title="项目分组分布">
<v-chart :option="groupChartOption" class="chart-box" autoresize />
</a-card>
</a-col>
<a-col :xs="24" :xl="12">
<a-card :bordered="false" class="section-card" title="学校申报排行">
<v-chart :option="schoolChartOption" class="chart-box" autoresize />
</a-card>
</a-col>
</a-row>
<a-card :bordered="false" class="section-card table-card">
<template #title>
<div class="table-title">
<span>分布明细</span>
<a-tag color="processing">
{{ selectedYear ? `${selectedYear} 年` : '全部年份' }}
</a-tag>
</div>
</template>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :xl="12">
<a-table
row-key="label"
size="small"
:pagination="false"
:columns="distributionColumns"
:data-source="stats.typeDistribution"
/>
</a-col>
<a-col :xs="24" :xl="12">
<a-table
row-key="label"
size="small"
:pagination="false"
:columns="distributionColumns"
:data-source="stats.groupDistribution"
/>
</a-col>
</a-row>
</a-card>
</a-spin>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { use } from 'echarts/core';
import type { EChartsCoreOption } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart, PieChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TooltipComponent
} from 'echarts/components';
import VChart from 'vue-echarts';
import { getTzbcyStatistics } from '@/api/gxmu/tzbcy-form';
import type {
TzbcyStatisticItem,
TzbcyStatisticsResult
} from '@/api/gxmu/tzbcy-form/model';
use([
CanvasRenderer,
BarChart,
PieChart,
GridComponent,
LegendComponent,
TooltipComponent
]);
const loading = ref(false);
const selectedYear = ref<number>();
const stats = reactive<TzbcyStatisticsResult>({
availableYears: [],
summary: {
declarationCount: 0,
participantCount: 0,
advisorCount: 0,
awardCount: 0,
schoolCount: 0
},
awardCountDescription: '',
yearDistribution: [],
typeDistribution: [],
groupDistribution: [],
schoolDistribution: []
});
const distributionColumns = [
{
title: '分类',
dataIndex: 'label',
key: 'label'
},
{
title: '数量',
dataIndex: 'value',
key: 'value',
width: 120
}
];
const summary = computed(() => stats.summary);
const yearOptions = computed(() =>
stats.availableYears.map((item) => ({
label: `${item} 年`,
value: item
}))
);
const createEmptyOption = (title: string): EChartsCoreOption => ({
title: {
text: title,
left: 'center',
top: 'middle',
textStyle: {
color: '#94a3b8',
fontSize: 14,
fontWeight: 500
}
}
});
const yearChartOption = computed<EChartsCoreOption>(() => {
if (!stats.yearDistribution.length) {
return createEmptyOption('暂无年度数据');
}
return {
tooltip: { trigger: 'axis' },
grid: {
left: 40,
right: 20,
top: 30,
bottom: 30
},
xAxis: {
type: 'category',
data: stats.yearDistribution.map((item) => item.label),
axisLine: {
lineStyle: { color: '#cbd5e1' }
}
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: { color: '#e2e8f0' }
}
},
series: [
{
type: 'bar',
barWidth: 36,
data: stats.yearDistribution.map((item) => item.value),
itemStyle: {
borderRadius: [10, 10, 0, 0],
color: '#2563eb'
}
}
]
};
});
const typeChartOption = computed<EChartsCoreOption>(() => {
if (!stats.typeDistribution.length) {
return createEmptyOption('暂无类型分布');
}
return {
tooltip: { trigger: 'item' },
legend: {
bottom: 0,
itemWidth: 10,
itemHeight: 10
},
series: [
{
type: 'pie',
radius: ['42%', '68%'],
center: ['50%', '42%'],
data: stats.typeDistribution.map((item) => ({
name: item.label,
value: item.value
})),
label: {
formatter: '{b}\n{c}'
}
}
]
};
});
const groupChartOption = computed<EChartsCoreOption>(() => {
if (!stats.groupDistribution.length) {
return createEmptyOption('暂无分组分布');
}
const rows = [...stats.groupDistribution].reverse();
return {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: {
left: 120,
right: 20,
top: 20,
bottom: 20
},
xAxis: {
type: 'value',
splitLine: {
lineStyle: { color: '#e2e8f0' }
}
},
yAxis: {
type: 'category',
data: rows.map((item) => item.label),
axisLine: {
lineStyle: { color: '#cbd5e1' }
}
},
series: [
{
type: 'bar',
data: rows.map((item) => item.value),
barWidth: 20,
itemStyle: {
borderRadius: [0, 10, 10, 0],
color: '#f59e0b'
}
}
]
};
});
const schoolChartOption = computed<EChartsCoreOption>(() => {
if (!stats.schoolDistribution.length) {
return createEmptyOption('暂无学校排行');
}
const rows = [...stats.schoolDistribution].reverse();
return {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: {
left: 110,
right: 20,
top: 20,
bottom: 20
},
xAxis: {
type: 'value',
splitLine: {
lineStyle: { color: '#e2e8f0' }
}
},
yAxis: {
type: 'category',
data: rows.map((item) => item.label),
axisLine: {
lineStyle: { color: '#cbd5e1' }
}
},
series: [
{
type: 'bar',
data: rows.map((item) => item.value),
barWidth: 18,
itemStyle: {
borderRadius: [0, 10, 10, 0],
color: '#10b981'
}
}
]
};
});
const assignItems = (
target: TzbcyStatisticItem[],
source?: TzbcyStatisticItem[]
) => {
target.splice(0, target.length, ...(source ?? []));
};
const loadStatistics = async () => {
loading.value = true;
try {
const data = await getTzbcyStatistics({
year: selectedYear.value
});
stats.year = data.year;
stats.availableYears = data.availableYears ?? [];
stats.summary = data.summary ?? stats.summary;
stats.awardCountDescription = data.awardCountDescription || '';
assignItems(stats.yearDistribution, data.yearDistribution);
assignItems(stats.typeDistribution, data.typeDistribution);
assignItems(stats.groupDistribution, data.groupDistribution);
assignItems(stats.schoolDistribution, data.schoolDistribution);
} catch (e: any) {
message.error(e.message || '获取挑战杯统计数据失败');
} finally {
loading.value = false;
}
};
onMounted(() => {
loadStatistics();
});
</script>
<script lang="ts">
export default {
name: 'GxmuChallengeCupStats'
};
</script>
<style scoped lang="less">
.challenge-stats-page {
min-height: 100%;
padding: 24px;
background: radial-gradient(
circle at top left,
rgba(37, 99, 235, 0.12),
transparent 24%
),
linear-gradient(180deg, #f7fbff 0%, #fffdf8 100%);
}
.hero-card,
.section-card {
border-radius: 22px;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.08);
}
.hero-wrap {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.hero-kicker {
margin-bottom: 8px;
color: #5b7cc7;
font-size: 12px;
letter-spacing: 0.22em;
}
.hero-title {
color: #102a43;
font-size: 32px;
font-weight: 700;
line-height: 1.2;
}
.hero-desc {
max-width: 760px;
margin-top: 12px;
color: #52637a;
line-height: 1.8;
}
.summary-alert {
margin-top: 16px;
border-radius: 16px;
}
.stats-row {
margin-top: 16px;
}
.secondary-row {
margin-top: 0;
}
.metric-card {
height: 100%;
padding: 22px 24px;
border-radius: 20px;
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%);
border: 1px solid #dbeafe;
}
.metric-card.accent {
background: linear-gradient(135deg, #f0fdf4 0%, #ffffff 100%);
border-color: #bbf7d0;
}
.metric-card.warm {
background: linear-gradient(135deg, #fff7ed 0%, #ffffff 100%);
border-color: #fed7aa;
}
.metric-card.success {
background: linear-gradient(135deg, #ecfeff 0%, #ffffff 100%);
border-color: #a5f3fc;
}
.metric-label {
color: #64748b;
font-size: 14px;
}
.metric-value {
margin-top: 10px;
color: #0f172a;
font-size: 34px;
font-weight: 700;
line-height: 1;
}
.metric-foot {
margin-top: 10px;
color: #94a3b8;
font-size: 13px;
}
.mini-metric-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
border-radius: 18px;
background: #ffffff;
border: 1px solid #e2e8f0;
color: #475569;
}
.mini-metric-card b {
color: #0f172a;
font-size: 24px;
}
.chart-box {
height: 320px;
}
.table-card {
margin-top: 16px;
}
.table-title {
display: flex;
align-items: center;
gap: 10px;
}
@media (max-width: 992px) {
.challenge-stats-page {
padding: 16px;
}
.hero-wrap {
flex-direction: column;
}
.hero-title {
font-size: 28px;
}
}
</style>
@@ -0,0 +1,273 @@
<template>
<div class="class-table">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1450 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space style="flex-wrap: wrap">
<a-button type="primary" @click="openEdit()">
<template #icon>
<plus-outlined />
</template>
<span>新增班级</span>
</a-button>
<a-input-search
v-model:value="searchText"
allow-clear
style="width: 220px"
placeholder="班级名称/编码"
@search="reload"
@pressEnter="reload"
/>
<a-select
v-if="showOrganizationFilter"
v-model:value="selectedOrganizationId"
allow-clear
show-search
option-filter-prop="label"
style="width: 220px"
placeholder="所属机构"
:options="organizationOptions"
/>
<a-select
v-model:value="status"
allow-clear
style="width: 120px"
placeholder="状态"
:options="statusOptions"
/>
<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' : 'red'">
{{ 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>
<edit-modal
v-model:visible="showEdit"
:data="current"
:default-college-id="currentDefaultOrganizationId"
:lock-college-id="lockOrganization"
@done="reload"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } 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 EditModal from './edit.vue';
import { pageClassInfo, removeClassInfo } from '@/api/gxmu/class';
import { listOrganizations } from '@/api/system/organization';
import type { ClassInfo, ClassInfoParam } from '@/api/gxmu/class/model';
const props = withDefaults(
defineProps<{
organizationId?: number;
showOrganizationFilter?: boolean;
lockOrganization?: boolean;
}>(),
{
showOrganizationFilter: true,
lockOrganization: false
}
);
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const selectedOrganizationId = ref<number | undefined>(props.organizationId);
const status = ref<number>();
const showEdit = ref(false);
const current = ref<ClassInfo>();
const organizationOptions = ref<{ label: string; value: number }[]>([]);
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 }
];
const columns = ref<ColumnItem[]>([
{
title: '班级名称',
dataIndex: 'className',
key: 'className',
width: 200
},
{
title: '班级编码',
dataIndex: 'classCode',
key: 'classCode',
width: 160
},
{
title: '所属机构',
dataIndex: 'collegeName',
key: 'collegeName',
width: 220
},
{
title: '年级',
dataIndex: 'gradeYear',
key: 'gradeYear',
align: 'center',
width: 100
},
{
title: '辅导员',
dataIndex: 'counselorName',
key: 'counselorName',
width: 120
},
{
title: '联系电话',
dataIndex: 'counselorPhone',
key: 'counselorPhone',
width: 150
},
{
title: '学生人数',
dataIndex: 'studentCount',
key: 'studentCount',
align: 'center',
width: 100
},
{
title: '排序',
dataIndex: 'sortNumber',
key: 'sortNumber',
align: 'center',
width: 90,
sorter: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 100
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 180,
sorter: true,
customRender: ({ text }) => (text ? toDateString(text) : '-')
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 140
}
]);
const currentDefaultOrganizationId = computed(
() => props.organizationId ?? selectedOrganizationId.value
);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageClassInfo({
...(where || {}),
...orders,
page,
limit,
keywords: searchText.value.trim() || undefined,
collegeId: currentDefaultOrganizationId.value,
status: status.value
} as ClassInfoParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const loadOrganizations = () => {
listOrganizations()
.then((list) => {
organizationOptions.value = list.map((item) => ({
label: item.organizationFullName || item.organizationName || '',
value: item.organizationId || 0
}));
})
.catch((e) => {
message.error(e.message);
});
};
const reload = () => {
tableRef.value?.reload();
};
const resetSearch = () => {
searchText.value = '';
selectedOrganizationId.value = props.organizationId;
status.value = undefined;
reload();
};
const openEdit = (record?: ClassInfo) => {
current.value = record ? { ...record } : void 0;
showEdit.value = true;
};
const remove = (record: ClassInfo) => {
const hide = message.loading('请求中..', 0);
removeClassInfo(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
watch(
() => props.organizationId,
(value) => {
selectedOrganizationId.value = value;
reload();
}
);
onMounted(() => {
loadOrganizations();
});
defineExpose({
reload
});
</script>
@@ -0,0 +1,234 @@
<template>
<a-modal
:width="700"
:visible="visible"
:title="isUpdate ? '编辑班级' : '新增班级'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="所属机构"
name="collegeId"
:rules="[{ required: true, message: '请选择所属机构' }]"
>
<a-select
v-model:value="form.collegeId"
:disabled="lockCollegeId"
show-search
option-filter-prop="label"
placeholder="请选择所属机构"
:options="organizationOptions"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="班级名称"
name="className"
:rules="[{ required: true, message: '请输入班级名称' }]"
>
<a-input
v-model:value="form.className"
placeholder="请输入班级名称"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="班级编码" name="classCode">
<a-input
v-model:value="form.classCode"
placeholder="请输入班级编码"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="年级" name="gradeYear">
<a-input-number
v-model:value="form.gradeYear"
:min="2000"
:max="2100"
:precision="0"
style="width: 100%"
placeholder="请输入年级"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="辅导员" name="counselorName">
<a-input
v-model:value="form.counselorName"
placeholder="请输入辅导员"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话" name="counselorPhone">
<a-input
v-model:value="form.counselorPhone"
placeholder="请输入联系电话"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="学生人数" name="studentCount">
<a-input-number
v-model:value="form.studentCount"
:min="0"
:precision="0"
style="width: 100%"
placeholder="请输入学生人数"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序" name="sortNumber">
<a-input-number
v-model:value="form.sortNumber"
:min="0"
:precision="0"
style="width: 100%"
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="0">停用</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="form.remark"
:rows="3"
placeholder="请输入备注"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addClassInfo, updateClassInfo } from '@/api/gxmu/class';
import { listOrganizations } from '@/api/system/organization';
import type { ClassInfo } from '@/api/gxmu/class/model';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: ClassInfo | null;
defaultCollegeId?: number;
lockCollegeId?: boolean;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const organizationOptions = ref<{ label: string; value: number }[]>([]);
const form = reactive<ClassInfo>({
id: undefined,
collegeId: undefined,
classCode: '',
className: '',
gradeYear: undefined,
counselorName: '',
counselorPhone: '',
studentCount: 0,
sortNumber: 0,
status: 1,
remark: ''
});
const resetForm = () => {
form.id = undefined;
form.collegeId = props.defaultCollegeId;
form.classCode = '';
form.className = '';
form.gradeYear = undefined;
form.counselorName = '';
form.counselorPhone = '';
form.studentCount = 0;
form.sortNumber = 0;
form.status = 1;
form.remark = '';
};
const loadOrganizationOptions = () => {
listOrganizations()
.then((list) => {
organizationOptions.value = list.map((item) => ({
label: item.organizationFullName || item.organizationName || '',
value: item.organizationId || 0
}));
})
.catch((e) => {
message.error(e.message);
});
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const request = isUpdate.value ? updateClassInfo : addClassInfo;
request({ ...form })
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
loadOrganizationOptions();
if (props.data) {
isUpdate.value = true;
Object.assign(form, {
...props.data
});
if (props.lockCollegeId && props.defaultCollegeId != null) {
form.collegeId = props.defaultCollegeId;
}
} else {
isUpdate.value = false;
resetForm();
}
} else {
resetForm();
}
}
);
</script>
@@ -0,0 +1,216 @@
<template>
<ele-modal
:width="560"
:footer="null"
title="批量导入班级"
:visible="visible"
@update:visible="updateVisible"
>
<a-spin :spinning="loading">
<a-space direction="vertical" style="width: 100%" :size="16">
<a-alert
type="info"
show-icon
message="导入说明"
description="请先下载模板,按模板填写后上传。导入时会校验所属学院是否存在,不存在则自动创建,上级ID固定为24。"
/>
<a-button block @click="downloadTemplate">下载模板</a-button>
<a-upload-dragger
accept=".xls,.xlsx"
:show-upload-list="false"
:customRequest="doUpload"
style="padding: 24px 0"
>
<p class="ant-upload-drag-icon">
<cloud-upload-outlined />
</p>
<p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
</a-upload-dragger>
</a-space>
</a-spin>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { CloudUploadOutlined } from '@ant-design/icons-vue';
import { read, utils, writeFile } from 'xlsx';
import { importClassInfo } from '@/api/gxmu/class';
import type { ClassImportItem } from '@/api/gxmu/class/model';
const TEMPLATE_HEADERS = [
'所属学院',
'班级名称',
'班级编码',
'年级',
'辅导员',
'联系电话',
'学生人数',
'排序',
'状态',
'备注'
];
const TEMPLATE_SAMPLE = [
'第一临床医学院',
'2022级临床1班',
'2022LC01',
'2022',
'张老师',
'13800138000',
'45',
'1',
'启用',
'示例数据'
];
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
defineProps<{
visible: boolean;
}>();
const loading = ref(false);
const readFileBuffer = (file: File) => {
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsArrayBuffer(file);
});
};
const toNumber = (value: unknown) => {
const text = String(value ?? '').trim();
if (!text) {
return void 0;
}
const result = Number(text);
return Number.isFinite(result) ? result : void 0;
};
const toStatus = (value: unknown) => {
const text = String(value ?? '').trim();
if (!text) {
return 1;
}
if (['1', '启用', '是', '正常'].includes(text)) {
return 1;
}
if (['0', '停用', '否', '禁用'].includes(text)) {
return 0;
}
return 1;
};
const downloadTemplate = () => {
const sheet = utils.aoa_to_sheet([TEMPLATE_HEADERS, TEMPLATE_SAMPLE]);
sheet['!cols'] = TEMPLATE_HEADERS.map((title) => ({
wch: Math.max(String(title).length + 4, 16)
}));
writeFile(
{
SheetNames: ['班级导入模板'],
Sheets: {
班级导入模板: sheet
}
},
'班级导入模板.xlsx'
);
};
const buildRows = (rows: Record<string, any>[]) => {
return rows
.map<ClassImportItem>((row) => ({
collegeName: String(row['所属学院'] || '').trim(),
className: String(row['班级名称'] || '').trim(),
classCode: String(row['班级编码'] || '').trim(),
gradeYear: toNumber(row['年级']),
counselorName: String(row['辅导员'] || '').trim(),
counselorPhone: String(row['联系电话'] || '').trim(),
studentCount: toNumber(row['学生人数']),
sortNumber: toNumber(row['排序']) ?? 0,
status: toStatus(row['状态']),
remark: String(row['备注'] || '').trim()
}))
.filter((item) =>
[
item.collegeName,
item.className,
item.classCode,
item.counselorName,
item.remark
].some((value) => !!String(value || '').trim())
);
};
const validateRows = (rows: ClassImportItem[]) => {
const errors: string[] = [];
rows.forEach((item, index) => {
const rowNo = index + 2;
if (!item.collegeName) {
errors.push(`第 ${rowNo} 行缺少所属学院`);
}
if (!item.className) {
errors.push(`第 ${rowNo} 行缺少班级名称`);
}
});
return errors;
};
const doUpload = async ({ file }: { file: File }) => {
if (
![
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
].includes(file.type) &&
!file.name.endsWith('.xls') &&
!file.name.endsWith('.xlsx')
) {
message.error('只能选择 Excel 文件');
return false;
}
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
return false;
}
loading.value = true;
try {
const buffer = await readFileBuffer(file);
const workbook = read(buffer, { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = utils.sheet_to_json<Record<string, any>>(sheet, {
defval: ''
});
if (!rows.length) {
throw new Error('导入文件内容为空');
}
const data = buildRows(rows);
if (!data.length) {
throw new Error('未识别到有效数据');
}
const errors = validateRows(data);
if (errors.length) {
throw new Error(errors.slice(0, 5).join(';'));
}
const msg = await importClassInfo(data);
message.success(msg);
updateVisible(false);
emit('done');
} catch (e: any) {
message.error(e.message || '导入失败');
} finally {
loading.value = false;
}
return false;
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
</script>
+19
View File
@@ -0,0 +1,19 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<class-table />
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import ClassTable from './components/class-table.vue';
</script>
<script lang="ts">
export default {
name: 'ClassManagement'
};
</script>
@@ -0,0 +1,181 @@
<template>
<a-modal
:width="640"
:visible="visible"
:title="isUpdate ? '编辑学院' : '新增学院'"
:confirm-loading="loading"
@update:visible="updateVisible"
@ok="save"
>
<a-form ref="formRef" :model="form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="学院名称"
name="collegeName"
:rules="[{ required: true, message: '请输入学院名称' }]"
>
<a-input
v-model:value="form.collegeName"
placeholder="请输入学院名称"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="学院编码"
name="collegeCode"
>
<a-input
v-model:value="form.collegeCode"
placeholder="请输入学院编码"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="学院简称" name="shortName">
<a-input
v-model:value="form.shortName"
placeholder="请输入学院简称"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="负责人" name="leaderName">
<a-input
v-model:value="form.leaderName"
placeholder="请输入负责人"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话" name="leaderPhone">
<a-input
v-model:value="form.leaderPhone"
placeholder="请输入联系电话"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序" name="sortNumber">
<a-input-number
v-model:value="form.sortNumber"
:min="0"
:precision="0"
style="width: 100%"
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="0">停用</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="form.remark"
:rows="3"
placeholder="请输入备注"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addCollege, updateCollege } from '@/api/gxmu/college';
import type { College } from '@/api/gxmu/college/model';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const props = defineProps<{
visible: boolean;
data?: College | null;
}>();
const formRef = ref();
const loading = ref(false);
const isUpdate = ref(false);
const form = reactive<College>({
id: undefined,
collegeCode: '',
collegeName: '',
shortName: '',
leaderName: '',
leaderPhone: '',
sortNumber: 0,
status: 1,
remark: ''
});
const resetForm = () => {
form.id = undefined;
form.collegeCode = '';
form.collegeName = '';
form.shortName = '';
form.leaderName = '';
form.leaderPhone = '';
form.sortNumber = 0;
form.status = 1;
form.remark = '';
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const request = isUpdate.value ? updateCollege : addCollege;
request({ ...form })
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
isUpdate.value = true;
Object.assign(form, {
...props.data
});
} else {
isUpdate.value = false;
resetForm();
}
} else {
resetForm();
}
}
);
</script>
+210
View File
@@ -0,0 +1,210 @@
<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 style="flex-wrap: wrap">
<a-button type="primary" @click="openEdit()">
<template #icon>
<plus-outlined />
</template>
<span>新增学院</span>
</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"
/>
<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' : 'red'">
{{ 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>
</div>
<edit-modal v-model:visible="showEdit" :data="current" @done="reload" />
</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 EditModal from './components/edit.vue';
import { pageCollege, removeCollege } from '@/api/gxmu/college';
import type { College, CollegeParam } from '@/api/gxmu/college/model';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const searchText = ref('');
const status = ref<number>();
const showEdit = ref(false);
const current = ref<College>();
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 }
];
const columns = ref<ColumnItem[]>([
{
title: '学院名称',
dataIndex: 'collegeName',
key: 'collegeName',
width: 220
},
{
title: '学院编码',
dataIndex: 'collegeCode',
key: 'collegeCode',
width: 160
},
{
title: '学院简称',
dataIndex: 'shortName',
key: 'shortName',
width: 160
},
{
title: '负责人',
dataIndex: 'leaderName',
key: 'leaderName',
width: 120
},
{
title: '联系电话',
dataIndex: 'leaderPhone',
key: 'leaderPhone',
width: 150
},
{
title: '班级数',
dataIndex: 'classCount',
key: 'classCount',
align: 'center',
width: 100
},
{
title: '排序',
dataIndex: 'sortNumber',
key: 'sortNumber',
align: 'center',
width: 90,
sorter: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 100
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 180,
sorter: true,
customRender: ({ text }) => (text ? toDateString(text) : '-')
},
{
title: '操作',
key: 'action',
fixed: 'right',
align: 'center',
width: 140
}
]);
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
return pageCollege({
...(where || {}),
...orders,
page,
limit,
keywords: searchText.value.trim() || undefined,
status: status.value
} as CollegeParam).catch((e) => {
message.error(e.message);
return { list: [], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const resetSearch = () => {
searchText.value = '';
status.value = undefined;
reload();
};
const openEdit = (record?: College) => {
current.value = record;
showEdit.value = true;
};
const remove = (record: College) => {
const hide = message.loading('请求中..', 0);
removeCollege(record.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
</script>
<script lang="ts">
export default {
name: 'CollegeManagement'
};
</script>

Some files were not shown because too many files have changed in this diff Show More