feat(verification): 教練審核流程完整版——狀態機/證照送審/Admin 裁決/通知
Run Tests / test (pull_request) Successful in 2m3s
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:
@@ -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>
|
||||
Reference in New Issue
Block a user