feat: update appraisal ordering and payment flows

This commit is contained in:
wushumin
2026-06-03 18:14:40 +08:00
parent 0838db5aba
commit 6383ec5a2a
50 changed files with 6143 additions and 988 deletions

View File

@@ -10,6 +10,7 @@ import {
type AdminOrderDetail,
type AdminOrderListItem,
type AdminOrderWarehouseOption,
type AdminServicePricePackage,
} from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
import { recognizeReturnAddress } from "../../utils/address-recognition";
@@ -36,7 +37,7 @@ const defaultExpressCompany = ref("");
const manualDialogVisible = ref(false);
const manualSubmitting = ref(false);
const manualMetaLoading = ref(false);
const manualMeta = ref<AdminManualOrderMeta>({ categories: [], brands: [] });
const manualMeta = ref<AdminManualOrderMeta>({ categories: [], brands: [], service_price_packages: [] });
const manualForm = ref<AdminManualOrderCreatePayload>(createManualOrderForm());
const manualAddressRecognitionText = ref("");
@@ -150,11 +151,16 @@ const expressCompanySelectOptions = computed(() => {
...expressCompanyOptions.value,
];
});
const manualServicePackages = computed<AdminServicePricePackage[]>(() =>
manualMeta.value.service_price_packages.find((item) => item.service_provider === manualForm.value.service_provider)?.packages.filter((item) => item.is_enabled) || [],
);
let returnRecognitionTimer: ReturnType<typeof setTimeout> | undefined;
function createManualOrderForm(): AdminManualOrderCreatePayload {
return {
service_provider: "anxinyan",
price_package_id: 0,
price_package_code: "",
product_info: {
category_id: 0,
brand_id: 0,
@@ -214,6 +220,7 @@ async function ensureManualMeta() {
try {
const response = await adminApi.getManualOrderMeta();
manualMeta.value = response.data;
applyManualDefaultPackage(true);
} catch (error) {
console.error(error);
ElMessage.error("补录订单选项加载失败");
@@ -222,11 +229,24 @@ async function ensureManualMeta() {
}
}
function applyManualDefaultPackage(force = false) {
const current = manualServicePackages.value.find((item) => item.id === manualForm.value.price_package_id);
if (current && !force) {
manualForm.value.price_package_code = current.package_code;
return;
}
const target = manualServicePackages.value.find((item) => item.is_default) || manualServicePackages.value[0];
manualForm.value.price_package_id = target?.id || 0;
manualForm.value.price_package_code = target?.package_code || "";
}
async function openManualDialog() {
manualForm.value = createManualOrderForm();
manualAddressRecognitionText.value = "";
manualDialogVisible.value = true;
await ensureManualMeta();
applyManualDefaultPackage(true);
}
async function ensureExpressCompanyOptions() {
@@ -260,6 +280,10 @@ function applyRecognizedManualAddress() {
function validateManualForm() {
const form = manualForm.value;
if (!form.price_package_id) {
ElMessage.warning("请选择价格套餐");
return false;
}
if (!form.product_info.category_id) {
ElMessage.warning("请选择品类");
return false;
@@ -474,6 +498,10 @@ watch(returnTrackingNo, () => {
}
});
watch(() => manualForm.value.service_provider, () => {
applyManualDefaultPackage(true);
});
onBeforeUnmount(() => {
if (returnRecognitionTimer) {
clearTimeout(returnRecognitionTimer);
@@ -523,6 +551,9 @@ onMounted(fetchOrders);
<el-table-column prop="appraisal_no" label="鉴定单号" min-width="180" />
<el-table-column prop="product_name" label="商品名称" min-width="220" />
<el-table-column prop="service_provider_text" label="服务类型" min-width="120" />
<el-table-column prop="price_package_name" label="价格套餐" min-width="150">
<template #default="{ row }">{{ row.price_package_name || "-" }}</template>
</el-table-column>
<el-table-column label="下单渠道" min-width="150">
<template #default="{ row }">
<span>{{ row.source_channel_text }}</span>
@@ -615,6 +646,10 @@ onMounted(fetchOrders);
<div class="order-detail-item__label">服务类型</div>
<div class="order-detail-item__value">{{ detail.order_info.service_provider_text }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">价格套餐</div>
<div class="order-detail-item__value">{{ detail.order_info.price_package_name || "-" }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">下单渠道</div>
<div class="order-detail-item__value">{{ detail.order_info.source_channel_text || "-" }}</div>
@@ -966,6 +1001,21 @@ onMounted(fetchOrders);
<el-option label="中检鉴定" value="zhongjian" />
</el-select>
</el-form-item>
<el-form-item label="价格套餐">
<el-select
v-model="manualForm.price_package_id"
style="width: 100%"
:disabled="manualServicePackages.length === 0"
@change="applyManualDefaultPackage(false)"
>
<el-option
v-for="item in manualServicePackages"
:key="item.id"
:label="`${item.package_name} / ¥${item.price}`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="品类">
<el-select v-model="manualForm.product_info.category_id" filterable style="width: 100%">
<el-option v-for="item in manualMeta.categories" :key="item.id" :label="item.name" :value="item.id" />

View File

@@ -0,0 +1,256 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { Edit, Plus, Star } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { adminApi, type AdminServicePricePackage, type AdminServicePricePayload } from "../../api/admin";
const loading = ref(false);
const submitting = ref(false);
const dialogVisible = ref(false);
const activeProvider = ref("anxinyan");
const providers = ref<Array<{ service_provider: string; service_provider_text: string; sla_hours: number }>>([]);
const packages = ref<AdminServicePricePackage[]>([]);
const form = reactive<AdminServicePricePayload>({
service_provider: "anxinyan",
package_name: "",
package_code: "",
price: 0,
description: "",
is_enabled: true,
is_default: false,
sort_order: 0,
});
const filteredPackages = computed(() => packages.value.filter((item) => item.service_provider === activeProvider.value));
const activeProviderText = computed(() => providerText(activeProvider.value));
function providerText(serviceProvider: string) {
return providers.value.find((item) => item.service_provider === serviceProvider)?.service_provider_text || serviceProvider;
}
function formatMoney(value: number) {
const amount = Number(value || 0);
return Number.isInteger(amount) ? amount.toFixed(0) : amount.toFixed(2);
}
function defaultPackageCode(serviceProvider: string) {
return serviceProvider === "zhongjian" ? "zhongjian_" : "anxinyan_";
}
async function fetchPackages() {
loading.value = true;
try {
const response = await adminApi.getServicePricePackages();
providers.value = response.data.providers;
packages.value = response.data.list;
if (!providers.value.some((item) => item.service_provider === activeProvider.value)) {
activeProvider.value = providers.value[0]?.service_provider || "anxinyan";
}
} catch (error) {
console.error(error);
ElMessage.error("服务价格加载失败");
} finally {
loading.value = false;
}
}
function resetForm(serviceProvider = activeProvider.value) {
form.id = undefined;
form.service_provider = serviceProvider;
form.package_name = "";
form.package_code = defaultPackageCode(serviceProvider);
form.price = 0;
form.description = "";
form.is_enabled = true;
form.is_default = filteredPackages.value.length === 0;
form.sort_order = filteredPackages.value.length + 1;
}
function openDialog(row?: AdminServicePricePackage) {
if (row) {
form.id = row.id;
form.service_provider = row.service_provider;
form.package_name = row.package_name;
form.package_code = row.package_code;
form.price = row.price;
form.description = row.description;
form.is_enabled = row.is_enabled;
form.is_default = row.is_default;
form.sort_order = row.sort_order;
} else {
resetForm(activeProvider.value);
}
dialogVisible.value = true;
}
async function submit() {
submitting.value = true;
try {
await adminApi.saveServicePricePackage({ ...form });
ElMessage.success(form.id ? "套餐已更新" : "套餐已创建");
dialogVisible.value = false;
await fetchPackages();
} catch (error) {
console.error(error);
ElMessage.error("套餐保存失败");
} finally {
submitting.value = false;
}
}
async function updateStatus(row: AdminServicePricePackage, isEnabled: boolean) {
try {
await adminApi.updateServicePricePackageStatus(row.id, isEnabled);
ElMessage.success(isEnabled ? "套餐已启用" : "套餐已停用");
await fetchPackages();
} catch (error) {
console.error(error);
ElMessage.error("套餐状态更新失败");
await fetchPackages();
}
}
async function setDefault(row: AdminServicePricePackage) {
try {
await ElMessageBox.confirm(`确认将「${row.package_name}」设为${row.service_provider_text}默认套餐?`, "设置默认套餐", {
type: "warning",
confirmButtonText: "确认",
cancelButtonText: "取消",
});
await adminApi.setDefaultServicePricePackage(row.id);
ElMessage.success("默认套餐已更新");
await fetchPackages();
} catch (error) {
if (error !== "cancel") {
console.error(error);
ElMessage.error("默认套餐设置失败");
}
}
}
onMounted(fetchPackages);
</script>
<template>
<div v-loading="loading" class="service-prices-page">
<el-card class="panel-card" shadow="never">
<div class="service-prices-toolbar">
<el-tabs v-model="activeProvider" class="service-prices-tabs">
<el-tab-pane v-for="provider in providers" :key="provider.service_provider" :label="provider.service_provider_text" :name="provider.service_provider" />
</el-tabs>
<el-button type="primary" :icon="Plus" @click="openDialog()">新增套餐</el-button>
</div>
</el-card>
<el-card class="panel-card orders-table" shadow="never">
<el-table :data="filteredPackages" stripe>
<el-table-column prop="package_name" label="套餐名称" min-width="180" />
<el-table-column prop="package_code" label="套餐编码" min-width="160" />
<el-table-column label="服务方" min-width="120">
<template #default="{ row }">{{ row.service_provider_text }}</template>
</el-table-column>
<el-table-column label="价格" min-width="120">
<template #default="{ row }">¥{{ formatMoney(row.price) }}</template>
</el-table-column>
<el-table-column label="状态" min-width="160">
<template #default="{ row }">
<el-space>
<el-tag :type="row.is_enabled ? 'success' : 'info'">{{ row.is_enabled ? "启用" : "停用" }}</el-tag>
<el-tag v-if="row.is_default" type="warning">默认</el-tag>
</el-space>
</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" min-width="90" />
<el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip />
<el-table-column label="操作" fixed="right" width="250">
<template #default="{ row }">
<el-button link type="primary" :icon="Edit" @click="openDialog(row)">编辑</el-button>
<el-button link :type="row.is_enabled ? 'warning' : 'success'" @click="updateStatus(row, !row.is_enabled)">
{{ row.is_enabled ? "停用" : "启用" }}
</el-button>
<el-button link type="primary" :icon="Star" :disabled="row.is_default || !row.is_enabled" @click="setDefault(row)">设默认</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty :description="`${activeProviderText}暂无价格套餐`" />
</template>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑服务价格套餐' : '新增服务价格套餐'" width="640px" destroy-on-close>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="服务方">
<el-select v-model="form.service_provider" :disabled="Boolean(form.id)" style="width: 100%">
<el-option v-for="provider in providers" :key="provider.service_provider" :label="provider.service_provider_text" :value="provider.service_provider" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序">
<el-input v-model.number="form.sort_order" type="number" placeholder="越小越靠前" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="套餐名称">
<el-input v-model="form.package_name" maxlength="128" placeholder="例如 安心验基础套餐" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="套餐编码">
<el-input v-model="form.package_code" maxlength="64" placeholder="例如 anxinyan_basic" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="价格">
<el-input-number v-model="form.price" :min="0" :precision="2" :step="1" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-switch v-model="form.is_enabled" inline-prompt active-text="启用" inactive-text="停用" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="说明">
<el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="可填写适用范围或套餐说明" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="默认套餐">
<el-switch v-model="form.is_default" :disabled="!form.is_enabled" inline-prompt active-text="默认" inactive-text="普通" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.service-prices-page {
min-width: 0;
}
.service-prices-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.service-prices-tabs {
flex: 1;
min-width: 0;
}
.service-prices-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
</style>

View File

@@ -86,13 +86,14 @@ function buildH5OAuthRedirectUrl(pageBaseUrl: string) {
}
function applyDerivedConfigValues(group: AdminSystemConfigGroupItem) {
if (group.group_code !== "h5") return;
if (group.group_code === "h5") {
const pageBaseUrl = group.items.find((item) => item.config_key === "page_base_url");
const oauthRedirectUrl = group.items.find((item) => item.config_key === "oauth_redirect_url");
if (!oauthRedirectUrl) return;
const pageBaseUrl = group.items.find((item) => item.config_key === "page_base_url");
const oauthRedirectUrl = group.items.find((item) => item.config_key === "oauth_redirect_url");
if (!oauthRedirectUrl) return;
oauthRedirectUrl.value = buildH5OAuthRedirectUrl(pageBaseUrl?.value || "");
oauthRedirectUrl.value = buildH5OAuthRedirectUrl(pageBaseUrl?.value || "");
return;
}
}
function handleFieldValueChange(
@@ -120,7 +121,7 @@ async function saveGroup(group: AdminSystemConfigGroupItem) {
ElMessage.success(`${group.group_name}已保存`);
} catch (error) {
console.error(error);
ElMessage.error(`${group.group_name}保存失败`);
ElMessage.error(error instanceof Error ? error.message : `${group.group_name}保存失败`);
} finally {
savingGroupCode.value = "";
}
@@ -262,7 +263,7 @@ onMounted(fetchConfigs);
v-else-if="item.field_type !== 'textarea'"
:model-value="item.value"
:type="item.field_type === 'password' ? 'password' : 'text'"
show-password
:show-password="item.field_type === 'password'"
:disabled="item.read_only"
:placeholder="item.placeholder"
@update:model-value="handleFieldValueChange(group, item, $event)"