feat(verification): 教練審核流程完整版——狀態機/證照送審/Admin 裁決/通知
Run Tests / test (pull_request) Successful in 2m3s

實作 openspec change provider-verification-workflow(25/25 tasks):
- is_verified 升級為 verification_status 四狀態機(unsubmitted/pending/
  approved/rejected),migration 自動轉換既有資料,API 以 accessor 保留
  is_verified 相容輸出
- 教練端:證照上傳(≤3 張、複用 CompressesImages)、送審、駁回原因
  顯示與重送(/coach/verification 新頁面)
- Admin 端:審核佇列、通過/駁回(原因必填)、撤銷 approved;移除
  toggle-verified 端點(防繞過狀態機);裁決後 flush 課程快取
- 通知:審核通過/駁回(站內+Email,駁回含原因)
- 可見性與預約入口改判 approved(行為等價)
- 測試 +18(ProviderVerificationTest 10、AdminVerificationTest 8),
  既有 4 個測試檔 helper 遷移;Swagger 同步
- 規格同步:provider-verification 全面改寫、admin-user-management
  toggle 段落改為移除聲明

容器內全套件 173 passed / 439 assertions。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:26:12 +08:00
parent 37140554d3
commit 43720482c4
36 changed files with 1622 additions and 274 deletions
+1
View File
@@ -23,6 +23,7 @@ async function handleLogout() {
<RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink>
<RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink>
<RouterLink to="/coach/reviews" class="text-sm hover:text-gray-300 transition">課程評價</RouterLink>
<RouterLink to="/coach/verification" class="text-sm hover:text-gray-300 transition">驗證申請</RouterLink>
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
</div>
+1
View File
@@ -30,6 +30,7 @@ const routes = [
{ path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') },
{ path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') },
{ path: 'reviews', component: () => import('../views/coach/ReviewsView.vue') },
{ path: 'verification', component: () => import('../views/coach/VerificationView.vue') },
],
},
+97 -15
View File
@@ -2,9 +2,24 @@
import { ref, onMounted } from 'vue'
import adminApi from '../../api/adminAxios'
const providers = ref([])
const loading = ref(true)
const search = ref('')
const providers = ref([])
const loading = ref(true)
const search = ref('')
const pendingOnly = ref(false)
const certsOf = ref(null) // { name, certifications } 查看證照面板
const rejectTarget = ref(null) // 駁回原因輸入面板的對象
const rejectReason = ref('')
const STATUS_META = {
unsubmitted: { label: '未送審', cls: 'bg-slate-100 text-slate-500' },
pending: { label: '待審核', cls: 'bg-amber-100 text-amber-700' },
approved: { label: '已通過', cls: 'bg-teal-100 text-teal-700' },
rejected: { label: '已駁回', cls: 'bg-rose-100 text-rose-600' },
}
function statusMeta(p) {
return STATUS_META[p.provider_profile?.verification_status] ?? STATUS_META.unsubmitted
}
async function fetchProviders() {
loading.value = true
@@ -12,12 +27,19 @@ async function fetchProviders() {
const params = {}
if (search.value) params.q = search.value
const res = await adminApi.get('/admin/providers', { params })
providers.value = res.data.data
providers.value = pendingOnly.value
? res.data.data.filter(p => p.provider_profile?.verification_status === 'pending')
: res.data.data
} finally {
loading.value = false
}
}
function togglePendingFilter() {
pendingOnly.value = !pendingOnly.value
fetchProviders()
}
async function toggleActive(p) {
try {
const res = await adminApi.put(`/admin/providers/${p.id}/toggle-active`)
@@ -25,10 +47,32 @@ async function toggleActive(p) {
} catch (e) { alert(e.response?.data?.message || '操作失敗') }
}
async function toggleVerified(p) {
async function viewCertifications(p) {
try {
const res = await adminApi.put(`/admin/providers/${p.id}/toggle-verified`)
if (p.provider_profile) p.provider_profile.is_verified = res.data.data.is_verified
const res = await adminApi.get('/admin/verifications', { params: { status: 'all' } })
const row = res.data.data.find(v => v.user_id === p.id)
certsOf.value = { name: p.name, certifications: row?.certifications ?? [] }
} catch (e) { alert(e.response?.data?.message || '讀取失敗') }
}
async function approve(p) {
try {
await adminApi.put(`/admin/verifications/${p.id}/approve`)
await fetchProviders()
} catch (e) { alert(e.response?.data?.message || '操作失敗') }
}
function openReject(p) {
rejectTarget.value = p
rejectReason.value = ''
}
async function confirmReject() {
if (!rejectReason.value.trim()) { alert('請填寫駁回原因'); return }
try {
await adminApi.put(`/admin/verifications/${rejectTarget.value.id}/reject`, { reason: rejectReason.value.trim() })
rejectTarget.value = null
await fetchProviders()
} catch (e) { alert(e.response?.data?.message || '操作失敗') }
}
@@ -44,6 +88,9 @@ onMounted(fetchProviders)
class="flex-1 border border-slate-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" />
<button @click="fetchProviders"
class="bg-slate-800 text-white px-5 py-2 rounded-lg text-sm hover:bg-slate-700 transition">搜尋</button>
<button @click="togglePendingFilter"
:class="pendingOnly ? 'bg-amber-500 text-white' : 'bg-amber-50 text-amber-700'"
class="px-5 py-2 rounded-lg text-sm hover:opacity-80 transition">待審核</button>
</div>
<div v-if="loading" class="text-center text-slate-400 py-20">載入中...</div>
@@ -67,9 +114,8 @@ onMounted(fetchProviders)
</td>
<td class="px-6 py-4 text-slate-500">{{ p.provider_profile?.business_name || '-' }}</td>
<td class="px-6 py-4 text-center">
<span :class="p.provider_profile?.is_verified ? 'bg-teal-100 text-teal-700' : 'bg-slate-100 text-slate-500'"
class="text-xs px-2 py-1 rounded-full font-medium">
{{ p.provider_profile?.is_verified ? '已驗證' : '未驗證' }}
<span :class="statusMeta(p).cls" class="text-xs px-2 py-1 rounded-full font-medium">
{{ statusMeta(p).label }}
</span>
</td>
<td class="px-6 py-4 text-center">
@@ -79,11 +125,18 @@ onMounted(fetchProviders)
</span>
</td>
<td class="px-6 py-4 text-center">
<div class="flex justify-center gap-2">
<button @click="toggleVerified(p)"
:class="p.provider_profile?.is_verified ? 'bg-slate-100 text-slate-600' : 'bg-teal-50 text-teal-700'"
class="text-xs px-3 py-1 rounded-lg hover:opacity-80 transition">
{{ p.provider_profile?.is_verified ? '取消驗證' : '驗證' }}
<div class="flex justify-center gap-2 flex-wrap">
<button @click="viewCertifications(p)"
class="text-xs px-3 py-1 rounded-lg bg-slate-50 text-slate-600 hover:opacity-80 transition">
證照
</button>
<button v-if="p.provider_profile?.verification_status === 'pending'" @click="approve(p)"
class="text-xs px-3 py-1 rounded-lg bg-teal-50 text-teal-700 hover:opacity-80 transition">
通過
</button>
<button v-if="['pending', 'approved'].includes(p.provider_profile?.verification_status)" @click="openReject(p)"
class="text-xs px-3 py-1 rounded-lg bg-rose-50 text-rose-600 hover:opacity-80 transition">
駁回
</button>
<button @click="toggleActive(p)"
:class="p.is_active ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-700'"
@@ -99,5 +152,34 @@ onMounted(fetchProviders)
</tbody>
</table>
</div>
<!-- 證照檢視面板 -->
<div v-if="certsOf" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50" @click.self="certsOf = null">
<div class="bg-white rounded-2xl p-6 max-w-lg w-full mx-4">
<h2 class="font-semibold text-slate-800 mb-4">{{ certsOf.name }} 的證照</h2>
<p v-if="certsOf.certifications.length === 0" class="text-sm text-slate-400 py-6 text-center">尚未上傳證照</p>
<div v-else class="grid grid-cols-2 gap-3">
<a v-for="c in certsOf.certifications" :key="c.id" :href="c.url" target="_blank"
class="rounded-xl overflow-hidden border hover:opacity-90 transition">
<img :src="c.url" loading="lazy" class="w-full h-36 object-cover" />
</a>
</div>
<button @click="certsOf = null" class="mt-4 w-full bg-slate-100 text-slate-600 py-2 rounded-xl text-sm hover:bg-slate-200 transition">關閉</button>
</div>
</div>
<!-- 駁回原因面板 -->
<div v-if="rejectTarget" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50" @click.self="rejectTarget = null">
<div class="bg-white rounded-2xl p-6 max-w-md w-full mx-4">
<h2 class="font-semibold text-slate-800 mb-1">駁回 {{ rejectTarget.name }}</h2>
<p class="text-xs text-slate-400 mb-4">原因將以站內通知與 Email 告知教練</p>
<textarea v-model="rejectReason" rows="3" maxlength="500" placeholder="例如:證照影像不清晰,請重新拍攝上傳"
class="w-full border border-slate-300 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300"></textarea>
<div class="flex gap-2 mt-4">
<button @click="rejectTarget = null" class="flex-1 bg-slate-100 text-slate-600 py-2 rounded-xl text-sm hover:bg-slate-200 transition">取消</button>
<button @click="confirmReject" class="flex-1 bg-rose-600 text-white py-2 rounded-xl text-sm hover:bg-rose-700 transition">確認駁回</button>
</div>
</div>
</div>
</main>
</template>
@@ -0,0 +1,138 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import coachApi from '../../api/coachAxios'
const status = ref('unsubmitted')
const reason = ref(null)
const certs = ref([])
const loading = ref(true)
const uploading = ref(false)
const submitting = ref(false)
const errorMessage = ref('')
const STATUS_META = {
unsubmitted: { label: '尚未送審', cls: 'bg-slate-100 text-slate-600', hint: '上傳教練證照後即可送出審核。通過審核前,你的課程不會公開曝光。' },
pending: { label: '審核中', cls: 'bg-amber-100 text-amber-700', hint: '已送出審核,平台將盡快處理。審核期間證照不可變更。' },
approved: { label: '已通過', cls: 'bg-teal-100 text-teal-700', hint: '你的教練資格已通過審核,課程已公開曝光並可接受預約。' },
rejected: { label: '未通過', cls: 'bg-rose-100 text-rose-700', hint: '請依駁回原因補正證照後重新送審。' },
}
const meta = computed(() => STATUS_META[status.value] ?? STATUS_META.unsubmitted)
const canEdit = computed(() => ['unsubmitted', 'rejected'].includes(status.value))
const canSubmit = computed(() => canEdit.value && certs.value.length > 0)
async function load() {
loading.value = true
try {
const res = await coachApi.get('/provider/verification')
status.value = res.data.data.verification_status
reason.value = res.data.data.rejection_reason
certs.value = res.data.data.certifications
} finally {
loading.value = false
}
}
async function uploadCert(event) {
const file = event.target.files[0]
if (!file) return
errorMessage.value = ''
uploading.value = true
try {
const form = new FormData()
form.append('image', file)
await coachApi.post('/provider/verification/certifications', form)
await load()
} catch (e) {
errorMessage.value = e.response?.data?.message || '上傳失敗'
} finally {
uploading.value = false
event.target.value = ''
}
}
async function removeCert(id) {
errorMessage.value = ''
try {
await coachApi.delete(`/provider/verification/certifications/${id}`)
await load()
} catch (e) {
errorMessage.value = e.response?.data?.message || '刪除失敗'
}
}
async function submit() {
errorMessage.value = ''
submitting.value = true
try {
await coachApi.post('/provider/verification/submit')
await load()
} catch (e) {
errorMessage.value = e.response?.data?.message || '送審失敗'
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<template>
<div class="p-6 max-w-3xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-2">驗證申請</h1>
<p class="text-gray-500 text-sm mb-6">上傳教練證照並送審通過後課程才會公開曝光</p>
<div v-if="loading" class="text-gray-400">載入中</div>
<template v-else>
<!-- 狀態卡 -->
<div class="bg-white rounded-2xl shadow p-6 mb-6">
<div class="flex items-center gap-3 mb-2">
<span class="text-sm font-medium text-gray-600">目前狀態</span>
<span :class="meta.cls" class="px-3 py-1 rounded-full text-sm font-medium">{{ meta.label }}</span>
</div>
<p class="text-sm text-gray-500">{{ meta.hint }}</p>
<div v-if="status === 'rejected' && reason" class="mt-3 bg-rose-50 border border-rose-200 rounded-xl p-3 text-sm text-rose-700">
駁回原因{{ reason }}
</div>
</div>
<!-- 證照管理 -->
<div class="bg-white rounded-2xl shadow p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-gray-700">教練證照{{ certs.length }}/3</h2>
<label v-if="canEdit && certs.length < 3"
class="text-sm bg-ocean-600 hover:bg-ocean-700 text-white px-4 py-2 rounded-xl cursor-pointer transition"
:class="{ 'opacity-50 pointer-events-none': uploading }">
{{ uploading ? '上傳中…' : ' 上傳證照' }}
<input type="file" accept="image/jpeg,image/png,image/webp" class="hidden" @change="uploadCert" />
</label>
</div>
<div v-if="certs.length === 0" class="text-sm text-gray-400 py-6 text-center">尚未上傳任何證照</div>
<div v-else class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div v-for="cert in certs" :key="cert.id" class="relative group rounded-xl overflow-hidden border">
<a :href="cert.url" target="_blank">
<img :src="cert.url" loading="lazy" class="w-full h-32 object-cover" />
</a>
<button v-if="canEdit"
@click="removeCert(cert.id)"
class="absolute top-1 right-1 bg-black/60 text-white text-xs px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 transition">
刪除
</button>
</div>
</div>
</div>
<p v-if="errorMessage" class="text-sm text-rose-600 mb-4">{{ errorMessage }}</p>
<button v-if="canEdit"
@click="submit"
:disabled="!canSubmit || submitting"
class="w-full bg-ocean-600 hover:bg-ocean-700 disabled:opacity-40 disabled:cursor-not-allowed text-white font-medium py-3 rounded-xl transition">
{{ submitting ? '送出中…' : (status === 'rejected' ? '重新送出審核' : '送出審核') }}
</button>
</template>
</div>
</template>