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
@@ -1,25 +1,25 @@
<script setup lang="ts">
const props = defineProps<{ total: number; page: number; pageSize: number }>();
const emit = defineEmits<{ 'update:page': [page: number]; 'update:pageSize': [size: number] }>();
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)));
const pages = computed<(number | string)[]>(() => totalPages.value <= 7 ? Array.from({ length: totalPages.value }, (_, i) => i + 1) : [1, 2, 3, 4, 5, '•••', totalPages.value]);
const goto = shallowRef('');
function go(page: number): void { if (page >= 1 && page <= totalPages.value && page !== props.page) emit('update:page', page); }
function jump(): void { go(Math.floor(Number(goto.value))); goto.value = ''; }
</script>
<template>
<div class="pagination">
<strong>总计{{ total }}个产品</strong>
<button type="button" :disabled="page <= 1" aria-label="上一页" @click="go(page - 1)"><i class="fa-solid fa-chevron-left"></i></button>
<template v-for="item in pages" :key="item"><span v-if="typeof item === 'string'" class="dots">{{ item }}</span><button v-else type="button" :class="{ current: item === page }" @click="go(item)">{{ item }}</button></template>
<button type="button" :disabled="page >= totalPages" aria-label="下一页" @click="go(page + 1)"><i class="fa-solid fa-chevron-right"></i></button>
<select :value="pageSize" aria-label="每页数量" @change="emit('update:pageSize', Number(($event.target as HTMLSelectElement).value))"><option :value="10">10 / 每页</option><option :value="20">20 / 每页</option><option :value="40">40 / 每页</option></select>
<label>导航至<input v-model="goto" type="number" min="1" :max="totalPages" @keydown.enter.prevent="jump" /></label>
</div>
</template>
<style scoped>
.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 32px 0; color: rgba(0,0,0,.85); font-size: 14px; }.pagination strong { margin-right: 2px; font-size: 13px; }
button { display: grid; place-items: center; min-width: 35px; height: 32px; border: 1px solid #d9d9d9; border-radius: 4px; background: #fff; cursor: pointer; }button.current { color: #ff6800; border-color: #ff6800; }button:disabled { opacity: .4; }.dots { width: 35px; text-align: center; color: rgba(0,0,0,.3); letter-spacing: 2px; }
select,input { height: 32px; border: 1px solid #d9d9d9; border-radius: 4px; background: #fff; }select { width: 112px; padding: 0 12px; }label { display: flex; align-items: center; gap: 8px; }input { width: 55px; padding: 0 8px; }
@media (max-width: 760px) { .pagination { flex-wrap: wrap; }.pagination strong { width: 100%; text-align: center; } }
</style>
<script setup lang="ts">
const props = defineProps<{ total: number; page: number; pageSize: number }>();
const emit = defineEmits<{ 'update:page': [page: number]; 'update:pageSize': [size: number] }>();
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)));
const pages = computed<(number | string)[]>(() => totalPages.value <= 7 ? Array.from({ length: totalPages.value }, (_, i) => i + 1) : [1, 2, 3, 4, 5, '•••', totalPages.value]);
const goto = shallowRef('');
function go(page: number): void { if (page >= 1 && page <= totalPages.value && page !== props.page) emit('update:page', page); }
function jump(): void { go(Math.floor(Number(goto.value))); goto.value = ''; }
</script>
<template>
<div class="pagination">
<strong>总计{{ total }}个产品</strong>
<button type="button" :disabled="page <= 1" aria-label="上一页" @click="go(page - 1)"><i class="fa-solid fa-chevron-left"></i></button>
<template v-for="item in pages" :key="item"><span v-if="typeof item === 'string'" class="dots">{{ item }}</span><button v-else type="button" :class="{ current: item === page }" @click="go(item)">{{ item }}</button></template>
<button type="button" :disabled="page >= totalPages" aria-label="下一页" @click="go(page + 1)"><i class="fa-solid fa-chevron-right"></i></button>
<select :value="pageSize" aria-label="每页数量" @change="emit('update:pageSize', Number(($event.target as HTMLSelectElement).value))"><option :value="10">10 / 每页</option><option :value="20">20 / 每页</option><option :value="40">40 / 每页</option></select>
<label>导航至<input v-model="goto" type="number" min="1" :max="totalPages" @keydown.enter.prevent="jump" /></label>
</div>
</template>
<style scoped>
.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 32px 0; color: rgba(0,0,0,.85); font-size: 14px; }.pagination strong { margin-right: 2px; font-size: 13px; }
button { display: grid; place-items: center; min-width: 35px; height: 32px; border: 1px solid #d9d9d9; border-radius: 4px; background: #fff; cursor: pointer; }button.current { color: #ff6800; border-color: #ff6800; }button:disabled { opacity: .4; }.dots { width: 35px; text-align: center; color: rgba(0,0,0,.3); letter-spacing: 2px; }
select,input { height: 32px; border: 1px solid #d9d9d9; border-radius: 4px; background: #fff; }select { width: 112px; padding: 0 12px; }label { display: flex; align-items: center; gap: 8px; }input { width: 55px; padding: 0 8px; }
@media (max-width: 760px) { .pagination { flex-wrap: wrap; }.pagination strong { width: 100%; text-align: center; } }
</style>