feat(join): B1 名单加入申请 —— 学生提交 + 管理端审批(V7 存 AES 密文)
按契约「服务端转 HMAC、不落明文」设计会写不进名册:collect_roster.id_card_no 是 NOT NULL 的密文列,而申请表只有 hash 与 mask(三条替代路都被排除)。 V7 给 collect_join_request 补 id_card_cipher(与名册同一把密钥/算法),审批时原样搬进名册, 明文全程只在提交请求的内存里存在过。V7 之前的老申请明确提示重新提交,不静默建一条 永远过不了校验的名册行。 另修 F-20:自述班级不能借用姓名规范化(姓名要「张 三」→「张三」,班级名里的空格有意义)。 验证:scripts/e2e-m2-join.py 50/50。
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 名单加入申请接口(《03-接口设计》§8.7.3 ~ §8.7.5,FR-509)。
|
||||
*
|
||||
* ## 这条通道的边界(界面文案要照着说)
|
||||
*
|
||||
* 它**只解决"进名单",不绕过门禁** —— 申请期间学生仍然不能填报。
|
||||
* 所以审批通过之后,界面要告诉管理员"学生返回重新核验就能进",
|
||||
* 而不是"他已经可以填了"。
|
||||
*
|
||||
* ## 两个刻意的设计,前端要照做
|
||||
*
|
||||
* 1. **没有批量通过/一键全通过接口**(契约 §8.7.5 的取舍):身份认定环节的
|
||||
* 误操作代价极高 —— 放错一个人进名单,等于让一个不是这批的人拿到认定资格。
|
||||
* 所以这里只有逐条通过、逐条驳回(驳回还必须填原因)。
|
||||
* 2. **`notifySent` 恒为 false**:通知渠道(M5-12)还没做,
|
||||
* 服务端如实返回 false,界面必须显示"请口头/群里告知学生",不能假装通知过了。
|
||||
*/
|
||||
import http from '@/utils/http'
|
||||
|
||||
export interface JoinDupHint {
|
||||
type: string
|
||||
taskName: string
|
||||
realName: string
|
||||
}
|
||||
|
||||
export interface JoinAdminItem {
|
||||
requestId: number
|
||||
taskId: number
|
||||
taskName: string | null
|
||||
realName: string
|
||||
idCardMask: string
|
||||
phoneMask: string | null
|
||||
orgHint: string | null
|
||||
reason: string | null
|
||||
/** `0` 待审 / `1` 已通过 / `2` 已驳回 */
|
||||
status: number
|
||||
statusText: string
|
||||
createTime: string | null
|
||||
waitHours: number | null
|
||||
/** 该证件号在**其他任务**的名册里出现过(帮判断是不是本校学生) */
|
||||
duplicateHint: JoinDupHint | null
|
||||
rosterId: number | null
|
||||
}
|
||||
|
||||
export interface JoinAdminList {
|
||||
list: JoinAdminItem[]
|
||||
total: number
|
||||
counts: { pending: number, approved: number, rejected: number }
|
||||
alert: { level: number, text: string } | null
|
||||
}
|
||||
|
||||
export interface JoinListQuery {
|
||||
taskId?: number
|
||||
/** 默认 0(待审) */
|
||||
status?: number
|
||||
keyword?: string
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export function getJoinRequests(params: JoinListQuery = {}) {
|
||||
return http.get<JoinAdminList>('/roster/join-requests', params)
|
||||
}
|
||||
|
||||
export interface JoinApproveReq {
|
||||
/** 指定班级;不传则用学生的自述班级匹配组织名(匹配不上会要求手选) */
|
||||
orgId: number
|
||||
/** 修正姓名(管理员核对后可改) */
|
||||
realName?: string
|
||||
/** 修正证件号 */
|
||||
idCardNo?: string
|
||||
}
|
||||
|
||||
export interface JoinApproveResult {
|
||||
requestId: number
|
||||
status: number
|
||||
rosterId: number
|
||||
rosterCreated: boolean
|
||||
/** ⚠️ 恒为 false:通知没发出去,需要人工告知 */
|
||||
notifySent: boolean
|
||||
/** true = 已绑定,学生返回重新核验就能直接进 */
|
||||
canFillNow: boolean
|
||||
}
|
||||
|
||||
export function approveJoinRequest(id: number, data: JoinApproveReq) {
|
||||
return http.post<JoinApproveResult>(`/roster/join-requests/${id}/approve`, data)
|
||||
}
|
||||
|
||||
export interface JoinRejectReq {
|
||||
/** **必填** —— 会原样给学生看 */
|
||||
reason: string
|
||||
reasonCode?: string
|
||||
}
|
||||
|
||||
export function rejectJoinRequest(id: number, data: JoinRejectReq) {
|
||||
return http.post<{ requestId: number, status: number, notifySent: boolean }>(
|
||||
`/roster/join-requests/${id}/reject`,
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
/** 申请状态。 */
|
||||
export const JOIN_STATUS: Record<number, { text: string, color: string }> = {
|
||||
0: { text: '待审核', color: 'orange' },
|
||||
1: { text: '已通过', color: 'green' },
|
||||
2: { text: '已驳回', color: 'red' }
|
||||
}
|
||||
|
||||
/** 驳回快捷原因码(契约 §8.7.5)。 */
|
||||
export const REJECT_REASON_CODES = [
|
||||
{ code: 'NOT_THIS_SCHOOL', label: '不是本校学生' },
|
||||
{ code: 'INFO_MISMATCH', label: '信息与报名资料不符' },
|
||||
{ code: 'NOT_IN_SCOPE', label: '不在本次认定范围' },
|
||||
{ code: 'OTHER', label: '其他原因(请在说明里写清)' }
|
||||
]
|
||||
|
||||
/** 关键错误码 → 一句"接下来怎么办"。 */
|
||||
export const JOIN_ERROR_HINT: Record<number, string> = {
|
||||
40040: '这条申请已经被别人处理过了(刷新列表即可看到最新状态)。',
|
||||
40041: '名册里已经有这个证件号了 —— 可能刚被老师手工加进去,去「名单管理」核对一下。',
|
||||
40042: '已通过的申请不能驳回。要撤销请到「名单管理」里删掉那条名册项。',
|
||||
40037: '该学生申请次数已达上限(每任务 2 次)。',
|
||||
40038: '该学生已有一条待审申请。',
|
||||
40039: '该证件号已在名册中 —— 学生只是姓名打错了,让他回去重新核验即可。'
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<a-modal v-model:visible="visible" :title="mode === 'approve' ? '通过加入申请' : '驳回加入申请'"
|
||||
:width="560" :mask-closable="false" :ok-loading="saving"
|
||||
:ok-text="mode === 'approve' ? '确认通过' : '确认驳回'" @before-ok="onOk" @close="close">
|
||||
<a-alert v-if="row" type="normal" :show-icon="false" class="jd__info">
|
||||
<template #title>
|
||||
<strong>{{ row.realName }}</strong> · {{ row.idCardMask }}
|
||||
<template v-if="row.orgHint"> · 自述班级「{{ row.orgHint }}」</template>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="row.reason" class="jd__muted">申请说明:{{ row.reason }}</div>
|
||||
<div v-if="row.duplicateHint" class="jd__dup">
|
||||
提示:该证件号在「{{ row.duplicateHint.taskName }}」的名册里出现过
|
||||
(登记姓名 {{ row.duplicateHint.realName }})—— 建议核对是不是同一个人。
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- ── 通过 ── -->
|
||||
<template v-if="mode === 'approve'">
|
||||
<a-form layout="vertical" size="small" :model="approveForm">
|
||||
<a-form-item label="加入哪个班级" required>
|
||||
<a-select v-model="approveForm.orgId" placeholder="选择班级">
|
||||
<a-option v-for="c in classOptions" :key="c.id" :value="c.id">
|
||||
{{ c.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<template #extra>
|
||||
<span class="jd__muted">
|
||||
学生的自述班级只作参考 —— 班级写错会让他的材料挂到别的班,
|
||||
将来导出时目录也会分错
|
||||
</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
<a-form-item label="姓名(如与证件不符可修正)">
|
||||
<a-input v-model="approveForm.realName" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-alert type="info" :show-icon="false" class="jd__tip">
|
||||
<template #title>
|
||||
通过后:系统会把他<strong>直接加入名单并绑定</strong>,
|
||||
他返回重新核验即可开始填报,不用再输一遍姓名与证件号。
|
||||
</template>
|
||||
<template #default>
|
||||
<!-- 通知没做:如实说,不假装 -->
|
||||
<span class="jd__muted">
|
||||
⚠️ 通知渠道还没做,<strong>通过之后要人工告知学生</strong>(群里说一声)。
|
||||
</span>
|
||||
</template>
|
||||
</a-alert>
|
||||
</template>
|
||||
|
||||
<!-- ── 驳回 ── -->
|
||||
<template v-else>
|
||||
<a-form layout="vertical" size="small" :model="rejectForm">
|
||||
<a-form-item label="快捷原因">
|
||||
<a-select v-model="rejectForm.reasonCode" placeholder="选择或直接在下面写" allow-clear>
|
||||
<a-option v-for="r in REJECT_REASON_CODES" :key="r.code" :value="r.code">
|
||||
{{ r.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item :label="textLabel" :validate-status="textError ? 'error' : undefined"
|
||||
:help="textError">
|
||||
<a-textarea v-model="rejectForm.reason" :rows="3" :max-length="200" show-word-limit
|
||||
placeholder="如:名单里没有你的报名记录,请联系班主任核对报名信息" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-alert type="warning" :show-icon="false" class="jd__tip">
|
||||
<template #title>
|
||||
这段说明会<strong>原样显示给学生</strong>,所以要说清"哪里不对、该怎么办",
|
||||
不要只写"审核不通过"。
|
||||
</template>
|
||||
</a-alert>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { JoinAdminItem } from '@/apis/xt/join-request'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {
|
||||
approveJoinRequest,
|
||||
JOIN_ERROR_HINT,
|
||||
REJECT_REASON_CODES,
|
||||
rejectJoinRequest
|
||||
} from '@/apis/xt/join-request'
|
||||
import { getOrgTree } from '@/apis/xt/org'
|
||||
|
||||
/**
|
||||
* 加入申请审批弹窗(通过 / 驳回两种模式)。
|
||||
*
|
||||
* ## 为什么通过时要**手选班级**
|
||||
*
|
||||
* 学生的"自述班级"只是他自己打的字(可能写成"数控2班"、"24数控二班"…),
|
||||
* 而班级决定了:他的材料归谁审、导出时落在哪个目录。**让它自动猜**,
|
||||
* 猜错的表现是"材料挂到了别的班",而导出包打开时才会发现。
|
||||
* 服务端在"自述班级匹配到唯一组织"时会自动选上,匹配不上或撞名时必须手选。
|
||||
*
|
||||
* ## 驳回必须填原因
|
||||
*
|
||||
* 原因会原样给学生看 —— 这也是学生唯一能知道"哪里不对"的来源。
|
||||
* 只写"审核不通过"等于让他反复提交。
|
||||
*/
|
||||
defineOptions({ name: 'JoinAuditModal' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'done'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const saving = ref(false)
|
||||
const mode = ref<'approve' | 'reject'>('approve')
|
||||
const row = ref<JoinAdminItem | null>(null)
|
||||
const classOptions = ref<Array<{ id: number, label: string }>>([])
|
||||
const textError = ref('')
|
||||
|
||||
const approveForm = reactive({ orgId: undefined as number | undefined, realName: '' })
|
||||
const rejectForm = reactive({ reasonCode: '', reason: '' })
|
||||
|
||||
const textLabel = computed(() =>
|
||||
rejectForm.reasonCode && rejectForm.reasonCode !== 'OTHER'
|
||||
? '补充说明(可选)'
|
||||
: '驳回原因(必填)')
|
||||
|
||||
async function loadClasses() {
|
||||
if (classOptions.value.length) {
|
||||
return
|
||||
}
|
||||
const res = await getOrgTree()
|
||||
const out: Array<{ id: number, label: string }> = []
|
||||
const walk = (nodes: Array<Record<string, unknown>>) => {
|
||||
for (const n of nodes || []) {
|
||||
if (n.orgType === 3) {
|
||||
out.push({ id: Number(n.id), label: String(n.orgName) })
|
||||
}
|
||||
walk((n.children as Array<Record<string, unknown>>) || [])
|
||||
}
|
||||
}
|
||||
walk((res.data as unknown as Array<Record<string, unknown>>) || [])
|
||||
classOptions.value = out
|
||||
}
|
||||
|
||||
async function openApprove(r: JoinAdminItem) {
|
||||
mode.value = 'approve'
|
||||
row.value = r
|
||||
approveForm.orgId = undefined
|
||||
approveForm.realName = r.realName
|
||||
textError.value = ''
|
||||
visible.value = true
|
||||
try {
|
||||
await loadClasses()
|
||||
// 自述班级能唯一匹配上就预选(服务端也会做同样的匹配,这里只是少一次点击)
|
||||
const hit = classOptions.value.filter((c) => r.orgHint && c.label === r.orgHint)
|
||||
if (hit.length === 1) {
|
||||
approveForm.orgId = hit[0].id
|
||||
}
|
||||
} catch {
|
||||
// 组织树取不到不阻断:他可以不选,服务端会按自述班级再试一次
|
||||
}
|
||||
}
|
||||
|
||||
function openReject(r: JoinAdminItem) {
|
||||
mode.value = 'reject'
|
||||
row.value = r
|
||||
rejectForm.reasonCode = ''
|
||||
rejectForm.reason = ''
|
||||
textError.value = ''
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
async function onOk() {
|
||||
if (!row.value) {
|
||||
return false
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (mode.value === 'approve') {
|
||||
if (!approveForm.orgId) {
|
||||
Message.warning('请选择要加入的班级')
|
||||
return false
|
||||
}
|
||||
const res = await approveJoinRequest(row.value.requestId, {
|
||||
orgId: approveForm.orgId,
|
||||
realName: approveForm.realName.trim() || undefined
|
||||
})
|
||||
const d = res.data
|
||||
// 通知没发出去是**事实**,要说出来(不能让人以为学生已经收到通知了)
|
||||
Message.success({
|
||||
content: `已通过并加入名单。${d?.canFillNow ? '该学生重新核验即可填报。' : ''}${
|
||||
d?.notifySent === false ? '(通知未发出,请人工告知学生)' : ''}`,
|
||||
duration: 5000
|
||||
})
|
||||
} else {
|
||||
const needsText = !rejectForm.reasonCode || rejectForm.reasonCode === 'OTHER'
|
||||
if (needsText && !rejectForm.reason.trim()) {
|
||||
textError.value = '请写清驳回原因 —— 学生只能从这句话知道哪里不对'
|
||||
return false
|
||||
}
|
||||
await rejectJoinRequest(row.value.requestId, {
|
||||
reason: rejectForm.reason.trim() || '信息与报名资料不符',
|
||||
reasonCode: rejectForm.reasonCode || undefined
|
||||
})
|
||||
Message.success({
|
||||
content: '已驳回。(通知未发出,请人工告知学生原因)',
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
visible.value = false
|
||||
emit('done')
|
||||
return true
|
||||
} catch (e) {
|
||||
const err = e as Error & { bizCode?: number }
|
||||
const hint = err.bizCode ? JOIN_ERROR_HINT[err.bizCode] : ''
|
||||
Message.error(hint ? `${err.message}(${hint})` : err.message)
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ openApprove, openReject })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.jd {
|
||||
&__info {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&__dup {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--orange-6));
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>加入申请</template>
|
||||
<template #extra>
|
||||
<span class="join__hint">
|
||||
门禁没匹配上的人在这里申请加入;通过后他重新核验即可填报
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<a-alert v-if="alert" type="warning" class="join__alert">
|
||||
<template #title>{{ alert.text }}</template>
|
||||
</a-alert>
|
||||
|
||||
<div class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<a-button v-for="t in statusTabs" :key="String(t.value)"
|
||||
:type="statusFilter === t.value ? 'primary' : 'outline'" size="small"
|
||||
@click="setStatus(t.value)">
|
||||
{{ t.label }}
|
||||
<span v-if="t.count !== undefined" class="join__count">{{ t.count }}</span>
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
<a-space wrap>
|
||||
<a-select v-model="query.taskId" placeholder="全部任务" allow-clear size="small"
|
||||
class="join__task" :loading="taskLoading" @change="search">
|
||||
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">{{ t.taskName }}</a-option>
|
||||
</a-select>
|
||||
<a-input-search v-model="query.keyword" placeholder="搜索姓名" allow-clear
|
||||
class="join__search" @search="search" @clear="search" />
|
||||
<a-button size="small" @click="load">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
⚠️ 这一屏**没有批量通过、没有全选**(契约 §8.7.5 的取舍):
|
||||
放错一个人进名单 = 让一个不属于这批的人拿到认定资格,代价极高。
|
||||
所以只有逐条的通过/驳回,且驳回必须填原因。
|
||||
-->
|
||||
<a-table :data="list" :loading="loading" row-key="requestId" :pagination="pagination"
|
||||
:scroll="{ x: 1000 }" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="申请人" :width="170">
|
||||
<template #cell="{ record }">
|
||||
<div class="join__name">{{ record.realName }}</div>
|
||||
<div class="join__muted">{{ record.idCardMask }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="手机号" :width="130">
|
||||
<template #cell="{ record }">
|
||||
<span class="join__muted">{{ record.phoneMask || '—' }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="自述班级 / 申请说明" :width="220">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.orgHint || '—' }}</div>
|
||||
<div class="join__muted">{{ record.reason || '(未填写说明)' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="任务" :width="170" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.taskName || '—' }}</div>
|
||||
<div v-if="record.duplicateHint" class="join__dup">
|
||||
该证件号在「{{ record.duplicateHint.taskName }}」出现过
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态 / 等待" :width="120" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="JOIN_STATUS[record.status]?.color ?? 'gray'" size="small">
|
||||
{{ record.statusText || JOIN_STATUS[record.status]?.text }}
|
||||
</a-tag>
|
||||
<div class="join__muted">{{ fmtWait(record.waitHours) }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="提交时间" :width="140">
|
||||
<template #cell="{ record }">
|
||||
<span class="join__muted">{{ fmt(record.createTime) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="150" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space v-if="record.status === 0" size="mini">
|
||||
<a-link @click="onApprove(record)">通过</a-link>
|
||||
<a-link status="danger" @click="onReject(record)">驳回</a-link>
|
||||
</a-space>
|
||||
<span v-else class="join__muted">
|
||||
{{ record.rosterId ? `已入册 #${record.rosterId}` : '—' }}
|
||||
</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<JoinAuditModal ref="modalRef" @done="load" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { JoinAdminItem } from '@/apis/xt/join-request'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { getJoinRequests, JOIN_STATUS } from '@/apis/xt/join-request'
|
||||
import { getTaskList } from '@/apis/xt/task'
|
||||
import JoinAuditModal from './JoinAuditModal.vue'
|
||||
|
||||
/**
|
||||
* 加入申请审批(FR-509,原型 STEP 11)。
|
||||
*
|
||||
* ## 为什么没有批量通过
|
||||
*
|
||||
* 契约 §8.7.5 明确取舍:批量**通过**要求管理员先逐条看过,
|
||||
* 而**驳回必须逐条填原因**;且批量通过**不提供"全选"快捷键**。
|
||||
* 理由与"批量通过只带绿色项"同源 —— **身份认定环节的误操作代价极高**:
|
||||
* 放错一个人进名单,等于让一个不属于这批的人拿到认定资格,而撤销只能删名册项。
|
||||
* 所以这一屏干脆不做批量,只留逐条动作。
|
||||
*
|
||||
* ## 两个信息是用来"帮他判断"的
|
||||
*
|
||||
* - `waitHours`:等最久的排最前(列表本来就按提交时间正序)
|
||||
* - `duplicateHint`:这个证件号在**别的任务**的名册里出现过 ——
|
||||
* 这是判断"是不是本校学生"的唯一线索,比对着姓名猜可靠
|
||||
*/
|
||||
defineOptions({ name: 'Join' })
|
||||
|
||||
const loading = ref(false)
|
||||
const taskLoading = ref(false)
|
||||
const list = ref<JoinAdminItem[]>([])
|
||||
const total = ref(0)
|
||||
const counts = reactive({ pending: 0, approved: 0, rejected: 0 })
|
||||
const alert = ref<{ level: number, text: string } | null>(null)
|
||||
const statusFilter = ref<number>(0)
|
||||
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
|
||||
|
||||
const query = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
taskId: undefined as number | undefined
|
||||
})
|
||||
|
||||
const modalRef = useTemplateRef('modalRef')
|
||||
|
||||
const statusTabs = computed(() => [
|
||||
{ label: '待审核', value: 0, count: counts.pending },
|
||||
{ label: '已通过', value: 1, count: counts.approved },
|
||||
{ label: '已驳回', value: 2, count: counts.rejected }
|
||||
])
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
}))
|
||||
|
||||
function fmt(v: string | null) {
|
||||
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
function fmtWait(h: number | null) {
|
||||
if (h === null || h === undefined) {
|
||||
return '—'
|
||||
}
|
||||
return h < 24 ? `已等 ${h} 小时` : `已等 ${Math.floor(h / 24)} 天`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getJoinRequests({
|
||||
taskId: query.taskId,
|
||||
status: statusFilter.value,
|
||||
keyword: query.keyword.trim() || undefined,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
const d = res.data
|
||||
list.value = d?.list ?? []
|
||||
total.value = d?.total ?? 0
|
||||
Object.assign(counts, d?.counts ?? { pending: 0, approved: 0, rejected: 0 })
|
||||
alert.value = d?.alert ?? null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
taskLoading.value = true
|
||||
try {
|
||||
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
|
||||
} finally {
|
||||
taskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
query.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function setStatus(v: number) {
|
||||
statusFilter.value = v
|
||||
search()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
load()
|
||||
}
|
||||
|
||||
function onApprove(row: JoinAdminItem) {
|
||||
modalRef.value?.openApprove(row)
|
||||
}
|
||||
|
||||
function onReject(row: JoinAdminItem) {
|
||||
modalRef.value?.openReject(row)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.join {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&__task {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
&__search {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__dup {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user