feat: add rider h5 MVP
This commit is contained in:
12
rider-h5/index.html
Normal file
12
rider-h5/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>邻小帮配送员</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2185
rider-h5/package-lock.json
generated
Normal file
2185
rider-h5/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
rider-h5/src/App.vue
Normal file
3
rider-h5/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
45
rider-h5/src/api/http.ts
Normal file
45
rider-h5/src/api/http.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import axios, { AxiosHeaders } from 'axios'
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export const TOKEN_KEY = 'rider_token'
|
||||
export const USER_KEY = 'rider_user'
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || ''
|
||||
})
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
if (token) {
|
||||
const headers = AxiosHeaders.from(config.headers)
|
||||
headers.set('satoken', token)
|
||||
headers.set('Authorization', token)
|
||||
config.headers = headers
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
async function unwrap<T>(request: Promise<{ data: ApiEnvelope<T> }>): Promise<T> {
|
||||
const response = await request
|
||||
if (response.data.code !== 0) {
|
||||
throw new Error(response.data.message || '请求失败')
|
||||
}
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export function apiGet<T>(url: string, params?: Record<string, unknown>) {
|
||||
return unwrap<T>(http.get(url, { params }))
|
||||
}
|
||||
|
||||
export function apiPost<T>(url: string, data?: unknown) {
|
||||
return unwrap<T>(http.post(url, data))
|
||||
}
|
||||
|
||||
export function apiPut<T>(url: string, data?: unknown) {
|
||||
return unwrap<T>(http.put(url, data))
|
||||
}
|
||||
18
rider-h5/src/api/riderTaskApi.test.ts
Normal file
18
rider-h5/src/api/riderTaskApi.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { groupTasksByStatus } from './riderTaskApi'
|
||||
|
||||
describe('groupTasksByStatus', () => {
|
||||
it('groups assigned delivering completed and exception tasks', () => {
|
||||
const grouped = groupTasksByStatus([
|
||||
{ id: 1, status: 'ASSIGNED' },
|
||||
{ id: 2, status: 'DELIVERING' },
|
||||
{ id: 3, status: 'DELIVERED' },
|
||||
{ id: 4, status: 'EXCEPTION' }
|
||||
])
|
||||
|
||||
expect(grouped.assigned).toHaveLength(1)
|
||||
expect(grouped.delivering).toHaveLength(1)
|
||||
expect(grouped.completed).toHaveLength(1)
|
||||
expect(grouped.exception).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
58
rider-h5/src/api/riderTaskApi.ts
Normal file
58
rider-h5/src/api/riderTaskApi.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { CurrentUser, DeliveryTask, FileUploadResult, RiderIncomeSummary } from '../types'
|
||||
import { apiGet, apiPost, apiPut, http } from './http'
|
||||
|
||||
export interface TaskGroups<T extends { status: string }> {
|
||||
assigned: T[]
|
||||
delivering: T[]
|
||||
completed: T[]
|
||||
exception: T[]
|
||||
canceled: T[]
|
||||
}
|
||||
|
||||
export function groupTasksByStatus<T extends { status: string }>(tasks: T[]): TaskGroups<T> {
|
||||
return {
|
||||
assigned: tasks.filter((task) => task.status === 'ASSIGNED' || task.status === 'ACCEPTED'),
|
||||
delivering: tasks.filter((task) => task.status === 'DELIVERING'),
|
||||
completed: tasks.filter((task) => task.status === 'DELIVERED' || task.status === 'COMPLETED'),
|
||||
exception: tasks.filter((task) => task.status === 'EXCEPTION'),
|
||||
canceled: tasks.filter((task) => task.status === 'CANCELED')
|
||||
}
|
||||
}
|
||||
|
||||
export function riderLogin(username: string, password: string) {
|
||||
return apiPost<CurrentUser>('/api/auth/admin-login', { username, password })
|
||||
}
|
||||
|
||||
export function listRiderTasks() {
|
||||
return apiGet<DeliveryTask[]>('/api/rider/tasks')
|
||||
}
|
||||
|
||||
export function getRiderTask(id: number) {
|
||||
return apiGet<DeliveryTask>(`/api/rider/tasks/${id}`)
|
||||
}
|
||||
|
||||
export function startRiderTask(id: number) {
|
||||
return apiPut<DeliveryTask>(`/api/rider/tasks/${id}/start`)
|
||||
}
|
||||
|
||||
export function deliverRiderTask(id: number, photoUrl?: string) {
|
||||
return apiPut<DeliveryTask>(`/api/rider/tasks/${id}/delivered`, { photoUrl })
|
||||
}
|
||||
|
||||
export function markRiderTaskException(id: number, exceptionType: string, exceptionNote: string) {
|
||||
return apiPut<DeliveryTask>(`/api/rider/tasks/${id}/exception`, { exceptionType, exceptionNote })
|
||||
}
|
||||
|
||||
export function getIncomeSummary() {
|
||||
return apiGet<RiderIncomeSummary>('/api/rider/income-summary')
|
||||
}
|
||||
|
||||
export async function uploadDeliveredPhoto(file: File) {
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
const response = await http.post<{ code: number; message: string; data: FileUploadResult }>('/api/files/upload', data)
|
||||
if (response.data.code !== 0) {
|
||||
throw new Error(response.data.message || '上传失败')
|
||||
}
|
||||
return response.data.data
|
||||
}
|
||||
1
rider-h5/src/env.d.ts
vendored
Normal file
1
rider-h5/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
6
rider-h5/src/main.ts
Normal file
6
rider-h5/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import './styles.css'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
26
rider-h5/src/router/index.ts
Normal file
26
rider-h5/src/router/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import TaskListView from '../views/TaskListView.vue'
|
||||
import TaskDetailView from '../views/TaskDetailView.vue'
|
||||
import IncomeSummaryView from '../views/IncomeSummaryView.vue'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/', component: TaskListView },
|
||||
{ path: '/tasks/:id', component: TaskDetailView },
|
||||
{ path: '/income', component: IncomeSummaryView }
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const loggedIn = Boolean(localStorage.getItem('rider_token'))
|
||||
if (to.path !== '/login' && !loggedIn) {
|
||||
return '/login'
|
||||
}
|
||||
if (to.path === '/login' && loggedIn) {
|
||||
return '/'
|
||||
}
|
||||
return true
|
||||
})
|
||||
323
rider-h5/src/styles.css
Normal file
323
rider-h5/src/styles.css
Normal file
@@ -0,0 +1,323 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: #182026;
|
||||
background: #eef2f5;
|
||||
font-family: Inter, "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.mobile-page {
|
||||
min-height: 100vh;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
background: #f7f9fb;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 56px;
|
||||
padding: 12px 16px;
|
||||
color: #fff;
|
||||
background: #176b5d;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 2px 0 0;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: #e8eef2;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: 26px 22px;
|
||||
border: 1px solid #dbe3e9;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 0 0 22px;
|
||||
color: #64717d;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
color: #586774;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea,
|
||||
.select {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ced8df;
|
||||
border-radius: 6px;
|
||||
color: #182026;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.textarea:focus,
|
||||
.select:focus {
|
||||
border-color: #176b5d;
|
||||
box-shadow: 0 0 0 3px rgba(23, 107, 93, 0.12);
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
color: #fff;
|
||||
background: #176b5d;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
color: #176b5d;
|
||||
border-color: #b8cec8;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
color: #fff;
|
||||
background: #b43b38;
|
||||
}
|
||||
|
||||
.button.text {
|
||||
min-height: 36px;
|
||||
padding: 0 8px;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.button.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
padding: 12px 14px 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
min-height: 38px;
|
||||
border: 1px solid #d6e0e6;
|
||||
border-radius: 6px;
|
||||
color: #4c5d68;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #176b5d;
|
||||
border-color: #176b5d;
|
||||
background: #e8f3f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-card,
|
||||
.panel,
|
||||
.stat-card {
|
||||
border: 1px solid #dfe7ed;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-card:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #687985;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: #b45118;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
color: #176b5d;
|
||||
background: #e8f3f0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tag.danger {
|
||||
color: #9c2d2a;
|
||||
background: #fdecea;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 36px 12px;
|
||||
text-align: center;
|
||||
color: #7a8a95;
|
||||
}
|
||||
|
||||
.detail-line {
|
||||
display: grid;
|
||||
grid-template-columns: 74px 1fr;
|
||||
gap: 10px;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid #edf1f4;
|
||||
}
|
||||
|
||||
.detail-line:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.photo-preview {
|
||||
width: 100%;
|
||||
max-height: 260px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dfe7ed;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
color: #9c2d2a;
|
||||
background: #fdecea;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
52
rider-h5/src/types.ts
Normal file
52
rider-h5/src/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface CurrentUser {
|
||||
token: string
|
||||
userId: number
|
||||
tenantId: number
|
||||
communityId: number
|
||||
roleCode: string
|
||||
}
|
||||
|
||||
export type DeliveryTaskStatus =
|
||||
| 'ASSIGNED'
|
||||
| 'ACCEPTED'
|
||||
| 'DELIVERING'
|
||||
| 'DELIVERED'
|
||||
| 'COMPLETED'
|
||||
| 'EXCEPTION'
|
||||
| 'CANCELED'
|
||||
| string
|
||||
|
||||
export interface DeliveryTask {
|
||||
id: number
|
||||
bizType: string
|
||||
bizOrderId: number
|
||||
bizOrderNo: string
|
||||
riderId: number
|
||||
riderName: string
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
address?: string
|
||||
amountCent: number
|
||||
remark?: string
|
||||
status: DeliveryTaskStatus
|
||||
deliveredPhotoUrl?: string
|
||||
exceptionType?: string
|
||||
exceptionNote?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface RiderIncomeSummary {
|
||||
assignedCount: number
|
||||
deliveringCount: number
|
||||
deliveredCount: number
|
||||
estimatedIncomeCent: number
|
||||
}
|
||||
|
||||
export interface FileUploadResult {
|
||||
originalFilename: string
|
||||
contentType: string
|
||||
size: number
|
||||
objectKey: string
|
||||
url: string
|
||||
}
|
||||
30
rider-h5/src/utils/format.ts
Normal file
30
rider-h5/src/utils/format.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export function yuan(cent?: number) {
|
||||
return ((cent || 0) / 100).toFixed(2)
|
||||
}
|
||||
|
||||
export function statusLabel(status?: string) {
|
||||
const labels: Record<string, string> = {
|
||||
ASSIGNED: '待配送',
|
||||
ACCEPTED: '待配送',
|
||||
DELIVERING: '配送中',
|
||||
DELIVERED: '已送达',
|
||||
COMPLETED: '已完成',
|
||||
EXCEPTION: '异常',
|
||||
CANCELED: '已取消'
|
||||
}
|
||||
return status ? labels[status] || status : '-'
|
||||
}
|
||||
|
||||
export function bizTypeLabel(type?: string) {
|
||||
const labels: Record<string, string> = {
|
||||
GOODS_ORDER: '商品订单',
|
||||
EXPRESS_ORDER: '快递代取',
|
||||
GROUP_BUY_ORDER: '团购订单'
|
||||
}
|
||||
return type ? labels[type] || type : '-'
|
||||
}
|
||||
|
||||
export function shortTime(value?: string) {
|
||||
if (!value) return '-'
|
||||
return value.replace('T', ' ').slice(5, 16)
|
||||
}
|
||||
68
rider-h5/src/views/IncomeSummaryView.vue
Normal file
68
rider-h5/src/views/IncomeSummaryView.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getIncomeSummary } from '../api/riderTaskApi'
|
||||
import type { RiderIncomeSummary } from '../types'
|
||||
import { yuan } from '../utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const summary = ref<RiderIncomeSummary | null>(null)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
summary.value = await getIncomeSummary()
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '收入加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mobile-page">
|
||||
<header class="page-header">
|
||||
<button class="button text" type="button" @click="router.back()">返回</button>
|
||||
<div>
|
||||
<h1 class="page-title">收入统计</h1>
|
||||
<p class="page-subtitle">按已送达任务预估</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="content">
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="panel muted">加载中</div>
|
||||
|
||||
<template v-if="summary">
|
||||
<section class="panel">
|
||||
<div class="muted">预计收入</div>
|
||||
<div class="stat-value price">¥{{ yuan(summary.estimatedIncomeCent) }}</div>
|
||||
</section>
|
||||
<section class="stat-grid">
|
||||
<div class="stat-card">
|
||||
<div class="muted">待配送</div>
|
||||
<div class="stat-value">{{ summary.assignedCount }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="muted">配送中</div>
|
||||
<div class="stat-value">{{ summary.deliveringCount }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="muted">已送达</div>
|
||||
<div class="stat-value">{{ summary.deliveredCount }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="muted">单均补贴</div>
|
||||
<div class="stat-value">3</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
53
rider-h5/src/views/LoginView.vue
Normal file
53
rider-h5/src/views/LoginView.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { TOKEN_KEY, USER_KEY } from '../api/http'
|
||||
import { riderLogin } from '../api/riderTaskApi'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const form = reactive({
|
||||
username: 'rider',
|
||||
password: 'rider123'
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const user = await riderLogin(form.username, form.password)
|
||||
localStorage.setItem(TOKEN_KEY, user.token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
router.replace('/')
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-panel">
|
||||
<h1 class="login-title">邻小帮配送员</h1>
|
||||
<p class="login-subtitle">查看任务、更新配送状态、上传送达照片</p>
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<label class="field">
|
||||
<span class="field-label">账号</span>
|
||||
<input v-model="form.username" class="input" autocomplete="username" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">密码</span>
|
||||
<input v-model="form.password" class="input" type="password" autocomplete="current-password" />
|
||||
</label>
|
||||
<button class="button primary full" :disabled="loading" type="submit">
|
||||
{{ loading ? '登录中' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
196
rider-h5/src/views/TaskDetailView.vue
Normal file
196
rider-h5/src/views/TaskDetailView.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
deliverRiderTask,
|
||||
getRiderTask,
|
||||
markRiderTaskException,
|
||||
startRiderTask,
|
||||
uploadDeliveredPhoto
|
||||
} from '../api/riderTaskApi'
|
||||
import type { DeliveryTask } from '../types'
|
||||
import { bizTypeLabel, shortTime, statusLabel, yuan } from '../utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const taskId = Number(route.params.id)
|
||||
const task = ref<DeliveryTask | null>(null)
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const photoUrl = ref('')
|
||||
const exceptionType = ref('CONTACT_FAILED')
|
||||
const exceptionNote = ref('')
|
||||
|
||||
const canStart = computed(() => task.value?.status === 'ASSIGNED' || task.value?.status === 'ACCEPTED')
|
||||
const canDeliver = computed(() => task.value?.status === 'DELIVERING')
|
||||
const canMarkException = computed(() => !['DELIVERED', 'COMPLETED', 'CANCELED'].includes(task.value?.status || ''))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
task.value = await getRiderTask(taskId)
|
||||
photoUrl.value = task.value.deliveredPhotoUrl || ''
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '任务加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startTask() {
|
||||
await runAction(async () => {
|
||||
task.value = await startRiderTask(taskId)
|
||||
})
|
||||
}
|
||||
|
||||
async function deliverTask() {
|
||||
if (!photoUrl.value) {
|
||||
errorMessage.value = '请先上传送达照片'
|
||||
return
|
||||
}
|
||||
await runAction(async () => {
|
||||
task.value = await deliverRiderTask(taskId, photoUrl.value)
|
||||
})
|
||||
}
|
||||
|
||||
async function markException() {
|
||||
if (!exceptionNote.value.trim()) {
|
||||
errorMessage.value = '请填写异常说明'
|
||||
return
|
||||
}
|
||||
await runAction(async () => {
|
||||
task.value = await markRiderTaskException(taskId, exceptionType.value, exceptionNote.value.trim())
|
||||
})
|
||||
}
|
||||
|
||||
async function runAction(action: () => Promise<void>) {
|
||||
actionLoading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '操作失败'
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePhotoChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = await uploadDeliveredPhoto(file)
|
||||
photoUrl.value = result.url
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '上传失败'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function callUser() {
|
||||
if (task.value?.contactPhone) {
|
||||
window.location.href = `tel:${task.value.contactPhone}`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mobile-page">
|
||||
<header class="page-header">
|
||||
<button class="button text" type="button" @click="router.back()">返回</button>
|
||||
<div>
|
||||
<h1 class="page-title">任务详情</h1>
|
||||
<p class="page-subtitle">{{ task ? bizTypeLabel(task.bizType) : '配送履约' }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="content">
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="panel muted">加载中</div>
|
||||
|
||||
<template v-if="task">
|
||||
<section class="panel">
|
||||
<div class="row between">
|
||||
<div>
|
||||
<div class="strong">{{ bizTypeLabel(task.bizType) }}</div>
|
||||
<div class="muted">{{ task.bizOrderNo }}</div>
|
||||
</div>
|
||||
<span class="tag" :class="{ danger: task.status === 'EXCEPTION' }">{{ statusLabel(task.status) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="detail-line">
|
||||
<span class="muted">联系人</span>
|
||||
<span>{{ task.contactName || '居民' }}</span>
|
||||
</div>
|
||||
<div class="detail-line">
|
||||
<span class="muted">手机号</span>
|
||||
<span>{{ task.contactPhone || '-' }}</span>
|
||||
</div>
|
||||
<div class="detail-line">
|
||||
<span class="muted">地址</span>
|
||||
<span>{{ task.address || '-' }}</span>
|
||||
</div>
|
||||
<div class="detail-line">
|
||||
<span class="muted">金额</span>
|
||||
<span class="price">¥{{ yuan(task.amountCent) }}</span>
|
||||
</div>
|
||||
<div class="detail-line">
|
||||
<span class="muted">备注</span>
|
||||
<span>{{ task.remark || '无' }}</span>
|
||||
</div>
|
||||
<div class="detail-line">
|
||||
<span class="muted">创建</span>
|
||||
<span>{{ shortTime(task.createdAt) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="canStart || canDeliver" class="panel stack">
|
||||
<label v-if="canDeliver" class="field">
|
||||
<span class="field-label">送达照片</span>
|
||||
<input class="input" type="file" accept="image/*" :disabled="uploading" @change="handlePhotoChange" />
|
||||
</label>
|
||||
<img v-if="photoUrl" class="photo-preview" :src="photoUrl" alt="送达照片" />
|
||||
<div class="actions">
|
||||
<button v-if="canStart" class="button primary" :disabled="actionLoading" type="button" @click="startTask">
|
||||
开始配送
|
||||
</button>
|
||||
<button v-if="canDeliver" class="button primary" :disabled="actionLoading || uploading" type="button" @click="deliverTask">
|
||||
{{ uploading ? '上传中' : '标记送达' }}
|
||||
</button>
|
||||
<button class="button secondary" type="button" @click="callUser">拨打电话</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="canMarkException" class="panel stack">
|
||||
<label class="field">
|
||||
<span class="field-label">异常类型</span>
|
||||
<select v-model="exceptionType" class="select">
|
||||
<option value="CONTACT_FAILED">联系不上</option>
|
||||
<option value="ADDRESS_ERROR">地址错误</option>
|
||||
<option value="PICKUP_FAILED">取件失败</option>
|
||||
<option value="OTHER">其他</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">异常说明</span>
|
||||
<textarea v-model="exceptionNote" class="textarea" placeholder="说明现场情况,便于后台处理" />
|
||||
</label>
|
||||
<button class="button danger full" :disabled="actionLoading" type="button" @click="markException">
|
||||
标记异常
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
102
rider-h5/src/views/TaskListView.vue
Normal file
102
rider-h5/src/views/TaskListView.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { TOKEN_KEY, USER_KEY } from '../api/http'
|
||||
import { groupTasksByStatus, listRiderTasks } from '../api/riderTaskApi'
|
||||
import type { DeliveryTask } from '../types'
|
||||
import { bizTypeLabel, shortTime, statusLabel, yuan } from '../utils/format'
|
||||
|
||||
type TabKey = 'assigned' | 'delivering' | 'completed' | 'exception'
|
||||
|
||||
const router = useRouter()
|
||||
const tasks = ref<DeliveryTask[]>([])
|
||||
const activeTab = ref<TabKey>('assigned')
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const grouped = computed(() => groupTasksByStatus(tasks.value))
|
||||
const tabs = computed(() => [
|
||||
{ key: 'assigned' as const, label: '待配送', count: grouped.value.assigned.length },
|
||||
{ key: 'delivering' as const, label: '配送中', count: grouped.value.delivering.length },
|
||||
{ key: 'completed' as const, label: '已完成', count: grouped.value.completed.length },
|
||||
{ key: 'exception' as const, label: '异常', count: grouped.value.exception.length }
|
||||
])
|
||||
const visibleTasks = computed(() => grouped.value[activeTab.value])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
tasks.value = await listRiderTasks()
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '任务加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
router.replace('/login')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mobile-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">今日任务</h1>
|
||||
<p class="page-subtitle">按状态处理配送履约</p>
|
||||
</div>
|
||||
<button class="button text" type="button" @click="router.push('/income')">收入</button>
|
||||
<button class="button text" type="button" @click="logout">退出</button>
|
||||
</header>
|
||||
|
||||
<nav class="tab-row">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
type="button"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }} {{ tab.count }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section class="content">
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
<div v-if="loading" class="panel muted">加载中</div>
|
||||
|
||||
<button
|
||||
v-for="task in visibleTasks"
|
||||
:key="task.id"
|
||||
class="task-card"
|
||||
type="button"
|
||||
@click="router.push(`/tasks/${task.id}`)"
|
||||
>
|
||||
<div class="row between">
|
||||
<div>
|
||||
<div class="strong">{{ bizTypeLabel(task.bizType) }} {{ task.bizOrderNo }}</div>
|
||||
<div class="muted">{{ shortTime(task.createdAt) }}</div>
|
||||
</div>
|
||||
<span class="tag" :class="{ danger: task.status === 'EXCEPTION' }">{{ statusLabel(task.status) }}</span>
|
||||
</div>
|
||||
<div class="stack" style="margin-top: 10px">
|
||||
<div>{{ task.contactName || '居民' }} {{ task.contactPhone || '' }}</div>
|
||||
<div class="muted">{{ task.address || '暂无地址' }}</div>
|
||||
<div class="row between">
|
||||
<span class="muted">{{ task.remark || '无备注' }}</span>
|
||||
<span class="price">¥{{ yuan(task.amountCent) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="!loading && !visibleTasks.length" class="empty">当前没有{{ tabs.find((tab) => tab.key === activeTab)?.label }}任务</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
17
rider-h5/tsconfig.json
Normal file
17
rider-h5/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
Reference in New Issue
Block a user