feat: add shipping template management and update favicon
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
Build and Push Docker Image / deploy (push) Successful in 7s

This commit is contained in:
nuyue
2026-07-12 18:52:27 +08:00
parent 6476d20faa
commit 46daa0999b
16 changed files with 865 additions and 99 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sale</title>
</head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

+7
View File
@@ -190,4 +190,11 @@ export const adminApi = {
getLogs: (lines?: number) => api.get('/admin/logs', { params: { lines: lines || 500 } }),
clearLogs: () => api.delete('/admin/logs'),
getLogInfo: () => api.get('/admin/logs/info'),
// 运费模板
getShippingTemplates: () => api.get('/admin/shipping-templates'),
getShippingTemplateById: (id: number) => api.get(`/admin/shipping-templates/${id}`),
createShippingTemplate: (data: any) => api.post('/admin/shipping-templates', data),
updateShippingTemplate: (id: number, data: any) => api.put(`/admin/shipping-templates/${id}`, data),
deleteShippingTemplate: (id: number) => api.delete(`/admin/shipping-templates/${id}`),
}
+6 -1
View File
@@ -69,6 +69,10 @@
<el-icon><Notebook /></el-icon>
<span class="nav-text">日志管理</span>
</router-link>
<router-link to="/admin/shipping-templates" class="nav-link">
<el-icon><Box /></el-icon>
<span class="nav-text">运费管理</span>
</router-link>
<router-link to="/admin/settings" class="nav-link">
<el-icon><Setting /></el-icon>
<span class="nav-text">{{ $t('admin.systemSettings') }}</span>
@@ -168,7 +172,7 @@
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { DataAnalysis, User, Folder, Grid, Stamp, Goods, List, Van, Trophy, ChatDotSquare, Setting, Document, Picture, Compass, Fold, Expand, HomeFilled, FullScreen, ArrowDown, SwitchButton, ArrowRight, Wallet, Notebook } from '@element-plus/icons-vue'
import { DataAnalysis, User, Folder, Grid, Stamp, Goods, List, Van, Trophy, ChatDotSquare, Setting, Document, Picture, Compass, Fold, Expand, HomeFilled, FullScreen, ArrowDown, SwitchButton, ArrowRight, Wallet, Notebook, Box } from '@element-plus/icons-vue'
import { useUserStore } from '../store/user'
import { useCartStore } from '../store/cart'
@@ -197,6 +201,7 @@ const pageTitles: Record<string, string> = {
'/admin/banners': '轮播图管理',
'/admin/payment': '支付管理',
'/admin/logs': '日志管理',
'/admin/shipping-templates': '运费管理',
'/admin/settings': '系统设置'
}
+1
View File
@@ -53,6 +53,7 @@ const routes = [
{ path: 'banners', name: 'AdminBanners', component: () => import('../views/admin/Banners.vue') },
{ path: 'payment', name: 'AdminPayment', component: () => import('../views/admin/PaymentChannels.vue') },
{ path: 'logs', name: 'AdminLogs', component: () => import('../views/admin/Logs.vue') },
{ path: 'shipping-templates', name: 'AdminShippingTemplates', component: () => import('../views/admin/ShippingTemplates.vue') },
],
},
{
+21 -3
View File
@@ -133,6 +133,15 @@
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="运费模板">
<el-select v-model="form.shipping_template_id" placeholder="选择运费模板" clearable popper-class="dark-select-dropdown" style="width: 100%">
<el-option v-for="t in shippingTemplates" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
<div class="form-section">
@@ -171,6 +180,7 @@ import { getImageUrl } from '../../utils/image'
const products = ref<any[]>([])
const categories = ref<any[]>([])
const brands = ref<any[]>([])
const shippingTemplates = ref<any[]>([])
const loading = ref(false)
const saving = ref(false)
const showAdd = ref(false)
@@ -202,7 +212,8 @@ const form = reactive({
credit_reward: 0,
is_active: true,
category_ids: [] as number[],
brand_id: undefined as number | undefined
brand_id: undefined as number | undefined,
shipping_template_id: undefined as number | undefined
})
function getFirstImage(images: string) {
@@ -268,13 +279,18 @@ async function fetchBrands() {
brands.value = res.data || []
}
async function fetchShippingTemplates() {
const res: any = await adminApi.getShippingTemplates()
shippingTemplates.value = res.data || []
}
function openAdd() {
editing.value = null
Object.assign(form, {
name: '', description: '', price: 0, weight: 0, stock: 0,
min_purchase: 1, max_purchase: undefined,
require_credit: false, credit_cost: 0, credit_reward: 0,
is_active: true, category_ids: [], brand_id: undefined
is_active: true, category_ids: [], brand_id: undefined, shipping_template_id: undefined
})
fileList.value = []
uploadedUrls.value = []
@@ -296,7 +312,8 @@ function editProd(row: any) {
credit_reward: row.credit_reward || 0,
is_active: row.is_active !== false,
category_ids: (row.categories || []).map((c: any) => c.id),
brand_id: row.brand_id
brand_id: row.brand_id,
shipping_template_id: row.shipping_template_id
})
const images = row.images ? row.images.split(',').map((s: string) => s.trim()).filter(Boolean) : []
uploadedUrls.value = images
@@ -350,6 +367,7 @@ onMounted(() => {
fetchProducts()
fetchCategories()
fetchBrands()
fetchShippingTemplates()
})
</script>
@@ -0,0 +1,337 @@
<template>
<div class="shipping-templates-page">
<div class="page-header">
<el-button type="primary" @click="openAdd">创建运费模板</el-button>
</div>
<div class="table-card">
<el-table :data="templates" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="模板名称" min-width="150" />
<el-table-column label="计费方式" width="100">
<template #default="{ row }">
<el-tag :type="row.calc_type === 'weight' ? 'primary' : 'success'" size="small">
{{ row.calc_type === 'weight' ? '按重量' : '按件数' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="首重/首件" width="120">
<template #default="{ row }">
{{ row.first_unit }}{{ row.calc_type === 'weight' ? '克' : '件' }}
</template>
</el-table-column>
<el-table-column label="首费" width="100">
<template #default="{ row }">¥{{ row.first_fee }}</template>
</el-table-column>
<el-table-column label="续重/续件" width="120">
<template #default="{ row }">
{{ row.additional_unit }}{{ row.calc_type === 'weight' ? '克' : '件' }}
</template>
</el-table-column>
<el-table-column label="续费" width="100">
<template #default="{ row }">¥{{ row.additional_fee }}</template>
</el-table-column>
<el-table-column label="包邮金额" width="100">
<template #default="{ row }">
{{ row.free_amount > 0 ? '¥' + row.free_amount : '不包邮' }}
</template>
</el-table-column>
<el-table-column label="适用地区" min-width="150">
<template #default="{ row }">
<span v-if="!row.provinces_list || row.provinces_list.length === 0">全国</span>
<el-tag v-else v-for="p in row.provinces_list.slice(0, 3)" :key="p" size="small" style="margin-right: 4px;">{{ p }}</el-tag>
<span v-if="row.provinces_list && row.provinces_list.length > 3">...</span>
</template>
</el-table-column>
<el-table-column label="默认" width="80">
<template #default="{ row }">
<el-tag v-if="row.is_default" type="warning" size="small">默认</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="editTemplate(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="deleteTemplate(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog v-model="showAdd" :title="editing ? '编辑运费模板' : '创建运费模板'" width="600px">
<el-form :model="form" label-width="100px">
<el-form-item label="模板名称" required>
<el-input v-model="form.name" placeholder="如:江浙沪包邮、偏远地区" />
</el-form-item>
<el-form-item label="计费方式" required>
<el-radio-group v-model="form.calc_type">
<el-radio value="weight">按重量</el-radio>
<el-radio value="piece">按件数</el-radio>
</el-radio-group>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="首重/首件" required>
<el-input-number v-model="form.first_unit" :min="0.01" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="首费(元)" required>
<el-input-number v-model="form.first_fee" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="续重/续件" required>
<el-input-number v-model="form.additional_unit" :min="0.01" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="续费(元)" required>
<el-input-number v-model="form.additional_fee" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="包邮金额">
<el-input-number v-model="form.free_amount" :min="0" :precision="2" style="width: 200px" />
<span class="form-tip" style="margin-left: 8px; color: rgba(255,255,255,0.5);">0表示不包邮</span>
</el-form-item>
<el-form-item label="适用地区">
<el-select
v-model="form.provinces"
multiple
filterable
placeholder="留空表示全国"
popper-class="dark-select-dropdown"
style="width: 100%"
>
<el-option v-for="p in chinaProvinces" :key="p" :label="p" :value="p" />
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="设为默认">
<el-switch v-model="form.is_default" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" :min="0" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="showAdd = false">取消</el-button>
<el-button type="primary" @click="saveTemplate" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const templates = ref<any[]>([])
const loading = ref(false)
const saving = ref(false)
const showAdd = ref(false)
const editing = ref<any>(null)
// 中国省份列表
const chinaProvinces = [
'北京市', '天津市', '上海市', '重庆市',
'河北省', '山西省', '辽宁省', '吉林省', '黑龙江省',
'江苏省', '浙江省', '安徽省', '福建省', '江西省', '山东省',
'河南省', '湖北省', '湖南省', '广东省', '海南省',
'四川省', '贵州省', '云南省', '陕西省', '甘肃省', '青海省',
'台湾省', '内蒙古自治区', '广西壮族自治区', '西藏自治区',
'宁夏回族自治区', '新疆维吾尔自治区', '香港特别行政区', '澳门特别行政区'
]
const form = reactive({
name: '',
calc_type: 'weight',
first_unit: 500,
first_fee: 10,
additional_unit: 100,
additional_fee: 2,
free_amount: 0,
provinces: [] as string[],
is_default: false,
sort_order: 0
})
async function fetchTemplates() {
loading.value = true
try {
const res: any = await adminApi.getShippingTemplates()
templates.value = res.data || []
} finally {
loading.value = false
}
}
function openAdd() {
editing.value = null
Object.assign(form, {
name: '',
calc_type: 'weight',
first_unit: 500,
first_fee: 10,
additional_unit: 100,
additional_fee: 2,
free_amount: 0,
provinces: [],
is_default: false,
sort_order: 0
})
showAdd.value = true
}
function editTemplate(row: any) {
editing.value = row
Object.assign(form, {
name: row.name,
calc_type: row.calc_type,
first_unit: row.first_unit,
first_fee: row.first_fee,
additional_unit: row.additional_unit,
additional_fee: row.additional_fee,
free_amount: row.free_amount || 0,
provinces: row.provinces_list || [],
is_default: row.is_default,
sort_order: row.sort_order || 0
})
showAdd.value = true
}
async function saveTemplate() {
if (!form.name) {
ElMessage.warning('请输入模板名称')
return
}
saving.value = true
try {
const submitData = { ...form }
if (editing.value) {
await adminApi.updateShippingTemplate(editing.value.id, submitData)
} else {
await adminApi.createShippingTemplate(submitData)
}
ElMessage.success('保存成功')
showAdd.value = false
editing.value = null
await fetchTemplates()
} catch (error: any) {
const msg = error.response?.data?.error || '保存失败'
ElMessage.error(msg)
} finally {
saving.value = false
}
}
async function deleteTemplate(id: number) {
await ElMessageBox.confirm('确定删除此运费模板?', '确认删除')
try {
await adminApi.deleteShippingTemplate(id)
ElMessage.success('删除成功')
await fetchTemplates()
} catch (error: any) {
const msg = error.response?.data?.error || '删除失败'
ElMessage.error(msg)
}
}
onMounted(fetchTemplates)
</script>
<style scoped lang="scss">
.shipping-templates-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
:deep(.el-form-item__label) {
color: rgba(255, 255, 255, 0.7);
}
:deep(.el-input__wrapper),
:deep(.el-textarea__inner) {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: none;
&:hover, &:focus {
border-color: #4e6ef2;
}
}
:deep(.el-input__inner),
:deep(.el-textarea__inner) {
color: #fff;
&::placeholder {
color: rgba(255, 255, 255, 0.3);
}
}
:deep(.el-select .el-input__wrapper) {
background: rgba(255, 255, 255, 0.06);
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-input-number .el-input__wrapper) {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
}
:deep(.el-radio-button__inner) {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
}
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background: #4e6ef2;
border-color: #4e6ef2;
color: #fff;
}
:deep(.el-dialog) {
background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
}
:deep(.el-dialog__header) {
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 16px 20px;
margin: 0;
}
:deep(.el-dialog__title) {
color: #fff;
font-weight: 600;
}
:deep(.el-dialog__headerbtn .el-dialog__close) {
color: rgba(255, 255, 255, 0.5);
&:hover {
color: #fff;
}
}
:deep(.el-dialog__footer) {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 16px 20px;
}
:deep(.el-radio) {
color: rgba(255, 255, 255, 0.7);
}
</style>