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