我的志愿明细:删除-提交

This commit is contained in:
zhouwentao 2026-01-24 17:23:53 +08:00
parent da16f9fdca
commit 57c6de808d
6 changed files with 229 additions and 85 deletions

View File

@ -37,8 +37,15 @@
- **Purpose**: Displays the Privacy Policy.
- **Features**: Static content detailing data collection, usage, and protection. Includes contact information. Responsive layout.
### `src/service/api/volunteer.ts`
- **Purpose**: API definitions for volunteer filling management.
- **Methods**: `saveVolunteer`, `getVolunteerDetail`.
- **Types**: `VolunteerInfo`, `VolunteerItem`, `VolunteerDetailResponse`.
### `src/pages/simulate.vue`
- **Purpose**: Volunteer simulation page.
- **Features**:
- Panel A: Displays recommended majors list fetched from API (`/user/major/list`). Supports infinite scroll and filtering by probability.
- Panel B: Displays user's selected volunteers (Mock data for now).
- **Updated**:
- Integrated `getVolunteerDetail` and `saveVolunteer`.
- Implemented `isModified` state for unsaved changes detection.
- Added route leave protection and panel switch protection.
- Updated Panel B template to dynamic matching backend data structure.

View File

@ -16,12 +16,27 @@
- [Result]:
- Updated `UserMajorListResponse` to support `{ list: { items: [], probCount: {} } }` structure.
- Added 'stable' (较稳妥) tab to `simulate.vue`.
- Implemented dynamic update of tab counts using `probCount` from API response.
## 2026-01-23
### [Task 8] Fix TypeScript type error in `simulate.vue`
- **Time**: 2026-01-23
- **Goal**: Fix type error `Argument of type '"stable"' is not assignable to parameter of type 'TabKey'`.
- **Scope**: `src/pages/simulate.vue`
- **Preparing**: Update `TabKey` definition to include `'stable'` and remove unused `'all'`.
- **Result**: Updated `TabKey` definition to include `'stable'` and remove unused `'all'`.
## 2026-01-24
### [Task 9] Volunteer Filling Logic Perfection
- **Time**: 2026-01-24
- **Goal**: Implement real API integration for saving and fetching volunteers, with modification detection.
- **Scope**:
- `src/service/api/volunteer.ts` (New)
- `src/pages/simulate.vue` (Update)
- **Result**:
- Created `volunteer.ts` with `saveVolunteer` and `getVolunteerDetail`.
- Integrated these into `simulate.vue`.
- Added `isModified` logic for reordering and deletion.
- Added `onBeforeRouteLeave` and `watch(activePanel)` protection for unsaved changes.
- Updated Panel B template to use real API data structure.

View File

@ -16,3 +16,4 @@
- `src/pages/agreement.vue`: User agreement page.
- `src/pages/privacy-policy.vue`: Privacy policy page.
- `src/pages/simulate.vue`: Simulation and volunteer filling page.
- `src/service/api/volunteer.ts`: Volunteer management API.

View File

@ -1,8 +1,6 @@
- [x] [Task 6] User Recommended Major List API Integration <!-- id: 32 -->
- [x] Create `src/service/api/major.ts` with types and API method <!-- id: 33 -->
- [x] Integrate API in `src/pages/simulate.vue` (Panel A) <!-- id: 34 -->
- [x] Update template to display real data <!-- id: 35 -->
- [ ] [Task 8] Fix TypeScript type error in `simulate.vue` <!-- id: 36 -->
- [ ] Add `'stable'` to `TabKey` type definition <!-- id: 37 -->
- [ ] Cleanup unused `'all'` type if necessary <!-- id: 38 -->
- [x] [Task 8] Fix TypeScript type error in `simulate.vue` <!-- id: 36 -->
- [x] [Task 9] Volunteer Filling Logic Perfection <!-- id: 39 -->
- [x] Encapsulate volunteer APIs (`src/service/api/volunteer.ts`) <!-- id: 40 -->
- [x] Integrate save/detail logic in `simulate.vue` <!-- id: 41 -->
- [x] Implement modification detection and leave protection <!-- id: 42 -->

View File

@ -1,7 +1,9 @@
<script setup lang="ts">
import type { FilterState } from '~/components/FilterBar.vue'
import { onMounted, ref, watch } from 'vue'
import { onMounted, ref, watch, computed } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { getUserMajorList, type MajorItem } from '~/service/api/major'
import { saveVolunteer, getVolunteerDetail, type VolunteerItem, type VolunteerInfo } from '~/service/api/volunteer'
// --- ---
type TabKey = 'all' | 'hard' | 'risky' | 'safe' | 'stable' | '本科' | '专科' | '985/211/双一流' | '公办本科' | '民办本科'
@ -14,31 +16,6 @@ interface VolunteerTab {
max: number
}
interface YearData {
count?: number
minScore?: number
diff?: number
method?: string
}
// Panel B
interface VolunteerSchool {
id: string
name: string
tags: string[]
code: string
probability: string
statusLabel: '保' | '冲' | '稳' | '难'
calcScore: number
diffScore: number
majorName: string
requirements: string
tuition: string
majorCode: string
planCount: number
history: { [key: string]: YearData }
}
interface MajorDetail {
code: string
name: string
@ -82,21 +59,18 @@ const volunteerPlans = ref([
])
const activePanel = ref<PanelType>('market') //
// (Panel B)
// selectedMajorCodes
const myVolunteers = ref<VolunteerSchool[]>([
{ id: '2025121426', name: '志愿2025121426', code: "1001", tags: ['手动'], probability: "80.5%", statusLabel: '保', calcScore: 450, diffScore: 250, majorName: '计算机科学与技术', requirements: '物化生', tuition: '', majorCode:'2003', planCount: 10, history: { '2025': {count: 10,minScore: 1,diff: 4,method: 'q'} } },
{ id: '2025121427', name: '志愿2025121427', code: "1001", tags: ['手动'], probability: "80.5%", statusLabel: '保', calcScore: 450, diffScore: 250, majorName: '计算机科学与技术', requirements: '物化生', tuition: '', majorCode:'2003', planCount: 10, history: { '2025': {count: 10,minScore: 1,diff: 4,method: 'q'} } }
])
const myVolunteers = ref<VolunteerItem[]>([])
const originalVolunteers = ref<VolunteerItem[]>([]) //
const currentVolunteerInfo = ref<VolunteerInfo | null>(null)
// Tab
const volunteerCurrentTab = ref<TabKey>('本科')
const volunteerTabs = [
{ key: '本科', label: '本科', count: 1, max: 64 },
{ key: '专科', label: '专科', count: 2, max: 64 },
] as VolunteerTab[]
const volunteerCurrentTab = ref<string>('本科批')
const volunteerTabs = ref([
{ key: '提前批', label: '提前批', count: 0, max: 64 },
{ key: '本科批', label: '本科批', count: 0, max: 64 },
{ key: '专科批', label: '专科批', count: 0, max: 64 },
]) as any
//============= Panel A
@ -246,6 +220,20 @@ watch(currentProbTab, () => {
loadMore(true)
})
watch(activePanel, (newVal, oldVal) => {
if (oldVal === 'my-volunteers' && newVal === 'market' && isModified.value) {
const confirmRevert = window.confirm('您有未保存的志愿变动,切换回模拟填报将恢复初始状态,确认吗?')
if (confirmRevert) {
//
myVolunteers.value = JSON.parse(JSON.stringify(originalVolunteers.value))
} else {
// ( watch nextTick )
// activePanel.value watch newVal === 'my-volunteers'
activePanel.value = 'my-volunteers'
}
}
})
function handleScroll() {
@ -274,14 +262,14 @@ async function openMajorModal(school: MajorItem) {
// Mock data for now
modalMajors.value = [
{
code: '02',
name: '汉语言文学',
prob: 98,
score: 460,
diff: 45,
plan: 12,
req: '历史+不限',
tuition: '4000/年',
code: currentSchool.value!.majorCode,
name: currentSchool.value!.majorName,
prob: currentSchool.value!.enrollProbability,
score: currentSchool.value!.studentScore,
diff: 0,
plan: currentSchool.value!.planNum,
req: currentSchool.value!.mainSubjects,
tuition: currentSchool.value!.tuition,
},
]
modalLoading.value = false
@ -310,26 +298,107 @@ function getVolunteerBtnText(code: string) {
return '加入志愿单'
}
/**
* 获取志愿详情
*/
async function fetchVolunteerDetail() {
try {
const res = await getVolunteerDetail()
if (res && res.volunteer) {
currentVolunteerInfo.value = res.volunteer
// Panel B Tab
const allItems: VolunteerItem[] = []
Object.keys(res.items).forEach(batch => {
const items = res.items[batch].map(item => ({...item, batchName: batch}))
allItems.push(...items)
// Tab
const tab = volunteerTabs.value.find((t: any) => t.key === batch)
if (tab) {
tab.count = res.items[batch].length
}
})
myVolunteers.value = allItems
//
originalVolunteers.value = JSON.parse(JSON.stringify(allItems))
}
} catch (error) {
console.error('获取志愿详情失败:', error)
}
}
/**
* 过滤后的当前显示志愿Panel B
*/
const filteredVolunteers = computed(() => {
// volunteerCurrentTab
// myVolunteers
// fetchVolunteerDetail item batch
return myVolunteers.value.filter(v => (v as any).batchName === volunteerCurrentTab.value)
})
/**
* 是否修改了志愿列表拖拽排序或删除
*/
const isModified = computed(() => {
if (myVolunteers.value.length !== originalVolunteers.value.length) return true
// ID
return myVolunteers.value.some((v, i) => v.id !== originalVolunteers.value[i]?.id)
})
// 3.
async function saveVolunteers() {
if (isSaving.value)
return
const codesToSave = activePanel.value === 'market'
? selectedMajorCodes.value
: myVolunteers.value.map(v => `${v.schoolCode}_${v.majorCode}_${v.enrollmentCode}`)
if (codesToSave.length === 0 && activePanel.value === 'market') {
//
showSaveConfirm.value = false
return
}
isSaving.value = true
// API
setTimeout(() => {
console.warn('保存成功,已选专业代码:', selectedMajorCodes.value)
try {
await saveVolunteer(codesToSave)
console.warn('保存成功')
//
//
await fetchVolunteerDetail()
//
isSaving.value = false
showSaveConfirm.value = false
closeModal()
if (showModal.value) closeModal()
// Toast ""
console.warn('保存志愿成功!')
}, 1000)
// Toast
// @ts-ignore
window.$message?.success?.('保存志愿成功!')
} catch (error) {
console.error('保存失败:', error)
isSaving.value = false
}
}
//
onBeforeRouteLeave((to, from, next) => {
if (isModified.value) {
const confirmLeave = window.confirm('您有未保存的志愿变动,确定要离开吗?')
if (confirmLeave) {
next()
} else {
next(false)
}
} else {
next()
}
})
function closeModal() {
showModal.value = false
showSaveConfirm.value = false
@ -340,6 +409,7 @@ onMounted(() => {
// 1024px
if (window.innerWidth >= 1024) {
loadMore()
fetchVolunteerDetail()
}
//
@ -402,12 +472,17 @@ function handleDrop(index: number) {
if (dragStartIndex.value === null || dragStartIndex.value === index)
return
const draggedItem = filteredVolunteers.value[dragStartIndex.value]
const targetItem = filteredVolunteers.value[index]
const realStartIdx = myVolunteers.value.findIndex(v => v.id === draggedItem.id)
const realTargetIdx = myVolunteers.value.findIndex(v => v.id === targetItem.id)
if (realStartIdx === -1 || realTargetIdx === -1) return
//
const draggedItem = myVolunteers.value[dragStartIndex.value]
// 1.
myVolunteers.value.splice(dragStartIndex.value, 1)
// 2.
myVolunteers.value.splice(index, 0, draggedItem)
const [removed] = myVolunteers.value.splice(realStartIdx, 1)
myVolunteers.value.splice(realTargetIdx, 0, removed)
//
handleDragEnd()
@ -421,6 +496,7 @@ function handleDragEnd() {
}
function removeVolunteer(index: number) {
// index myVolunteers
myVolunteers.value.splice(index, 1)
}
@ -907,8 +983,11 @@ function deletePlan(planId: string) {
<!-- 提交按钮 (突出显示) -->
<div class="mx-1 h-6 w-px bg-slate-200" />
<button
class="ml-1 rounded bg-blue-600 px-5 py-2 text-sm text-white font-medium shadow-md transition-all active:scale-95 hover:bg-blue-700"
:disabled="!isModified || isSaving"
class="ml-1 rounded bg-blue-600 px-5 py-2 text-sm text-white font-medium shadow-md transition-all active:scale-95 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
@click="saveVolunteers"
>
<span v-if="isSaving" class="mr-1 inline-block h-3 w-3 animate-spin border-2 border-white/30 border-t-white rounded-full"></span>
提交志愿表
</button>
</div>
@ -958,7 +1037,7 @@ function deletePlan(planId: string) {
</thead>
<tbody class="divide-y divide-slate-200">
<tr
v-for="(vol, index) in myVolunteers" :key="vol.id"
v-for="(vol, index) in filteredVolunteers" :key="vol.id"
class="group bg-white transition-colors hover:bg-slate-50" :draggable="dragEnabledIndex === index"
@dragstart="handleDragStart($event, index)" @dragover="handleDragOver($event)"
@drop="handleDrop(index)"
@ -984,16 +1063,16 @@ function deletePlan(planId: string) {
<!-- 院校信息 -->
<td class="border-r border-slate-200 p-4 text-left">
<div class="text-base text-slate-900 font-bold">
{{ vol.name }}
{{ vol.schoolName }}
</div>
<div class="mt-1 flex gap-2">
<span
v-for="tag in vol.tags" :key="tag"
v-for="tag in (vol.tags || [vol.province, vol.schoolNature].filter(Boolean))" :key="tag"
class="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-500"
>{{ tag }}</span>
</div>
<div class="mt-1 text-xs text-slate-400">
代码: {{ vol.code }}
代码: {{ vol.schoolCode }}
</div>
</td>
@ -1003,7 +1082,7 @@ function deletePlan(planId: string) {
{{ vol.majorName }}
</div>
<div class="mt-1 text-xs text-slate-500">
{{ vol.requirements }} | {{ vol.tuition }}
{{ vol.tuition || '-' }}
</div>
<div class="text-xs text-slate-400">
代码: {{ vol.majorCode }}
@ -1013,23 +1092,23 @@ function deletePlan(planId: string) {
<!-- 分数/概率 -->
<td class="border-r border-slate-200 p-4 text-center">
<div class="text-lg text-blue-600 font-bold">
{{ vol.probability }}%
{{ vol.enrollProbability }}%
</div>
<div class="mt-1 text-xs text-slate-500">
最低 {{ vol.calcScore }}
排名 {{ vol.indexs }}
</div>
<div
class="mt-1 inline-block border rounded px-2 py-0.5 text-xs"
:class="getStatusColor(vol.statusLabel)"
:class="getStatusColor(getProbabilityLabel(vol.enrollProbability))"
>
{{ vol.statusLabel }}
{{ getProbabilityLabel(vol.enrollProbability) }}
</div>
</td>
<!-- 25省内招生 -->
<td class="border-r border-slate-200 p-4 text-center">
<div class="text-lg font-bold">
{{ vol.planCount }}
{{ vol.planNum }}
</div>
</td>

View File

@ -0,0 +1,44 @@
import request from '../request'
export interface VolunteerInfo {
id: string
volunteerName: string
scoreId: string
createTime: string
}
export interface VolunteerItem {
id: string
schoolCode: string
schoolName: string
majorName: string
province: string
schoolNature: string
planNum: number
enrollProbability: number
indexs: number
majorCode?: string
enrollmentCode?: string
tuition?: string
tags?: string[]
}
export interface VolunteerDetailResponse {
volunteer: VolunteerInfo
items: Record<string, VolunteerItem[]>
}
/**
*
* @param data schoolCode_majorCode_enrollmentCode
*/
export function saveVolunteer(data: string[]) {
return request.post('/user/volunteer/save', data)
}
/**
*
*/
export function getVolunteerDetail() {
return request.get<VolunteerDetailResponse>('/user/volunteer/detail')
}