feat: add admin web MVP
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
target/
|
target/
|
||||||
*.class
|
*.class
|
||||||
*.log
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
12
admin-web/index.html
Normal file
12
admin-web/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>
|
||||||
2379
admin-web/package-lock.json
generated
Normal file
2379
admin-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
admin-web/src/App.vue
Normal file
3
admin-web/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
10
admin-web/src/api/authApi.ts
Normal file
10
admin-web/src/api/authApi.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { CurrentUser } from '../types'
|
||||||
|
import { apiPost } from './http'
|
||||||
|
|
||||||
|
export function adminLogin(username: string, password: string) {
|
||||||
|
return apiPost<CurrentUser>('/api/auth/admin-login', { username, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
return apiPost<void>('/api/auth/logout')
|
||||||
|
}
|
||||||
34
admin-web/src/api/deliveryApi.ts
Normal file
34
admin-web/src/api/deliveryApi.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { DeliveryOrder, DeliveryUser } from '../types'
|
||||||
|
import { apiGet, apiPost, apiPut } from './http'
|
||||||
|
|
||||||
|
export function listDeliveryUsers() {
|
||||||
|
return apiGet<DeliveryUser[]>('/api/admin/delivery-users')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeliveryUser(data: Partial<DeliveryUser>) {
|
||||||
|
return apiPost<DeliveryUser>('/api/admin/delivery-users', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enableDeliveryUser(id: number) {
|
||||||
|
return apiPut<DeliveryUser>(`/api/admin/delivery-users/${id}/enable`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disableDeliveryUser(id: number) {
|
||||||
|
return apiPut<DeliveryUser>(`/api/admin/delivery-users/${id}/disable`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDeliveryOrders(status?: string) {
|
||||||
|
return apiGet<DeliveryOrder[]>('/api/admin/delivery-orders', status ? { status } : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignGoodsOrder(orderId: number, riderId: number) {
|
||||||
|
return apiPost<DeliveryOrder>('/api/admin/delivery-orders/assign-goods-order', { orderId, riderId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignExpressOrder(orderId: number, riderId: number) {
|
||||||
|
return apiPost<DeliveryOrder>('/api/admin/delivery-orders/assign-express-order', { orderId, riderId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignGroupBuyOrder(orderId: number, riderId: number) {
|
||||||
|
return apiPost<DeliveryOrder>('/api/admin/delivery-orders/assign-group-buy-order', { orderId, riderId })
|
||||||
|
}
|
||||||
26
admin-web/src/api/groupBuyApi.ts
Normal file
26
admin-web/src/api/groupBuyApi.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { GroupBuy } from '../types'
|
||||||
|
import { apiGet, apiPost, apiPut } from './http'
|
||||||
|
|
||||||
|
export function listGroupBuys() {
|
||||||
|
return apiGet<GroupBuy[]>('/api/admin/group-buys')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGroupBuy(data: Partial<GroupBuy>) {
|
||||||
|
return apiPost<GroupBuy>('/api/admin/group-buys', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGroupBuy(id: number, data: Partial<GroupBuy>) {
|
||||||
|
return apiPut<GroupBuy>(`/api/admin/group-buys/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startGroupBuy(id: number) {
|
||||||
|
return apiPut<GroupBuy>(`/api/admin/group-buys/${id}/start`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeGroupBuy(id: number) {
|
||||||
|
return apiPut<GroupBuy>(`/api/admin/group-buys/${id}/close`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeGroupBuy(id: number) {
|
||||||
|
return apiPut<GroupBuy>(`/api/admin/group-buys/${id}/complete`)
|
||||||
|
}
|
||||||
46
admin-web/src/api/http.ts
Normal file
46
admin-web/src/api/http.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import axios, { AxiosHeaders } from 'axios'
|
||||||
|
|
||||||
|
export interface ApiEnvelope<T> {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const http = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
http.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiDelete<T>(url: string) {
|
||||||
|
return unwrap<T>(http.delete(url))
|
||||||
|
}
|
||||||
26
admin-web/src/api/noticeApi.ts
Normal file
26
admin-web/src/api/noticeApi.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Notice } from '../types'
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from './http'
|
||||||
|
|
||||||
|
export function listNotices() {
|
||||||
|
return apiGet<Notice[]>('/api/admin/notices')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNotice(data: Partial<Notice>) {
|
||||||
|
return apiPost<Notice>('/api/admin/notices', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateNotice(id: number, data: Partial<Notice>) {
|
||||||
|
return apiPut<Notice>(`/api/admin/notices/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishNotice(id: number) {
|
||||||
|
return apiPut<Notice>(`/api/admin/notices/${id}/publish`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offlineNotice(id: number) {
|
||||||
|
return apiPut<Notice>(`/api/admin/notices/${id}/offline`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteNotice(id: number) {
|
||||||
|
return apiDelete<void>(`/api/admin/notices/${id}`)
|
||||||
|
}
|
||||||
16
admin-web/src/api/orderApi.test.ts
Normal file
16
admin-web/src/api/orderApi.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildGoodsOrderQuery } from './orderApi'
|
||||||
|
|
||||||
|
describe('buildGoodsOrderQuery', () => {
|
||||||
|
it('keeps status and date range filters stable', () => {
|
||||||
|
expect(buildGoodsOrderQuery({
|
||||||
|
status: 'PENDING_DELIVERY',
|
||||||
|
startDate: '2026-07-01',
|
||||||
|
endDate: '2026-07-07'
|
||||||
|
})).toEqual({
|
||||||
|
status: 'PENDING_DELIVERY',
|
||||||
|
startDate: '2026-07-01',
|
||||||
|
endDate: '2026-07-07'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
56
admin-web/src/api/orderApi.ts
Normal file
56
admin-web/src/api/orderApi.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ExpressOrder, GoodsOrder } from '../types'
|
||||||
|
import { apiGet, apiPut } from './http'
|
||||||
|
|
||||||
|
export interface GoodsOrderQuery {
|
||||||
|
status?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGoodsOrderQuery(query: GoodsOrderQuery) {
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (query.status) params.status = query.status
|
||||||
|
if (query.startDate) params.startDate = query.startDate
|
||||||
|
if (query.endDate) params.endDate = query.endDate
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listGoodsOrders(query: GoodsOrderQuery = {}) {
|
||||||
|
return apiGet<GoodsOrder[]>('/api/admin/goods-orders', buildGoodsOrderQuery(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmGoodsOrder(id: number) {
|
||||||
|
return apiPut<GoodsOrder>(`/api/admin/goods-orders/${id}/confirm`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markGoodsOrderPrepared(id: number) {
|
||||||
|
return apiPut<GoodsOrder>(`/api/admin/goods-orders/${id}/prepared`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelGoodsOrder(id: number) {
|
||||||
|
return apiPut<GoodsOrder>(`/api/mini/goods-orders/${id}/cancel`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markGoodsOrderPaid(id: number) {
|
||||||
|
return apiPut<GoodsOrder>(`/api/admin/goods-orders/${id}/paid`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listExpressOrders(status?: string) {
|
||||||
|
return apiGet<ExpressOrder[]>('/api/admin/express-orders', status ? { status } : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmExpressOrder(id: number) {
|
||||||
|
return apiPut<ExpressOrder>(`/api/admin/express-orders/${id}/confirm`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adjustExpressFee(id: number, feeCent: number, reason: string) {
|
||||||
|
return apiPut<ExpressOrder>(`/api/admin/express-orders/${id}/adjust-fee`, { feeCent, reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelExpressOrder(id: number, reason?: string) {
|
||||||
|
return apiPut<ExpressOrder>(`/api/admin/express-orders/${id}/cancel`, { reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateExpressPayStatus(id: number, status: string) {
|
||||||
|
return apiPut<ExpressOrder>(`/api/admin/express-orders/${id}/offline-pay-status`, { status })
|
||||||
|
}
|
||||||
38
admin-web/src/api/productApi.ts
Normal file
38
admin-web/src/api/productApi.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Product, ProductCategory, ProductSku } from '../types'
|
||||||
|
import { apiGet, apiPost, apiPut } from './http'
|
||||||
|
|
||||||
|
export function listProducts() {
|
||||||
|
return apiGet<Product[]>('/api/admin/products')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProduct(data: Partial<Product>) {
|
||||||
|
return apiPost<Product>('/api/admin/products', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateProduct(id: number, data: Partial<Product>) {
|
||||||
|
return apiPut<Product>(`/api/admin/products/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onShelfProduct(id: number) {
|
||||||
|
return apiPut<Product>(`/api/admin/products/${id}/on-shelf`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offShelfProduct(id: number) {
|
||||||
|
return apiPut<Product>(`/api/admin/products/${id}/off-shelf`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listProductCategories() {
|
||||||
|
return apiGet<ProductCategory[]>('/api/admin/product-categories')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProductCategory(data: Partial<ProductCategory>) {
|
||||||
|
return apiPost<ProductCategory>('/api/admin/product-categories', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSku(productId: number, data: Partial<ProductSku>) {
|
||||||
|
return apiPost<ProductSku>(`/api/admin/products/${productId}/skus`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSku(productId: number, skuId: number, data: Partial<ProductSku>) {
|
||||||
|
return apiPut<ProductSku>(`/api/admin/products/${productId}/skus/${skuId}`, data)
|
||||||
|
}
|
||||||
18
admin-web/src/api/secondGoodsApi.ts
Normal file
18
admin-web/src/api/secondGoodsApi.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { SecondGoods } from '../types'
|
||||||
|
import { apiGet, apiPut } from './http'
|
||||||
|
|
||||||
|
export function listSecondGoods(status?: string) {
|
||||||
|
return apiGet<SecondGoods[]>('/api/admin/second-goods', status ? { status } : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approveSecondGoods(id: number) {
|
||||||
|
return apiPut<SecondGoods>(`/api/admin/second-goods/${id}/approve`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectSecondGoods(id: number, reason: string) {
|
||||||
|
return apiPut<SecondGoods>(`/api/admin/second-goods/${id}/reject`, { reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offShelfSecondGoods(id: number) {
|
||||||
|
return apiPut<SecondGoods>(`/api/admin/second-goods/${id}/off-shelf`)
|
||||||
|
}
|
||||||
8
admin-web/src/env.d.ts
vendored
Normal file
8
admin-web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
|
||||||
|
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
78
admin-web/src/layout/AdminLayout.vue
Normal file
78
admin-web/src/layout/AdminLayout.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
Box,
|
||||||
|
DataLine,
|
||||||
|
Goods,
|
||||||
|
House,
|
||||||
|
List,
|
||||||
|
Shop,
|
||||||
|
SwitchButton,
|
||||||
|
Tickets,
|
||||||
|
Van,
|
||||||
|
User
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: '仪表盘', icon: DataLine },
|
||||||
|
{ path: '/products', label: '商品管理', icon: Goods },
|
||||||
|
{ path: '/goods-orders', label: '商品订单', icon: List },
|
||||||
|
{ path: '/express-orders', label: '快递代取', icon: Box },
|
||||||
|
{ path: '/dispatch', label: '配送派单', icon: Van },
|
||||||
|
{ path: '/group-buys', label: '社区团购', icon: Shop },
|
||||||
|
{ path: '/second-goods', label: '闲置审核', icon: Tickets },
|
||||||
|
{ path: '/notices', label: '社区公告', icon: Bell }
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentUser = computed(() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem('admin_user') || '{}')
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function signOut() {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
localStorage.removeItem('admin_user')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<el-icon><House /></el-icon>
|
||||||
|
<span style="margin-left: 8px">邻小帮后台</span>
|
||||||
|
</div>
|
||||||
|
<el-menu
|
||||||
|
class="side-menu"
|
||||||
|
:default-active="route.path"
|
||||||
|
router
|
||||||
|
background-color="#1f2933"
|
||||||
|
text-color="#dbe4ee"
|
||||||
|
active-text-color="#ffffff"
|
||||||
|
>
|
||||||
|
<el-menu-item v-for="item in navItems" :key="item.path" :index="item.path">
|
||||||
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</aside>
|
||||||
|
<main class="main-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>社区 ID:{{ currentUser.communityId || 1 }}</div>
|
||||||
|
<el-button :icon="SwitchButton" @click="signOut">退出</el-button>
|
||||||
|
</header>
|
||||||
|
<section class="content">
|
||||||
|
<router-view />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
admin-web/src/main.ts
Normal file
8
admin-web/src/main.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import './styles.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import { router } from './router'
|
||||||
|
|
||||||
|
createApp(App).use(router).use(ElementPlus).mount('#app')
|
||||||
42
admin-web/src/router/index.ts
Normal file
42
admin-web/src/router/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import AdminLayout from '../layout/AdminLayout.vue'
|
||||||
|
import LoginView from '../views/LoginView.vue'
|
||||||
|
import DashboardView from '../views/DashboardView.vue'
|
||||||
|
import ProductListView from '../views/ProductListView.vue'
|
||||||
|
import GoodsOrderListView from '../views/GoodsOrderListView.vue'
|
||||||
|
import ExpressOrderListView from '../views/ExpressOrderListView.vue'
|
||||||
|
import DeliveryDispatchView from '../views/DeliveryDispatchView.vue'
|
||||||
|
import GroupBuyListView from '../views/GroupBuyListView.vue'
|
||||||
|
import SecondGoodsReviewView from '../views/SecondGoodsReviewView.vue'
|
||||||
|
import NoticeListView from '../views/NoticeListView.vue'
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/login', component: LoginView },
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: AdminLayout,
|
||||||
|
children: [
|
||||||
|
{ path: '', name: 'dashboard', component: DashboardView },
|
||||||
|
{ path: 'products', name: 'products', component: ProductListView },
|
||||||
|
{ path: 'goods-orders', name: 'goods-orders', component: GoodsOrderListView },
|
||||||
|
{ path: 'express-orders', name: 'express-orders', component: ExpressOrderListView },
|
||||||
|
{ path: 'dispatch', name: 'dispatch', component: DeliveryDispatchView },
|
||||||
|
{ path: 'group-buys', name: 'group-buys', component: GroupBuyListView },
|
||||||
|
{ path: 'second-goods', name: 'second-goods', component: SecondGoodsReviewView },
|
||||||
|
{ path: 'notices', name: 'notices', component: NoticeListView }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.path !== '/login' && !localStorage.getItem('admin_token')) {
|
||||||
|
return '/login'
|
||||||
|
}
|
||||||
|
if (to.path === '/login' && localStorage.getItem('admin_token')) {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
142
admin-web/src/styles.css
Normal file
142
admin-web/src/styles.css
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 1180px;
|
||||||
|
color: #263238;
|
||||||
|
background: #f4f6f8;
|
||||||
|
font-family: Inter, "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 224px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: #1f2933;
|
||||||
|
color: #f7fafc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-shell {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #e5e9ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px 24px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e9ef;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e9ef;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
color: #637381;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #eef2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
width: 360px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #dde3ea;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
color: #637381;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-action {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
124
admin-web/src/types.ts
Normal file
124
admin-web/src/types.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
export interface CurrentUser {
|
||||||
|
token: string
|
||||||
|
userId: number
|
||||||
|
tenantId: number
|
||||||
|
communityId: number
|
||||||
|
roleCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSku {
|
||||||
|
id: number
|
||||||
|
productId: number
|
||||||
|
productName: string
|
||||||
|
skuName: string
|
||||||
|
priceCent: number
|
||||||
|
originPriceCent?: number
|
||||||
|
stock: number
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: number
|
||||||
|
merchantId: number
|
||||||
|
categoryId: number
|
||||||
|
name: string
|
||||||
|
coverUrl?: string
|
||||||
|
description?: string
|
||||||
|
unitName: string
|
||||||
|
status: string
|
||||||
|
skus: ProductSku[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductCategory {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
sortNo: number
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoodsOrder {
|
||||||
|
id: number
|
||||||
|
orderNo: string
|
||||||
|
merchantId: number
|
||||||
|
userId: number
|
||||||
|
deliveryMethod: string
|
||||||
|
status: string
|
||||||
|
offlinePayStatus: string
|
||||||
|
totalAmountCent: number
|
||||||
|
deliveryFeeCent: number
|
||||||
|
payableAmountCent: number
|
||||||
|
remark?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpressOrder {
|
||||||
|
id: number
|
||||||
|
orderNo: string
|
||||||
|
expressCompany: string
|
||||||
|
pickupCode: string
|
||||||
|
pickupAddress: string
|
||||||
|
contactName?: string
|
||||||
|
contactPhone?: string
|
||||||
|
deliveryAddress?: string
|
||||||
|
packageCount: number
|
||||||
|
feeCent: number
|
||||||
|
offlinePayStatus: string
|
||||||
|
status: string
|
||||||
|
remark?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeliveryUser {
|
||||||
|
id: number
|
||||||
|
userId: number
|
||||||
|
name: string
|
||||||
|
phone: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeliveryOrder {
|
||||||
|
id: number
|
||||||
|
bizType: string
|
||||||
|
bizOrderId: number
|
||||||
|
bizOrderNo: string
|
||||||
|
riderId: number
|
||||||
|
riderName: string
|
||||||
|
address?: string
|
||||||
|
amountCent: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupBuy {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
coverUrl?: string
|
||||||
|
priceCent: number
|
||||||
|
stock: number
|
||||||
|
soldCount: number
|
||||||
|
status: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
pickupAddress?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecondGoods {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
priceCent: number
|
||||||
|
category?: string
|
||||||
|
contactPhone?: string
|
||||||
|
tradeMethod?: string
|
||||||
|
status: string
|
||||||
|
reviewReason?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notice {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
category: string
|
||||||
|
content: string
|
||||||
|
pinned: boolean
|
||||||
|
status: string
|
||||||
|
publishedAt?: string
|
||||||
|
}
|
||||||
27
admin-web/src/utils/format.ts
Normal file
27
admin-web/src/utils/format.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export function yuan(value?: number) {
|
||||||
|
return `¥${((value || 0) / 100).toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusText(value?: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING_CONFIRM: '待确认',
|
||||||
|
PREPARING: '备货中',
|
||||||
|
PENDING_DELIVERY: '待配送',
|
||||||
|
PENDING_PICKUP: '待自提',
|
||||||
|
DELIVERING: '配送中',
|
||||||
|
DELIVERED: '已送达',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
CANCELED: '已取消',
|
||||||
|
UNPAID: '未收款',
|
||||||
|
PAID: '已收款',
|
||||||
|
DRAFT: '草稿',
|
||||||
|
ONGOING: '进行中',
|
||||||
|
CLOSED: '已截单',
|
||||||
|
PUBLISHED: '已发布',
|
||||||
|
PENDING_REVIEW: '待审核',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
OFF_SHELF: '已下架',
|
||||||
|
SOLD: '已售出'
|
||||||
|
}
|
||||||
|
return value ? map[value] || value : '-'
|
||||||
|
}
|
||||||
93
admin-web/src/views/DashboardView.vue
Normal file
93
admin-web/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { listGoodsOrders, listExpressOrders } from '../api/orderApi'
|
||||||
|
import { listGroupBuys } from '../api/groupBuyApi'
|
||||||
|
import { listSecondGoods } from '../api/secondGoodsApi'
|
||||||
|
import type { ExpressOrder, GoodsOrder, GroupBuy, SecondGoods } from '../types'
|
||||||
|
import { yuan } from '../utils/format'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const goodsOrders = ref<GoodsOrder[]>([])
|
||||||
|
const expressOrders = ref<ExpressOrder[]>([])
|
||||||
|
const groupBuys = ref<GroupBuy[]>([])
|
||||||
|
const pendingSecondGoods = ref<SecondGoods[]>([])
|
||||||
|
|
||||||
|
const todayOrderCount = computed(() => goodsOrders.value.length + expressOrders.value.length)
|
||||||
|
const receivableAmount = computed(() =>
|
||||||
|
goodsOrders.value.reduce((sum, item) => sum + item.payableAmountCent, 0)
|
||||||
|
+ expressOrders.value.reduce((sum, item) => sum + item.feeCent, 0)
|
||||||
|
)
|
||||||
|
const pendingConfirmCount = computed(() =>
|
||||||
|
goodsOrders.value.filter((item) => item.status === 'PENDING_CONFIRM').length
|
||||||
|
+ expressOrders.value.filter((item) => item.status === 'PENDING_CONFIRM').length
|
||||||
|
)
|
||||||
|
const pendingDeliveryCount = computed(() =>
|
||||||
|
goodsOrders.value.filter((item) => item.status === 'PENDING_DELIVERY').length
|
||||||
|
)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [goods, express, groups, second] = await Promise.all([
|
||||||
|
listGoodsOrders(),
|
||||||
|
listExpressOrders(),
|
||||||
|
listGroupBuys(),
|
||||||
|
listSecondGoods('PENDING_REVIEW')
|
||||||
|
])
|
||||||
|
goodsOrders.value = goods
|
||||||
|
expressOrders.value = express
|
||||||
|
groupBuys.value = groups
|
||||||
|
pendingSecondGoods.value = second
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="page-title">仪表盘</h1>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="metric-grid">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">订单数</div>
|
||||||
|
<div class="metric-value">{{ todayOrderCount }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">应收金额</div>
|
||||||
|
<div class="metric-value">{{ yuan(receivableAmount) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">待确认</div>
|
||||||
|
<div class="metric-value">{{ pendingConfirmCount }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">待配送</div>
|
||||||
|
<div class="metric-value">{{ pendingDeliveryCount }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">待审核闲置</div>
|
||||||
|
<div class="metric-value">{{ pendingSecondGoods.length }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" style="margin-top: 16px">
|
||||||
|
<div class="toolbar">
|
||||||
|
<strong>正在进行的团购</strong>
|
||||||
|
</div>
|
||||||
|
<el-table :data="groupBuys" border>
|
||||||
|
<el-table-column prop="title" label="标题" min-width="180" />
|
||||||
|
<el-table-column label="价格" width="120">
|
||||||
|
<template #default="{ row }">{{ yuan(row.priceCent) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="soldCount" label="已售" width="90" />
|
||||||
|
<el-table-column prop="status" label="状态" width="120" />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
134
admin-web/src/views/DeliveryDispatchView.vue
Normal file
134
admin-web/src/views/DeliveryDispatchView.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Plus, Refresh, Van } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { listGoodsOrders, listExpressOrders } from '../api/orderApi'
|
||||||
|
import {
|
||||||
|
assignExpressOrder,
|
||||||
|
assignGoodsOrder,
|
||||||
|
assignGroupBuyOrder,
|
||||||
|
createDeliveryUser,
|
||||||
|
listDeliveryOrders,
|
||||||
|
listDeliveryUsers
|
||||||
|
} from '../api/deliveryApi'
|
||||||
|
import type { DeliveryOrder, DeliveryUser, ExpressOrder, GoodsOrder } from '../types'
|
||||||
|
import { statusText } from '../utils/format'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const riders = ref<DeliveryUser[]>([])
|
||||||
|
const tasks = ref<DeliveryOrder[]>([])
|
||||||
|
const goodsOrders = ref<GoodsOrder[]>([])
|
||||||
|
const expressOrders = ref<ExpressOrder[]>([])
|
||||||
|
const riderForm = reactive({ userId: 3, name: '', phone: '', enabled: true })
|
||||||
|
const dispatchForm = reactive({ goodsOrderId: undefined as number | undefined, expressOrderId: undefined as number | undefined, groupBuyOrderId: undefined as number | undefined, riderId: undefined as number | undefined })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [riderRows, taskRows, goodsRows, expressRows] = await Promise.all([
|
||||||
|
listDeliveryUsers(),
|
||||||
|
listDeliveryOrders(),
|
||||||
|
listGoodsOrders({ status: 'PENDING_DELIVERY' }),
|
||||||
|
listExpressOrders('PENDING_PICKUP')
|
||||||
|
])
|
||||||
|
riders.value = riderRows
|
||||||
|
tasks.value = taskRows
|
||||||
|
goodsOrders.value = goodsRows
|
||||||
|
expressOrders.value = expressRows
|
||||||
|
if (!dispatchForm.riderId && riderRows[0]) dispatchForm.riderId = riderRows[0].id
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRider() {
|
||||||
|
try {
|
||||||
|
await createDeliveryUser(riderForm)
|
||||||
|
riderForm.name = ''
|
||||||
|
riderForm.phone = ''
|
||||||
|
ElMessage.success('已创建配送员')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatch(type: 'goods' | 'express' | 'group') {
|
||||||
|
if (!dispatchForm.riderId) {
|
||||||
|
ElMessage.warning('请选择配送员')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (type === 'goods' && dispatchForm.goodsOrderId) {
|
||||||
|
await assignGoodsOrder(dispatchForm.goodsOrderId, dispatchForm.riderId)
|
||||||
|
}
|
||||||
|
if (type === 'express' && dispatchForm.expressOrderId) {
|
||||||
|
await assignExpressOrder(dispatchForm.expressOrderId, dispatchForm.riderId)
|
||||||
|
}
|
||||||
|
if (type === 'group' && dispatchForm.groupBuyOrderId) {
|
||||||
|
await assignGroupBuyOrder(dispatchForm.groupBuyOrderId, dispatchForm.riderId)
|
||||||
|
}
|
||||||
|
ElMessage.success('派单成功')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '派单失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="page-title">配送派单</h1>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<strong>配送员</strong>
|
||||||
|
<el-input v-model="riderForm.name" placeholder="姓名" style="width: 140px" />
|
||||||
|
<el-input v-model="riderForm.phone" placeholder="手机号" style="width: 160px" />
|
||||||
|
<el-input-number v-model="riderForm.userId" :min="1" />
|
||||||
|
<el-button type="primary" :icon="Plus" @click="saveRider">新增</el-button>
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-select v-model="dispatchForm.riderId" placeholder="选择配送员" style="width: 220px; margin-bottom: 12px">
|
||||||
|
<el-option v-for="rider in riders" :key="rider.id" :label="`${rider.name} ${rider.phone}`" :value="rider.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-tabs>
|
||||||
|
<el-tab-pane label="商品订单">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-select v-model="dispatchForm.goodsOrderId" placeholder="待配送商品订单" style="width: 260px">
|
||||||
|
<el-option v-for="item in goodsOrders" :key="item.id" :label="item.orderNo" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" :icon="Van" @click="dispatch('goods')">派单</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="快递代取">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-select v-model="dispatchForm.expressOrderId" placeholder="待取件订单" style="width: 260px">
|
||||||
|
<el-option v-for="item in expressOrders" :key="item.id" :label="`${item.orderNo} ${item.pickupCode}`" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" :icon="Van" @click="dispatch('express')">派单</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="团购订单">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-input-number v-model="dispatchForm.groupBuyOrderId" :min="1" />
|
||||||
|
<el-button type="primary" :icon="Van" @click="dispatch('group')">派单</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="toolbar"><strong>配送任务</strong></div>
|
||||||
|
<el-table :data="tasks" border>
|
||||||
|
<el-table-column prop="bizOrderNo" label="业务单号" min-width="150" />
|
||||||
|
<el-table-column prop="bizType" label="类型" width="150" />
|
||||||
|
<el-table-column prop="riderName" label="配送员" width="120" />
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">{{ statusText(row.status) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="address" label="地址" min-width="180" />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
92
admin-web/src/views/ExpressOrderListView.vue
Normal file
92
admin-web/src/views/ExpressOrderListView.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Check, Edit, Money, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
adjustExpressFee,
|
||||||
|
cancelExpressOrder,
|
||||||
|
confirmExpressOrder,
|
||||||
|
listExpressOrders,
|
||||||
|
updateExpressPayStatus
|
||||||
|
} from '../api/orderApi'
|
||||||
|
import type { ExpressOrder } from '../types'
|
||||||
|
import { statusText, yuan } from '../utils/format'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<ExpressOrder[]>([])
|
||||||
|
const filters = reactive({ status: '' })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
rows.value = await listExpressOrders(filters.status || undefined)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(action: () => Promise<ExpressOrder>) {
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adjust(row: ExpressOrder) {
|
||||||
|
const { value } = await ElMessageBox.prompt('输入新的服务费(元)', '调整费用', {
|
||||||
|
inputValue: String((row.feeCent / 100).toFixed(2))
|
||||||
|
})
|
||||||
|
await run(() => adjustExpressFee(row.id, Math.round(Number(value) * 100), '后台调整'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel(row: ExpressOrder) {
|
||||||
|
const { value } = await ElMessageBox.prompt('取消原因', '取消订单')
|
||||||
|
await run(() => cancelExpressOrder(row.id, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="page-title">快递代取</h1>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-select v-model="filters.status" placeholder="订单状态" clearable style="width: 180px">
|
||||||
|
<el-option label="待确认" value="PENDING_CONFIRM" />
|
||||||
|
<el-option label="待取件" value="PENDING_PICKUP" />
|
||||||
|
<el-option label="取件中" value="PICKING_UP" />
|
||||||
|
<el-option label="配送中" value="DELIVERING" />
|
||||||
|
<el-option label="已完成" value="COMPLETED" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" :icon="Search" :loading="loading" @click="load">查询</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="rows" border v-loading="loading">
|
||||||
|
<el-table-column prop="orderNo" label="订单号" min-width="150" />
|
||||||
|
<el-table-column prop="expressCompany" label="快递公司" width="120" />
|
||||||
|
<el-table-column prop="pickupCode" label="取件码" width="120" />
|
||||||
|
<el-table-column prop="pickupAddress" label="取件地址" min-width="150" />
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">{{ statusText(row.status) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="服务费" width="110">
|
||||||
|
<template #default="{ row }">{{ yuan(row.feeCent) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="300" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="table-action">
|
||||||
|
<el-button size="small" :icon="Check" @click="run(() => confirmExpressOrder(row.id))">确认</el-button>
|
||||||
|
<el-button size="small" :icon="Edit" @click="adjust(row)">改价</el-button>
|
||||||
|
<el-button size="small" :icon="Money" @click="run(() => updateExpressPayStatus(row.id, 'PAID'))">收款</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="cancel(row)">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
83
admin-web/src/views/GoodsOrderListView.vue
Normal file
83
admin-web/src/views/GoodsOrderListView.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Check, Money, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
cancelGoodsOrder,
|
||||||
|
confirmGoodsOrder,
|
||||||
|
listGoodsOrders,
|
||||||
|
markGoodsOrderPaid,
|
||||||
|
markGoodsOrderPrepared
|
||||||
|
} from '../api/orderApi'
|
||||||
|
import type { GoodsOrder } from '../types'
|
||||||
|
import { statusText, yuan } from '../utils/format'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<GoodsOrder[]>([])
|
||||||
|
const filters = reactive({ status: '' })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
rows.value = await listGoodsOrders({ status: filters.status || undefined })
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(action: () => Promise<GoodsOrder>) {
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="page-title">商品订单</h1>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-select v-model="filters.status" placeholder="订单状态" clearable style="width: 180px">
|
||||||
|
<el-option label="待确认" value="PENDING_CONFIRM" />
|
||||||
|
<el-option label="备货中" value="PREPARING" />
|
||||||
|
<el-option label="待配送" value="PENDING_DELIVERY" />
|
||||||
|
<el-option label="配送中" value="DELIVERING" />
|
||||||
|
<el-option label="已完成" value="COMPLETED" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" :icon="Search" :loading="loading" @click="load">查询</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="rows" border v-loading="loading">
|
||||||
|
<el-table-column prop="orderNo" label="订单号" min-width="150" />
|
||||||
|
<el-table-column prop="merchantId" label="商户" width="90" />
|
||||||
|
<el-table-column prop="deliveryMethod" label="配送方式" width="120" />
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">{{ statusText(row.status) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="收款" width="100">
|
||||||
|
<template #default="{ row }">{{ statusText(row.offlinePayStatus) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="应收" width="110">
|
||||||
|
<template #default="{ row }">{{ yuan(row.payableAmountCent) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="remark" label="备注" min-width="140" />
|
||||||
|
<el-table-column label="操作" width="300" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="table-action">
|
||||||
|
<el-button size="small" :icon="Check" @click="run(() => confirmGoodsOrder(row.id))">确认</el-button>
|
||||||
|
<el-button size="small" :icon="Refresh" @click="run(() => markGoodsOrderPrepared(row.id))">备好</el-button>
|
||||||
|
<el-button size="small" :icon="Money" @click="run(() => markGoodsOrderPaid(row.id))">收款</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="run(() => cancelGoodsOrder(row.id))">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
110
admin-web/src/views/GroupBuyListView.vue
Normal file
110
admin-web/src/views/GroupBuyListView.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Check, Close, Plus, Refresh, VideoPlay } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
closeGroupBuy,
|
||||||
|
completeGroupBuy,
|
||||||
|
createGroupBuy,
|
||||||
|
listGroupBuys,
|
||||||
|
startGroupBuy
|
||||||
|
} from '../api/groupBuyApi'
|
||||||
|
import type { GroupBuy } from '../types'
|
||||||
|
import { statusText, yuan } from '../utils/format'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<GroupBuy[]>([])
|
||||||
|
const dialog = ref(false)
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
coverUrl: '',
|
||||||
|
description: '',
|
||||||
|
priceCent: 0,
|
||||||
|
stock: 0,
|
||||||
|
startTime: '',
|
||||||
|
endTime: '',
|
||||||
|
pickupAddress: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
rows.value = await listGroupBuys()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
await createGroupBuy(form)
|
||||||
|
dialog.value = false
|
||||||
|
ElMessage.success('已创建团购')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(action: () => Promise<GroupBuy>) {
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="page-title">社区团购</h1>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="dialog = true">新建团购</el-button>
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="rows" border v-loading="loading">
|
||||||
|
<el-table-column prop="title" label="标题" min-width="180" />
|
||||||
|
<el-table-column label="价格" width="120">
|
||||||
|
<template #default="{ row }">{{ yuan(row.priceCent) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="stock" label="库存" width="90" />
|
||||||
|
<el-table-column prop="soldCount" label="已售" width="90" />
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">{{ statusText(row.status) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="endTime" label="截单时间" min-width="170" />
|
||||||
|
<el-table-column label="操作" width="260" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="table-action">
|
||||||
|
<el-button size="small" :icon="VideoPlay" @click="run(() => startGroupBuy(row.id))">开始</el-button>
|
||||||
|
<el-button size="small" :icon="Close" @click="run(() => closeGroupBuy(row.id))">截单</el-button>
|
||||||
|
<el-button size="small" :icon="Check" @click="run(() => completeGroupBuy(row.id))">完成</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialog" title="新建团购" width="560px">
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
|
||||||
|
<el-form-item label="封面"><el-input v-model="form.coverUrl" /></el-form-item>
|
||||||
|
<el-form-item label="价格"><el-input-number v-model="form.priceCent" :min="0" /></el-form-item>
|
||||||
|
<el-form-item label="库存"><el-input-number v-model="form.stock" :min="0" /></el-form-item>
|
||||||
|
<el-form-item label="开始时间"><el-input v-model="form.startTime" placeholder="2026-07-07T08:00:00" /></el-form-item>
|
||||||
|
<el-form-item label="截单时间"><el-input v-model="form.endTime" placeholder="2026-07-07T20:00:00" /></el-form-item>
|
||||||
|
<el-form-item label="自提点"><el-input v-model="form.pickupAddress" /></el-form-item>
|
||||||
|
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="save">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
49
admin-web/src/views/LoginView.vue
Normal file
49
admin-web/src/views/LoginView.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Lock, User } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { adminLogin } from '../api/authApi'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const form = reactive({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin123'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const user = await adminLogin(form.username, form.password)
|
||||||
|
localStorage.setItem('admin_token', user.token)
|
||||||
|
localStorage.setItem('admin_user', JSON.stringify(user))
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
router.push('/')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '登录失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-panel">
|
||||||
|
<h1 class="login-title">邻小帮后台</h1>
|
||||||
|
<p class="login-subtitle">社区运营管理</p>
|
||||||
|
<el-form label-position="top" @submit.prevent>
|
||||||
|
<el-form-item label="账号">
|
||||||
|
<el-input v-model="form.username" :prefix-icon="User" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input v-model="form.password" type="password" show-password :prefix-icon="Lock" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" style="width: 100%" @click="submit">
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
101
admin-web/src/views/NoticeListView.vue
Normal file
101
admin-web/src/views/NoticeListView.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Check, Close, Delete, Plus, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
createNotice,
|
||||||
|
deleteNotice,
|
||||||
|
listNotices,
|
||||||
|
offlineNotice,
|
||||||
|
publishNotice
|
||||||
|
} from '../api/noticeApi'
|
||||||
|
import type { Notice } from '../types'
|
||||||
|
import { statusText } from '../utils/format'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<Notice[]>([])
|
||||||
|
const dialog = ref(false)
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
category: '物业通知',
|
||||||
|
content: '',
|
||||||
|
pinned: false
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
rows.value = await listNotices()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
await createNotice(form)
|
||||||
|
dialog.value = false
|
||||||
|
ElMessage.success('已创建公告')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(action: () => Promise<Notice | void>) {
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="page-title">社区公告</h1>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="dialog = true">新建公告</el-button>
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="rows" border v-loading="loading">
|
||||||
|
<el-table-column prop="title" label="标题" min-width="180" />
|
||||||
|
<el-table-column prop="category" label="分类" width="120" />
|
||||||
|
<el-table-column label="置顶" width="90">
|
||||||
|
<template #default="{ row }">{{ row.pinned ? '是' : '否' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">{{ statusText(row.status) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="publishedAt" label="发布时间" min-width="160" />
|
||||||
|
<el-table-column label="操作" width="260" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="table-action">
|
||||||
|
<el-button size="small" :icon="Check" @click="run(() => publishNotice(row.id))">发布</el-button>
|
||||||
|
<el-button size="small" :icon="Close" @click="run(() => offlineNotice(row.id))">下线</el-button>
|
||||||
|
<el-button size="small" type="danger" :icon="Delete" @click="run(() => deleteNotice(row.id))">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialog" title="新建公告" width="560px">
|
||||||
|
<el-form label-width="80px">
|
||||||
|
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
|
||||||
|
<el-form-item label="分类"><el-input v-model="form.category" /></el-form-item>
|
||||||
|
<el-form-item label="置顶"><el-switch v-model="form.pinned" /></el-form-item>
|
||||||
|
<el-form-item label="内容"><el-input v-model="form.content" type="textarea" :rows="5" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="save">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
173
admin-web/src/views/ProductListView.vue
Normal file
173
admin-web/src/views/ProductListView.vue
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Check, Edit, Plus, Refresh, Upload } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
addSku,
|
||||||
|
createProduct,
|
||||||
|
createProductCategory,
|
||||||
|
listProductCategories,
|
||||||
|
listProducts,
|
||||||
|
offShelfProduct,
|
||||||
|
onShelfProduct
|
||||||
|
} from '../api/productApi'
|
||||||
|
import type { Product, ProductCategory } from '../types'
|
||||||
|
import { statusText, yuan } from '../utils/format'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const products = ref<Product[]>([])
|
||||||
|
const categories = ref<ProductCategory[]>([])
|
||||||
|
const productDialog = ref(false)
|
||||||
|
const categoryDialog = ref(false)
|
||||||
|
const skuDialog = ref(false)
|
||||||
|
const activeProduct = ref<Product | null>(null)
|
||||||
|
const productForm = reactive({ merchantId: 1, categoryId: 0, name: '', unitName: '件', coverUrl: '', description: '' })
|
||||||
|
const categoryForm = reactive({ name: '', sortNo: 0, enabled: true })
|
||||||
|
const skuForm = reactive({ skuName: '', priceCent: 0, originPriceCent: undefined as number | undefined, stock: 0, enabled: true })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [productRows, categoryRows] = await Promise.all([listProducts(), listProductCategories()])
|
||||||
|
products.value = productRows
|
||||||
|
categories.value = categoryRows
|
||||||
|
if (!productForm.categoryId && categoryRows[0]) {
|
||||||
|
productForm.categoryId = categoryRows[0].id
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProduct() {
|
||||||
|
try {
|
||||||
|
await createProduct(productForm)
|
||||||
|
productDialog.value = false
|
||||||
|
ElMessage.success('已创建商品')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCategory() {
|
||||||
|
try {
|
||||||
|
await createProductCategory(categoryForm)
|
||||||
|
categoryDialog.value = false
|
||||||
|
ElMessage.success('已创建分类')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSku(row: Product) {
|
||||||
|
activeProduct.value = row
|
||||||
|
skuForm.skuName = ''
|
||||||
|
skuForm.priceCent = 0
|
||||||
|
skuForm.stock = 0
|
||||||
|
skuDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSku() {
|
||||||
|
if (!activeProduct.value) return
|
||||||
|
try {
|
||||||
|
await addSku(activeProduct.value.id, skuForm)
|
||||||
|
skuDialog.value = false
|
||||||
|
ElMessage.success('已添加规格')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shelf(row: Product, onShelf: boolean) {
|
||||||
|
try {
|
||||||
|
await (onShelf ? onShelfProduct(row.id) : offShelfProduct(row.id))
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="page-title">商品管理</h1>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="productDialog = true">新建商品</el-button>
|
||||||
|
<el-button :icon="Plus" @click="categoryDialog = true">新建分类</el-button>
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="products" border v-loading="loading">
|
||||||
|
<el-table-column prop="name" label="商品" min-width="160" />
|
||||||
|
<el-table-column prop="merchantId" label="商户" width="90" />
|
||||||
|
<el-table-column prop="categoryId" label="分类" width="90" />
|
||||||
|
<el-table-column label="状态" width="110">
|
||||||
|
<template #default="{ row }">{{ statusText(row.status) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="规格" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-for="sku in row.skus" :key="sku.id" style="margin-right: 6px">
|
||||||
|
{{ sku.skuName }} {{ yuan(sku.priceCent) }} / 库存 {{ sku.stock }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="260" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="table-action">
|
||||||
|
<el-button size="small" :icon="Edit" @click="openSku(row)">规格</el-button>
|
||||||
|
<el-button size="small" :icon="Upload" @click="shelf(row, true)">上架</el-button>
|
||||||
|
<el-button size="small" :icon="Check" @click="shelf(row, false)">下架</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="productDialog" title="新建商品" width="520px">
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="名称"><el-input v-model="productForm.name" /></el-form-item>
|
||||||
|
<el-form-item label="商户"><el-input-number v-model="productForm.merchantId" :min="1" /></el-form-item>
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<el-select v-model="productForm.categoryId" style="width: 220px">
|
||||||
|
<el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="单位"><el-input v-model="productForm.unitName" /></el-form-item>
|
||||||
|
<el-form-item label="封面"><el-input v-model="productForm.coverUrl" /></el-form-item>
|
||||||
|
<el-form-item label="描述"><el-input v-model="productForm.description" type="textarea" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="productDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveProduct">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="categoryDialog" title="新建分类" width="420px">
|
||||||
|
<el-form label-width="80px">
|
||||||
|
<el-form-item label="名称"><el-input v-model="categoryForm.name" /></el-form-item>
|
||||||
|
<el-form-item label="排序"><el-input-number v-model="categoryForm.sortNo" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="categoryDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveCategory">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="skuDialog" title="添加规格" width="420px">
|
||||||
|
<el-form label-width="80px">
|
||||||
|
<el-form-item label="规格"><el-input v-model="skuForm.skuName" /></el-form-item>
|
||||||
|
<el-form-item label="价格"><el-input-number v-model="skuForm.priceCent" :min="0" /></el-form-item>
|
||||||
|
<el-form-item label="库存"><el-input-number v-model="skuForm.stock" :min="0" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="skuDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveSku">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
82
admin-web/src/views/SecondGoodsReviewView.vue
Normal file
82
admin-web/src/views/SecondGoodsReviewView.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Check, Close, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
approveSecondGoods,
|
||||||
|
listSecondGoods,
|
||||||
|
offShelfSecondGoods,
|
||||||
|
rejectSecondGoods
|
||||||
|
} from '../api/secondGoodsApi'
|
||||||
|
import type { SecondGoods } from '../types'
|
||||||
|
import { statusText, yuan } from '../utils/format'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<SecondGoods[]>([])
|
||||||
|
const filters = reactive({ status: 'PENDING_REVIEW' })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
rows.value = await listSecondGoods(filters.status || undefined)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(action: () => Promise<SecondGoods>) {
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject(row: SecondGoods) {
|
||||||
|
const { value } = await ElMessageBox.prompt('拒绝原因', '驳回闲置')
|
||||||
|
await run(() => rejectSecondGoods(row.id, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="page-title">闲置审核</h1>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-select v-model="filters.status" clearable placeholder="状态" style="width: 180px">
|
||||||
|
<el-option label="待审核" value="PENDING_REVIEW" />
|
||||||
|
<el-option label="已发布" value="PUBLISHED" />
|
||||||
|
<el-option label="已驳回" value="REJECTED" />
|
||||||
|
<el-option label="已下架" value="OFF_SHELF" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" :icon="Search" :loading="loading" @click="load">查询</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="rows" border v-loading="loading">
|
||||||
|
<el-table-column prop="title" label="标题" min-width="180" />
|
||||||
|
<el-table-column prop="category" label="分类" width="120" />
|
||||||
|
<el-table-column label="价格" width="110">
|
||||||
|
<template #default="{ row }">{{ yuan(row.priceCent) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="contactPhone" label="电话" width="140" />
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">{{ statusText(row.status) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="reviewReason" label="原因" min-width="140" />
|
||||||
|
<el-table-column label="操作" width="240" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="table-action">
|
||||||
|
<el-button size="small" :icon="Check" @click="run(() => approveSecondGoods(row.id))">通过</el-button>
|
||||||
|
<el-button size="small" :icon="Close" @click="reject(row)">驳回</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="run(() => offShelfSecondGoods(row.id))">下架</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
18
admin-web/tsconfig.json
Normal file
18
admin-web/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": false,
|
||||||
|
"baseUrl": "."
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user