feat(deploy): production deployment setup and fixes

- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma
  binaryTargets for openssl 3.0
- nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal
  cron, http->https redirect
- prisma: add origin_goods.delisted migration, sync missing schema
  (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz
- api: CORS wildcard reflection, helmet CORP cross-origin, price
  backfill in persistProductDetail, categoryIcon ancestor fallback,
  mediaByColor per-color gallery in public goods detail
- admin: /admin base path (vite + router)
- import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
yeuimu
2026-08-26 14:23:09 +08:00
parent be0b90e68f
commit 6c61a4e871
982 changed files with 74156 additions and 179393 deletions
+120 -120
View File
@@ -1,120 +1,120 @@
<script setup lang="ts">
const {
countryGroups,
loading,
loadingProducts,
currentProducts,
fetchCategories,
selectCountry,
} = usePodProducts();
const { countries: configuredCountries, categories: configuredCategories, loadFilters } = useProductCenter();
const selectedCountryId = shallowRef<string>();
async function initialize(): Promise<void> {
await Promise.all([fetchCategories(), loadFilters()]);
const firstCountry = configuredCountries.value[0];
if (firstCountry) switchCountry(firstCountry);
}
function switchCountry(country: { id: string; name: string }): void {
selectedCountryId.value = country.id;
const index = countryGroups.value.findIndex(
(group) => group.name === country.name || group.name.includes(country.name),
);
if (index >= 0) selectCountry(index);
}
function categoryLink(categoryName: string) {
const categoryId = findCategoryIdForPodName(categoryName, configuredCategories.value);
return {
path: '/product-center',
query: {
countryId: selectedCountryId.value,
...(categoryId ? { categoryId } : {}),
},
};
}
onMounted(() => void initialize());
</script>
<template>
<section class="pod-section w-full flex flex-col items-center p-6 lg:p-10 bg-inkreach-homepage-2">
<div class="pod-content w-full max-w-7xl flex flex-col gap-8 lg:gap-10">
<div class="text-[48px] font-extrabold md:text-4xl lg:text-5xl text-center">全球POD货盘</div>
<div v-if="loading" class="flex justify-center py-8">
<i class="fa-solid fa-spinner fa-spin text-3xl text-primary"></i>
</div>
<template v-else>
<div class="country-grid grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 pb-2">
<button
v-for="country in configuredCountries"
:key="country.id"
type="button"
class="country-button px-4 py-2 rounded-lg transition-colors whitespace-nowrap"
:class="selectedCountryId === country.id ? 'bg-primary text-white' : 'bg-white text-gray-600 hover:text-primary'"
@click="switchCountry(country)"
>
<span class="country-flag">
<img v-if="country.icon" :src="country.icon" :alt="country.name" />
</span>
<span>{{ country.name }}</span>
</button>
</div>
<div class="product-stage flex justify-center items-center min-h-[200px]">
<div v-if="loadingProducts" class="flex justify-center py-8">
<i class="fa-solid fa-spinner fa-spin text-2xl text-primary"></i>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3 lg:gap-5 w-full">
<NuxtLink
v-for="product in currentProducts"
:key="product.id"
:to="categoryLink(product.name)"
class="bg-white rounded-lg overflow-hidden"
>
<div class="h-[14.25rem] flex items-center justify-center">
<img
v-if="product.image"
:src="product.image"
:alt="product.name"
class="max-w-full max-h-full"
loading="lazy"
/>
<div v-else class="flex items-center justify-center text-gray-300">
<i class="fa-solid fa-image text-4xl"></i>
</div>
</div>
<div class="w-full text-xs lg:text-sm h-8 lg:h-10 flex justify-center items-center px-1 truncate">
<div class="truncate">{{ product.name }}</div>
</div>
</NuxtLink>
</div>
</div>
<div class="more-products w-full flex justify-center py-6 lg:py-10">
<NuxtLink
to="/product-center"
class="more-products-link text-primary text-base lg:text-lg font-medium hover:underline underline-offset-4"
>
更多产品
</NuxtLink>
</div>
</template>
</div>
</section>
</template>
<style scoped>
.country-button { display: grid; grid-template-columns: 22px auto; align-items: center; justify-content: center; column-gap: 8px; min-height: 40px; }
.country-flag { display: grid; place-items: center; width: 22px; height: 16px; }
.country-flag img { display: block; width: 22px; height: 16px; border-radius: 2px; object-fit: cover; }
.more-products { margin-top: auto; }
@media (min-width: 1024px) {
.pod-section { height: 1007px; padding-top: 54px; }
.pod-content { height: 100%; }
.product-stage { flex: 1; min-height: 0; align-items: flex-start; }
}
</style>
<script setup lang="ts">
const {
countryGroups,
loading,
loadingProducts,
currentProducts,
fetchCategories,
selectCountry,
} = usePodProducts();
const { countries: configuredCountries, categories: configuredCategories, loadFilters } = useProductCenter();
const selectedCountryId = shallowRef<string>();
async function initialize(): Promise<void> {
await Promise.all([fetchCategories(), loadFilters()]);
const firstCountry = configuredCountries.value[0];
if (firstCountry) switchCountry(firstCountry);
}
function switchCountry(country: { id: string; name: string }): void {
selectedCountryId.value = country.id;
const index = countryGroups.value.findIndex(
(group) => group.name === country.name || group.name.includes(country.name),
);
if (index >= 0) selectCountry(index);
}
function categoryLink(categoryName: string) {
const categoryId = findCategoryIdForPodName(categoryName, configuredCategories.value);
return {
path: '/product-center',
query: {
countryId: selectedCountryId.value,
...(categoryId ? { categoryId } : {}),
},
};
}
onMounted(() => void initialize());
</script>
<template>
<section class="pod-section w-full flex flex-col items-center p-6 lg:p-10 bg-inkreach-homepage-2">
<div class="pod-content w-full max-w-7xl flex flex-col gap-8 lg:gap-10">
<div class="text-[48px] font-extrabold md:text-4xl lg:text-5xl text-center">全球POD货盘</div>
<div v-if="loading" class="flex justify-center py-8">
<i class="fa-solid fa-spinner fa-spin text-3xl text-primary"></i>
</div>
<template v-else>
<div class="country-grid grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 pb-2">
<button
v-for="country in configuredCountries"
:key="country.id"
type="button"
class="country-button px-4 py-2 rounded-lg transition-colors whitespace-nowrap"
:class="selectedCountryId === country.id ? 'bg-primary text-white' : 'bg-white text-gray-600 hover:text-primary'"
@click="switchCountry(country)"
>
<span class="country-flag">
<img v-if="country.icon" :src="country.icon" :alt="country.name" />
</span>
<span>{{ country.name }}</span>
</button>
</div>
<div class="product-stage flex justify-center items-center min-h-[200px]">
<div v-if="loadingProducts" class="flex justify-center py-8">
<i class="fa-solid fa-spinner fa-spin text-2xl text-primary"></i>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3 lg:gap-5 w-full">
<NuxtLink
v-for="product in currentProducts"
:key="product.id"
:to="categoryLink(product.name)"
class="bg-white rounded-lg overflow-hidden"
>
<div class="h-[14.25rem] flex items-center justify-center">
<img
v-if="product.image"
:src="product.image"
:alt="product.name"
class="max-w-full max-h-full"
loading="lazy"
/>
<div v-else class="flex items-center justify-center text-gray-300">
<i class="fa-solid fa-image text-4xl"></i>
</div>
</div>
<div class="w-full text-xs lg:text-sm h-8 lg:h-10 flex justify-center items-center px-1 truncate">
<div class="truncate">{{ product.name }}</div>
</div>
</NuxtLink>
</div>
</div>
<div class="more-products w-full flex justify-center py-6 lg:py-10">
<NuxtLink
to="/product-center"
class="more-products-link text-primary text-base lg:text-lg font-medium hover:underline underline-offset-4"
>
更多产品
</NuxtLink>
</div>
</template>
</div>
</section>
</template>
<style scoped>
.country-button { display: grid; grid-template-columns: 22px auto; align-items: center; justify-content: center; column-gap: 8px; min-height: 40px; }
.country-flag { display: grid; place-items: center; width: 22px; height: 16px; }
.country-flag img { display: block; width: 22px; height: 16px; border-radius: 2px; object-fit: cover; }
.more-products { margin-top: auto; }
@media (min-width: 1024px) {
.pod-section { height: 1007px; padding-top: 54px; }
.pod-content { height: 100%; }
.product-stage { flex: 1; min-height: 0; align-items: flex-start; }
}
</style>