feat(freight): shop freight templates, server-side checkout fees, company dictionary (add-freight-templates)

This commit is contained in:
Chengdong Zhang
2026-09-24 15:03:51 +08:00
parent 5b426486ac
commit 473d19d089
44 changed files with 2409 additions and 67 deletions
+449
View File
@@ -0,0 +1,449 @@
<script setup lang="ts">
import type {
FreightRegionRule,
FreightRegionRuleInput,
FreightTemplate,
FreightTemplateInput,
} from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { t: translate } = useI18n();
const { load: loadMoney, fmt, shopCurrency, majorToMinor, minorToMajor } = useMoney();
interface RuleForm {
regions: string;
firstFeeMajor: string;
firstUnit: string;
additionalFeeMajor: string;
additionalUnit: string;
}
const templates = ref<FreightTemplate[]>([]);
const loading = ref(true);
const saving = ref(false);
const actionId = ref("");
const error = ref("");
const message = ref("");
const editingId = ref("");
const form = reactive({
name: "",
pricingMethod: "by_piece" as FreightTemplate["pricing_method"],
firstFeeMajor: "0",
firstUnit: "1",
additionalFeeMajor: "0",
additionalUnit: "1",
freeThresholdMajor: "",
alwaysFree: false,
isDefault: false,
});
const rules = ref<RuleForm[]>([]);
/** Unit word (pcs / g) for the given pricing method; many table + form call sites. */
function unitOf(method: FreightTemplate["pricing_method"]): string {
return method === "by_weight" ? translate("freight.unitGram") : translate("freight.unitPiece");
}
function parseUnit(value: string): number | null {
const clean = value.trim();
return /^[1-9]\d*$/.test(clean) ? Number(clean) : null;
}
function toRuleInput(rule: FreightRegionRule): FreightRegionRuleInput {
return {
regions: rule.regions,
first_fee_minor: rule.first_fee_minor,
first_unit: rule.first_unit,
additional_fee_minor: rule.additional_fee_minor,
additional_unit: rule.additional_unit,
};
}
/** Validated payload, or the first validation error to show. */
function payload(): FreightTemplateInput | string {
const name = form.name.trim();
if (!name) return translate("freight.nameRequired");
const firstFee = majorToMinor(form.firstFeeMajor, shopCurrency.value);
const additionalFee = majorToMinor(form.additionalFeeMajor, shopCurrency.value);
if (firstFee === null || additionalFee === null) return translate("freight.feeInvalid");
const firstUnit = parseUnit(form.firstUnit);
const additionalUnit = parseUnit(form.additionalUnit);
if (firstUnit === null || additionalUnit === null) return translate("freight.unitInvalid");
let threshold: number | null = null;
if (form.freeThresholdMajor.trim()) {
const parsed = majorToMinor(form.freeThresholdMajor, shopCurrency.value);
if (parsed === null) return translate("freight.thresholdInvalid");
threshold = parsed;
}
const regionRules: FreightRegionRuleInput[] = [];
for (const rule of rules.value) {
const regions = rule.regions
.split(",")
.map((region) => region.trim())
.filter((region) => region.length > 0);
if (!regions.length) return translate("freight.ruleRegionsRequired");
const ruleFirstFee = majorToMinor(rule.firstFeeMajor, shopCurrency.value);
const ruleAdditionalFee = majorToMinor(rule.additionalFeeMajor, shopCurrency.value);
if (ruleFirstFee === null || ruleAdditionalFee === null) return translate("freight.feeInvalid");
const ruleFirstUnit = parseUnit(rule.firstUnit);
const ruleAdditionalUnit = parseUnit(rule.additionalUnit);
if (ruleFirstUnit === null || ruleAdditionalUnit === null)
return translate("freight.unitInvalid");
regionRules.push({
regions,
first_fee_minor: ruleFirstFee,
first_unit: ruleFirstUnit,
additional_fee_minor: ruleAdditionalFee,
additional_unit: ruleAdditionalUnit,
});
}
return {
name,
is_default: form.isDefault,
always_free: form.alwaysFree,
pricing_method: form.pricingMethod,
first_fee_minor: firstFee,
first_unit: firstUnit,
additional_fee_minor: additionalFee,
additional_unit: additionalUnit,
free_threshold_minor: threshold,
region_rules: regionRules,
};
}
function resetForm(): void {
editingId.value = "";
form.name = "";
form.pricingMethod = "by_piece";
form.firstFeeMajor = "0";
form.firstUnit = "1";
form.additionalFeeMajor = "0";
form.additionalUnit = "1";
form.freeThresholdMajor = "";
form.alwaysFree = false;
form.isDefault = false;
rules.value = [];
error.value = "";
}
function addRule(): void {
rules.value.push({
regions: "",
firstFeeMajor: "0",
firstUnit: "1",
additionalFeeMajor: "0",
additionalUnit: "1",
});
}
function removeRule(index: number): void {
if (!confirm(translate("freight.deleteRuleConfirm"))) return;
rules.value.splice(index, 1);
}
function edit(template: FreightTemplate): void {
editingId.value = template.id;
form.name = template.name;
form.pricingMethod = template.pricing_method;
form.firstFeeMajor = minorToMajor(template.first_fee_minor, shopCurrency.value);
form.firstUnit = String(template.first_unit);
form.additionalFeeMajor = minorToMajor(template.additional_fee_minor, shopCurrency.value);
form.additionalUnit = String(template.additional_unit);
form.freeThresholdMajor =
template.free_threshold_minor === null
? ""
: minorToMajor(template.free_threshold_minor, shopCurrency.value);
form.alwaysFree = template.always_free;
form.isDefault = template.is_default;
rules.value = template.region_rules.map((rule) => ({
regions: rule.regions.join(", "),
firstFeeMajor: minorToMajor(rule.first_fee_minor, shopCurrency.value),
firstUnit: String(rule.first_unit),
additionalFeeMajor: minorToMajor(rule.additional_fee_minor, shopCurrency.value),
additionalUnit: String(rule.additional_unit),
}));
error.value = "";
message.value = "";
}
async function loadTemplates(): Promise<void> {
loading.value = true;
error.value = "";
try {
templates.value = await $api.shop.listFreightTemplates();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
loading.value = false;
}
}
async function submit(): Promise<void> {
error.value = "";
message.value = "";
const body = payload();
if (typeof body === "string") {
error.value = body;
return;
}
saving.value = true;
try {
if (editingId.value) await $api.shop.updateFreightTemplate(editingId.value, body);
else await $api.shop.createFreightTemplate(body);
message.value = translate("freight.saved");
resetForm();
await loadTemplates();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
saving.value = false;
}
}
async function setDefault(template: FreightTemplate): Promise<void> {
actionId.value = template.id;
error.value = "";
try {
await $api.shop.updateFreightTemplate(template.id, {
name: template.name,
is_default: true,
always_free: template.always_free,
pricing_method: template.pricing_method,
first_fee_minor: template.first_fee_minor,
first_unit: template.first_unit,
additional_fee_minor: template.additional_fee_minor,
additional_unit: template.additional_unit,
free_threshold_minor: template.free_threshold_minor,
region_rules: template.region_rules.map(toRuleInput),
});
message.value = translate("freight.defaultSaved");
await loadTemplates();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
actionId.value = "";
}
}
async function remove(template: FreightTemplate): Promise<void> {
if (!confirm(translate("freight.deleteConfirm"))) return;
actionId.value = template.id;
error.value = "";
message.value = "";
try {
await $api.shop.deleteFreightTemplate(template.id);
message.value = translate("freight.deleted");
if (editingId.value === template.id) resetForm();
await loadTemplates();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
actionId.value = "";
}
}
onMounted(async () => {
loadMoney();
resetForm();
await loadTemplates();
});
</script>
<template>
<VPage :title="$t('freight.title')">
<div v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</div>
<div v-if="message" class="text-muted my-2 text-sm" role="status">{{ message }}</div>
<VCard class="mb-5">
<h2 class="mb-4 text-base font-semibold">
{{ editingId ? $t("freight.editTemplate") : $t("freight.newTemplate") }}
</h2>
<div class="my-3 grid [grid-template-columns:repeat(auto-fit,minmax(220px,1fr))] gap-3">
<VField :label="$t('freight.name')">
<VInput id="freight-name" v-model="form.name" type="text" />
</VField>
<VField :label="$t('freight.pricingMethod')">
<select
id="freight-pricing-method"
v-model="form.pricingMethod"
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
>
<option value="by_piece">{{ $t("freight.methodByPiece") }}</option>
<option value="by_weight">{{ $t("freight.methodByWeight") }}</option>
</select>
</VField>
<VField :label="`${$t('freight.firstFeeMajor')} · ${shopCurrency}`">
<VInput id="freight-first-fee" v-model="form.firstFeeMajor" inputmode="decimal" />
</VField>
<VField :label="`${$t('freight.firstUnit')} (${unitOf(form.pricingMethod)})`">
<VInput id="freight-first-unit" v-model="form.firstUnit" inputmode="numeric" />
</VField>
<VField :label="`${$t('freight.additionalFeeMajor')} · ${shopCurrency}`">
<VInput
id="freight-additional-fee"
v-model="form.additionalFeeMajor"
inputmode="decimal"
/>
</VField>
<VField :label="`${$t('freight.additionalUnit')} (${unitOf(form.pricingMethod)})`">
<VInput id="freight-additional-unit" v-model="form.additionalUnit" inputmode="numeric" />
</VField>
<VField :label="`${$t('freight.freeThresholdMajor')} · ${shopCurrency}`">
<VInput
id="freight-free-threshold"
v-model="form.freeThresholdMajor"
inputmode="decimal"
/>
</VField>
<div class="grid content-start gap-2">
<label class="text-text flex items-center gap-2 text-sm font-medium"
><input v-model="form.alwaysFree" type="checkbox" class="accent-primary h-4 w-4" />
{{ $t("freight.alwaysFree") }}</label
>
<label class="text-text flex items-center gap-2 text-sm font-medium"
><input v-model="form.isDefault" type="checkbox" class="accent-primary h-4 w-4" />
{{ $t("freight.isDefault") }}</label
>
</div>
</div>
<h3 class="mb-2 text-base font-semibold">{{ $t("freight.regionRules") }}</h3>
<div v-if="!rules.length" class="text-muted mb-3 text-sm">
{{ $t("freight.noRegionRules") }}
</div>
<div
v-for="(rule, index) in rules"
:key="index"
class="border-border bg-bg mb-3 rounded-md border p-3"
>
<div class="grid [grid-template-columns:repeat(auto-fit,minmax(200px,1fr))] gap-3">
<VField :label="$t('freight.ruleRegions')">
<VInput
:id="`rule-regions-${index}`"
v-model="rule.regions"
type="text"
:placeholder="$t('freight.ruleRegions')"
/>
</VField>
<VField :label="`${$t('freight.firstFeeMajor')} · ${shopCurrency}`">
<VInput
:id="`rule-first-fee-${index}`"
v-model="rule.firstFeeMajor"
inputmode="decimal"
/>
</VField>
<VField :label="`${$t('freight.firstUnit')} (${unitOf(form.pricingMethod)})`">
<VInput :id="`rule-first-unit-${index}`" v-model="rule.firstUnit" inputmode="numeric" />
</VField>
<VField :label="`${$t('freight.additionalFeeMajor')} · ${shopCurrency}`">
<VInput
:id="`rule-additional-fee-${index}`"
v-model="rule.additionalFeeMajor"
inputmode="decimal"
/>
</VField>
<VField :label="`${$t('freight.additionalUnit')} (${unitOf(form.pricingMethod)})`">
<VInput
:id="`rule-additional-unit-${index}`"
v-model="rule.additionalUnit"
inputmode="numeric"
/>
</VField>
</div>
<VBtn size="sm" variant="danger" @click="removeRule(index)">
{{ $t("freight.deleteRule") }}
</VBtn>
</div>
<div class="flex flex-wrap gap-2">
<VBtn @click="addRule">{{ $t("freight.addRule") }}</VBtn>
<VBtn variant="primary" :disabled="saving" @click="submit">
{{ editingId ? $t("freight.updateTemplate") : $t("freight.createTemplate") }}
</VBtn>
<VBtn v-if="editingId" @click="resetForm">{{ $t("common.cancel") }}</VBtn>
</div>
</VCard>
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
<VCard v-else-if="!templates.length" class="text-muted">{{ $t("common.empty") }}</VCard>
<VTable v-else>
<thead>
<tr>
<th>{{ $t("freight.name") }}</th>
<th>{{ $t("freight.pricingMethod") }}</th>
<th>
{{ $t("freight.firstFeeShort") }} / {{ $t("freight.additionalFeeShort") }}
</th>
<th>{{ $t("freight.freeThresholdMajor") }}</th>
<th>{{ $t("freight.isDefault") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="template in templates" :key="template.id">
<td>
<div class="font-medium">{{ template.name }}</div>
<ul v-if="template.region_rules.length" class="text-muted mt-1 grid gap-0.5 text-xs">
<li v-for="rule in template.region_rules" :key="rule.id">
{{ rule.regions.join(", ") }}:
{{ fmt(rule.first_fee_minor, shopCurrency) }}/{{ rule.first_unit }}
{{ unitOf(template.pricing_method) }}
+ {{ fmt(rule.additional_fee_minor, shopCurrency) }}/{{ rule.additional_unit }}
{{ unitOf(template.pricing_method) }}
</li>
</ul>
</td>
<td>
{{
template.pricing_method === "by_weight"
? $t("freight.methodByWeight")
: $t("freight.methodByPiece")
}}
</td>
<td>
<div>
{{ fmt(template.first_fee_minor, shopCurrency) }} / {{ template.first_unit }}
{{ unitOf(template.pricing_method) }}
</div>
<div>
+ {{ fmt(template.additional_fee_minor, shopCurrency) }} /
{{ template.additional_unit }} {{ unitOf(template.pricing_method) }}
</div>
</td>
<td>
<VBadge v-if="template.always_free" tone="green">{{ $t("freight.alwaysFree") }}</VBadge>
<span v-else-if="template.free_threshold_minor !== null">
{{ fmt(template.free_threshold_minor, shopCurrency) }}
</span>
<span v-else>—</span>
</td>
<td>
<VBadge v-if="template.is_default" tone="blue">{{ $t("freight.defaultTag") }}</VBadge>
<span v-else>—</span>
</td>
<td class="flex flex-wrap gap-1.5">
<VBtn size="sm" :disabled="actionId === template.id" @click="edit(template)">{{
$t("shop.edit")
}}</VBtn>
<VBtn
v-if="!template.is_default"
size="sm"
:disabled="actionId === template.id"
@click="setDefault(template)"
>{{ $t("freight.setDefault") }}</VBtn
>
<VBtn
size="sm"
variant="danger"
:disabled="actionId === template.id"
@click="remove(template)"
>{{ $t("common.delete") }}</VBtn
>
</td>
</tr>
</tbody>
</VTable>
</VPage>
</template>