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
+6
View File
@@ -42,6 +42,12 @@ watchEffect(() => {
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
>{{ $t("nav.shopProfile") }}</NuxtLink
>
<NuxtLink
to="/freight-templates"
active-class="bg-primary-soft text-primary"
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
>{{ $t("nav.freightTemplates") }}</NuxtLink
>
<NuxtLink
to="/orders"
active-class="bg-primary-soft text-primary"
+17 -1
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { t as localized } from "@vmall/shared";
import type { Category, Product, ProductUpsertBody } from "@vmall/shared";
import type { Category, FreightTemplate, Product, ProductUpsertBody } from "@vmall/shared";
const props = defineProps<{
product?: Product;
categories: Category[];
freightTemplates: FreightTemplate[];
busy?: boolean;
}>();
const emit = defineEmits<{ submit: [body: ProductUpsertBody] }>();
@@ -16,6 +17,7 @@ const nameZh = ref("");
const descriptionEn = ref("");
const descriptionZh = ref("");
const categoryId = ref("");
const freightTemplateId = ref("");
const images = ref("");
const validationError = ref("");
@@ -26,6 +28,7 @@ function resetFromProduct(product: Product | undefined): void {
descriptionEn.value = product?.description.en ?? "";
descriptionZh.value = product?.description.zh ?? "";
categoryId.value = product?.category_id ?? "";
freightTemplateId.value = product?.freight_template_id ?? "";
images.value = product?.images.join("\n") ?? "";
validationError.value = "";
}
@@ -45,6 +48,7 @@ function submit(): void {
validationError.value = "";
const body: ProductUpsertBody = {
category_id: categoryId.value || null,
freight_template_id: freightTemplateId.value || null,
slug: cleanSlug,
name: { en: nameEn.value.trim(), zh: nameZh.value.trim() },
description: { en: descriptionEn.value.trim(), zh: descriptionZh.value.trim() },
@@ -100,6 +104,18 @@ function submit(): void {
</option>
</select>
</VField>
<VField :label="$t('shop.freightTemplate')">
<select
id="product-freight-template"
v-model="freightTemplateId"
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="">{{ $t("shop.freightTemplateDefault") }}</option>
<option v-for="template in freightTemplates" :key="template.id" :value="template.id">
{{ template.name }}
</option>
</select>
</VField>
<VField :label="$t('product.images')">
<textarea
id="product-images"
+34 -4
View File
@@ -16,10 +16,40 @@ export function useMoney() {
}
}
function fmt(amountMinor: number, currency: string): string {
const exponent = currencies.value.find((c) => c.code === currency)?.exponent ?? 2;
return formatMoney(amountMinor, currency, exponent, locale.value);
function exponentOf(currency: string): number {
return currencies.value.find((c) => c.code === currency)?.exponent ?? 2;
}
return { currencies, load, fmt };
function fmt(amountMinor: number, currency: string): string {
return formatMoney(amountMinor, currency, exponentOf(currency), locale.value);
}
/** Currency for money fields without an explicit currency: the table's base. */
const shopCurrency = computed(
() => currencies.value.find((c) => c.is_base)?.code ?? currencies.value[0]?.code ?? "USD",
);
/** Parse a major-unit decimal string into integer minor units; null when invalid. */
function majorToMinor(value: string, currency: string): number | null {
const clean = value.trim();
if (!/^\d+(?:\.\d+)?$/.test(clean)) return null;
const exponent = exponentOf(currency);
const [whole, fraction = ""] = clean.split(".");
if (fraction.length > exponent) return null;
const minor = Number(whole) * 10 ** exponent + Number(fraction.padEnd(exponent, "0") || 0);
return Number.isSafeInteger(minor) ? minor : null;
}
/** Render integer minor units as a major-unit string for form inputs. */
function minorToMajor(minor: number, currency: string): string {
const exponent = exponentOf(currency);
const sign = minor < 0 ? "-" : "";
const abs = Math.abs(minor);
const whole = Math.trunc(abs / 10 ** exponent);
if (exponent === 0) return `${sign}${whole}`;
const fraction = (abs % 10 ** exponent).toString().padStart(exponent, "0");
return `${sign}${whole}.${fraction}`;
}
return { currencies, load, fmt, shopCurrency, majorToMinor, minorToMajor };
}
+97
View File
@@ -10,6 +10,7 @@ export const enExtra = {
groupBuying: "Group buying",
shopProfile: "Shop profile",
aftersales: "After-sales",
freightTemplates: "Freight templates",
},
shop: {
profileSaved: "Shop profile saved.",
@@ -56,7 +57,13 @@ export const enExtra = {
skuPriceMajor: "Price (major units)",
stock: "Stock",
active: "Active",
addSku: "Add SKU",
skuSaved: "SKU saved.",
skuWeight: "Weight (g)",
skuWeightGrams: "Weight (grams, optional)",
skuWeightInvalid: "Weight must be a positive integer.",
freightTemplate: "Freight template",
freightTemplateDefault: "Shop default",
actions: "Actions",
edit: "Edit",
invoiceNo: "Invoice no.",
@@ -71,6 +78,9 @@ export const enExtra = {
phone: "Phone",
carrier: "Carrier",
trackingNo: "Tracking number",
shippingCompany: "Shipping company",
selectShippingCompany: "Select a shipping company",
shippingCompanyRequired: "Choose a shipping company.",
shipmentQuantities: "Shipment quantities",
unshipped: "Unshipped",
createShipment: "Create shipment",
@@ -160,6 +170,45 @@ export const enExtra = {
groupLifetimeInvalid: "Group lifetime must be a positive number of hours.",
groupWindowInvalid: "End must not precede start.",
},
freight: {
title: "Freight templates",
newTemplate: "New template",
editTemplate: "Edit template",
name: "Template name",
pricingMethod: "Pricing method",
methodByPiece: "By piece",
methodByWeight: "By weight",
unitPiece: "pcs",
unitGram: "g",
firstFeeShort: "First fee",
additionalFeeShort: "Additional fee",
firstFeeMajor: "First fee (major units)",
additionalFeeMajor: "Additional fee (major units)",
firstUnit: "First unit",
additionalUnit: "Additional unit",
freeThresholdMajor: "Free shipping over (major units, optional)",
alwaysFree: "Always free",
isDefault: "Default template",
defaultTag: "Default",
createTemplate: "Create template",
updateTemplate: "Update template",
saved: "Freight template saved.",
deleted: "Freight template deleted.",
setDefault: "Set as default",
defaultSaved: "Default template updated.",
nameRequired: "Template name is required.",
feeInvalid: "Fees must be non-negative amounts.",
unitInvalid: "Unit sizes must be positive integers.",
thresholdInvalid: "Free-shipping threshold must be a non-negative amount.",
regionRules: "Region rules",
noRegionRules: "No region rules yet.",
addRule: "Add rule",
ruleRegions: "Regions (comma separated)",
ruleRegionsRequired: "Each rule needs at least one region.",
deleteRule: "Delete rule",
deleteRuleConfirm: "Delete this region rule?",
deleteConfirm: "Delete this freight template?",
},
aftersale: {
title: "After-sales",
detail: "After-sale detail",
@@ -222,6 +271,7 @@ export const zhExtra = {
groupBuying: "拼团",
shopProfile: "店铺资料",
aftersales: "售后",
freightTemplates: "运费模板",
},
shop: {
profileSaved: "店铺资料已保存。",
@@ -269,6 +319,11 @@ export const zhExtra = {
stock: "库存",
active: "启用",
skuSaved: "SKU 已保存。",
skuWeight: "重量(克)",
skuWeightGrams: "重量(克,可选)",
skuWeightInvalid: "重量必须为正整数。",
freightTemplate: "运费模板",
freightTemplateDefault: "店铺默认",
actions: "操作",
edit: "编辑",
addSku: "添加 SKU",
@@ -283,6 +338,9 @@ export const zhExtra = {
phone: "电话",
carrier: "承运商",
trackingNo: "物流单号",
shippingCompany: "物流公司",
selectShippingCompany: "请选择物流公司",
shippingCompanyRequired: "请选择物流公司。",
shipmentQuantities: "发货数量",
unshipped: "待发货",
createShipment: "创建发货单",
@@ -372,6 +430,45 @@ export const zhExtra = {
groupLifetimeInvalid: "团有效期必须为正数小时。",
groupWindowInvalid: "结束时间不能早于开始时间。",
},
freight: {
title: "运费模板",
newTemplate: "新建运费模板",
editTemplate: "编辑运费模板",
name: "模板名称",
pricingMethod: "计价方式",
methodByPiece: "按件",
methodByWeight: "按重量",
unitPiece: "件",
unitGram: "克",
firstFeeShort: "首费",
additionalFeeShort: "续费",
firstFeeMajor: "首费(主单位)",
additionalFeeMajor: "续费(主单位)",
firstUnit: "首段单位量",
additionalUnit: "续段单位量",
freeThresholdMajor: "满额免运费(主单位,可留空)",
alwaysFree: "全程包邮",
isDefault: "默认模板",
defaultTag: "默认",
createTemplate: "创建模板",
updateTemplate: "更新模板",
saved: "运费模板已保存。",
deleted: "运费模板已删除。",
setDefault: "设为默认",
defaultSaved: "默认模板已更新。",
nameRequired: "模板名称为必填项。",
feeInvalid: "费用必须为非负金额。",
unitInvalid: "单位量必须为正整数。",
thresholdInvalid: "免运费门槛必须为非负金额。",
regionRules: "地区规则",
noRegionRules: "暂无地区规则。",
addRule: "添加规则",
ruleRegions: "地区(逗号分隔)",
ruleRegionsRequired: "每条规则至少填写一个地区。",
deleteRule: "删除规则",
deleteRuleConfirm: "确定删除该地区规则?",
deleteConfirm: "确定删除该运费模板?",
},
aftersale: {
title: "售后",
detail: "售后详情",
+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>
+41 -4
View File
@@ -1,6 +1,12 @@
<script setup lang="ts">
import { t as localized } from "@vmall/shared";
import type { Order, OrderStatus, Shipment, ShipmentItemBody } from "@vmall/shared";
import type {
Order,
OrderStatus,
Shipment,
ShipmentItemBody,
ShippingCompany,
} from "@vmall/shared";
definePageMeta({ middleware: "auth" });
@@ -11,9 +17,11 @@ const route = useRoute();
const orderId = computed(() => String(route.params.id));
const order = ref<Order | null>(null);
const shipments = ref<Shipment[]>([]);
const companies = ref<ShippingCompany[]>([]);
const quantities = ref<Record<string, number>>({});
const carrier = ref("");
const trackingNo = ref("");
const shippingCompanyCode = ref("");
const loading = ref(true);
const busy = ref(false);
const actionId = ref("");
@@ -34,10 +42,16 @@ function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function companyName(code: string | null | undefined): string {
if (!code) return "—";
const company = companies.value.find((item) => item.code === code);
return company ? localized(company.name, locale.value) : code;
}
function shippedQuantity(itemId: string): number {
return shipments.value
.filter((shipment) => shipment.order_id === orderId.value)
.flatMap((shipment) => shipment.items)
.flatMap((shipment) => shipment.items ?? [])
.filter((item) => item.order_item_id === itemId)
.reduce((total, item) => total + item.qty, 0);
}
@@ -63,12 +77,14 @@ async function loadOrder(): Promise<void> {
loading.value = true;
error.value = "";
try {
const [loadedOrder, loadedShipments] = await Promise.all([
const [loadedOrder, loadedShipments, loadedCompanies] = await Promise.all([
findShopOrder(),
$api.shop.listShipments(),
$api.listShippingCompanies(),
]);
order.value = loadedOrder;
shipments.value = loadedShipments.filter((shipment) => shipment.order_id === orderId.value);
companies.value = loadedCompanies;
const nextQuantities: Record<string, number> = {};
loadedOrder.items.forEach((item) => {
nextQuantities[item.id] = Math.max(
@@ -76,7 +92,7 @@ async function loadOrder(): Promise<void> {
item.qty -
loadedShipments
.filter((shipment) => shipment.order_id === orderId.value)
.flatMap((shipment) => shipment.items)
.flatMap((shipment) => shipment.items ?? [])
.filter((shipmentItem) => shipmentItem.order_item_id === item.id)
.reduce((total, shipmentItem) => total + shipmentItem.qty, 0),
);
@@ -94,6 +110,10 @@ async function createShipment(): Promise<void> {
error.value = translate("common.required");
return;
}
if (!shippingCompanyCode.value) {
error.value = translate("shop.shippingCompanyRequired");
return;
}
const items: ShipmentItemBody[] = order.value.items
.map((item) => ({ order_item_id: item.id, qty: quantities.value[item.id] ?? 0 }))
.filter((item) => item.qty > 0);
@@ -109,9 +129,11 @@ async function createShipment(): Promise<void> {
carrier.value.trim(),
trackingNo.value.trim(),
items,
shippingCompanyCode.value,
);
carrier.value = "";
trackingNo.value = "";
shippingCompanyCode.value = "";
await loadOrder();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
@@ -208,6 +230,19 @@ onMounted(() => {
<VField :label="$t('shop.trackingNo')">
<VInput id="tracking-no" v-model="trackingNo" required />
</VField>
<VField :label="$t('shop.shippingCompany')">
<select
id="shipping-company"
v-model="shippingCompanyCode"
required
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="" disabled>{{ $t("shop.selectShippingCompany") }}</option>
<option v-for="company in companies" :key="company.code" :value="company.code">
{{ localized(company.name, locale) }}
</option>
</select>
</VField>
</div>
<h3 class="mb-3 text-base font-semibold">{{ $t("shop.shipmentQuantities") }}</h3>
<div
@@ -246,6 +281,7 @@ onMounted(() => {
<tr>
<th>{{ $t("shipment.shipmentNo") }}</th>
<th>{{ $t("shop.carrier") }}</th>
<th>{{ $t("shop.shippingCompany") }}</th>
<th>{{ $t("shop.trackingNo") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("common.actions") }}</th>
@@ -255,6 +291,7 @@ onMounted(() => {
<tr v-for="shipment in shipments" :key="shipment.id">
<td>{{ shipment.shipment_no }}</td>
<td>{{ shipment.carrier }}</td>
<td>{{ companyName(shipment.shipping_company_code) }}</td>
<td>{{ shipment.tracking_no }}</td>
<td>
<VBadge :tone="shipmentStatusClass(shipment.status)">{{
+33 -3
View File
@@ -1,5 +1,12 @@
<script setup lang="ts">
import type { Category, Product, ProductUpsertBody, Sku, SkuUpsertBody } from "@vmall/shared";
import type {
Category,
FreightTemplate,
Product,
ProductUpsertBody,
Sku,
SkuUpsertBody,
} from "@vmall/shared";
definePageMeta({ middleware: "auth" });
@@ -10,6 +17,7 @@ const route = useRoute();
const productId = computed(() => String(route.params.id));
const product = ref<Product | null>(null);
const categories = ref<Category[]>([]);
const freightTemplates = ref<FreightTemplate[]>([]);
const loading = ref(true);
const busy = ref(false);
const skuBusy = ref(false);
@@ -18,6 +26,7 @@ const skuCode = ref("");
const skuPrice = ref("");
const skuCurrency = ref("USD");
const skuStock = ref("0");
const skuWeight = ref("");
const skuActive = ref(true);
function formatDate(value: string): string {
@@ -40,12 +49,14 @@ async function loadProduct(): Promise<void> {
loading.value = true;
error.value = "";
try {
const [loadedProduct, loadedCategories] = await Promise.all([
const [loadedProduct, loadedCategories, loadedFreightTemplates] = await Promise.all([
$api.shop.getProduct(productId.value),
$api.listCategories(),
$api.shop.listFreightTemplates(),
]);
product.value = loadedProduct;
categories.value = loadedCategories;
freightTemplates.value = loadedFreightTemplates;
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
@@ -69,10 +80,21 @@ async function updateProduct(body: ProductUpsertBody): Promise<void> {
async function saveSku(): Promise<void> {
const priceMinor = majorToMinor(skuPrice.value);
const stock = Number(skuStock.value);
if (!skuCode.value.trim() || priceMinor === null || !Number.isInteger(stock) || stock < 0) {
const weightText = skuWeight.value.trim();
const weight = weightText ? Number(weightText) : null;
if (
!skuCode.value.trim() ||
priceMinor === null ||
!Number.isInteger(stock) ||
stock < 0
) {
error.value = translate("common.required");
return;
}
if (weight !== null && (!Number.isInteger(weight) || weight <= 0)) {
error.value = translate("shop.skuWeightInvalid");
return;
}
skuBusy.value = true;
error.value = "";
const body: SkuUpsertBody = {
@@ -81,6 +103,7 @@ async function saveSku(): Promise<void> {
currency: skuCurrency.value,
stock,
active: skuActive.value,
weight_grams: weight,
};
try {
await $api.shop.upsertSku(productId.value, body);
@@ -89,6 +112,7 @@ async function saveSku(): Promise<void> {
skuPrice.value = "";
skuCurrency.value = "USD";
skuStock.value = "0";
skuWeight.value = "";
skuActive.value = true;
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
@@ -118,6 +142,7 @@ onMounted(() => {
<ProductForm
:product="product"
:categories="categories"
:freight-templates="freightTemplates"
:busy="busy"
@submit="updateProduct"
/>
@@ -131,6 +156,7 @@ onMounted(() => {
<th>{{ $t("common.price") }}</th>
<th>{{ $t("common.currency") }}</th>
<th>{{ $t("shop.stock") }}</th>
<th>{{ $t("shop.skuWeight") }}</th>
<th>{{ $t("shop.active") }}</th>
</tr>
</thead>
@@ -140,6 +166,7 @@ onMounted(() => {
<td>{{ formatSkuPrice(sku) }}</td>
<td>{{ sku.currency }}</td>
<td>{{ sku.stock }}</td>
<td>{{ sku.weight_grams ?? "—" }}</td>
<td>
<VBadge :tone="sku.active ? 'green' : 'red'">{{
sku.active ? $t("common.yes") : $t("common.no")
@@ -171,6 +198,9 @@ onMounted(() => {
<VField :label="$t('shop.stock')">
<VInput id="sku-stock" v-model="skuStock" type="number" min="0" step="1" required />
</VField>
<VField :label="$t('shop.skuWeightGrams')">
<VInput id="sku-weight" v-model="skuWeight" inputmode="numeric" />
</VField>
</div>
<label class="text-text mb-3.5 flex items-center gap-2 text-sm font-medium"
><input v-model="skuActive" type="checkbox" class="accent-primary h-4 w-4" />
+17 -5
View File
@@ -1,18 +1,24 @@
<script setup lang="ts">
import type { Category, ProductUpsertBody } from "@vmall/shared";
import type { Category, FreightTemplate, ProductUpsertBody } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { t: translate } = useI18n();
const categories = ref<Category[]>([]);
const freightTemplates = ref<FreightTemplate[]>([]);
const loading = ref(true);
const busy = ref(false);
const error = ref("");
async function loadCategories(): Promise<void> {
async function loadFormOptions(): Promise<void> {
try {
categories.value = await $api.listCategories();
const [loadedCategories, loadedFreightTemplates] = await Promise.all([
$api.listCategories(),
$api.shop.listFreightTemplates(),
]);
categories.value = loadedCategories;
freightTemplates.value = loadedFreightTemplates;
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
@@ -33,7 +39,7 @@ async function saveProduct(body: ProductUpsertBody): Promise<void> {
}
}
onMounted(loadCategories);
onMounted(loadFormOptions);
</script>
<template>
@@ -47,6 +53,12 @@ onMounted(loadCategories);
</template>
<div v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</div>
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
<ProductForm v-else :categories="categories" :busy="busy" @submit="saveProduct" />
<ProductForm
v-else
:categories="categories"
:freight-templates="freightTemplates"
:busy="busy"
@submit="saveProduct"
/>
</VPage>
</template>
+17 -2
View File
@@ -1,15 +1,23 @@
<script setup lang="ts">
import type { Shipment } from "@vmall/shared";
import { t as localized } from "@vmall/shared";
import type { Shipment, ShippingCompany } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t: translate } = useI18n();
const shipments = ref<Shipment[]>([]);
const companies = ref<ShippingCompany[]>([]);
const loading = ref(true);
const error = ref("");
const actionId = ref("");
function companyName(code: string | null | undefined): string {
if (!code) return "—";
const company = companies.value.find((item) => item.code === code);
return company ? localized(company.name, locale.value) : code;
}
function statusClass(value: Shipment["status"]): "green" | "blue" | "orange" {
return value === "delivered" ? "green" : value === "shipped" ? "blue" : "orange";
}
@@ -22,7 +30,12 @@ async function loadShipments(): Promise<void> {
loading.value = true;
error.value = "";
try {
shipments.value = await $api.shop.listShipments();
const [loadedShipments, loadedCompanies] = await Promise.all([
$api.shop.listShipments(),
$api.listShippingCompanies(),
]);
shipments.value = loadedShipments;
companies.value = loadedCompanies;
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : translate("common.error");
} finally {
@@ -57,6 +70,7 @@ onMounted(loadShipments);
<th>{{ $t("shipment.shipmentNo") }}</th>
<th>{{ $t("shop.orderNo") }}</th>
<th>{{ $t("shop.carrier") }}</th>
<th>{{ $t("shop.shippingCompany") }}</th>
<th>{{ $t("shop.trackingNo") }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ $t("shop.created") }}</th>
@@ -72,6 +86,7 @@ onMounted(loadShipments);
}}</NuxtLink>
</td>
<td>{{ shipment.carrier }}</td>
<td>{{ companyName(shipment.shipping_company_code) }}</td>
<td>{{ shipment.tracking_no }}</td>
<td>
<VBadge :tone="statusClass(shipment.status)">{{