我的志愿明细:删除-提交
This commit is contained in:
parent
da16f9fdca
commit
57c6de808d
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
// 这里可以加一个全局 Toast 提示 "保存成功"
|
||||
console.warn('保存志愿成功!')
|
||||
}, 1000)
|
||||
if (showModal.value) closeModal()
|
||||
|
||||
// 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
Loading…
Reference in New Issue