商城c端前端

This commit is contained in:
richjohnrambo
2026-10-12 23:40:02 +08:00
parent dd729cb679
commit 96d1de4a95
105 changed files with 6762 additions and 6 deletions
+150
View File
@@ -0,0 +1,150 @@
<template>
<section v-if="!isDetail" class="m-task-mall" :style="{ backgroundImage: `url(${mobileAssets.mallBackground})` }">
<header class="m-mall-search">
<h1>商城</h1>
<label><n-icon :component="SearchOutline" /><input v-model.trim="keyword" placeholder="搜索商品" /></label>
<button type="button" @click="applySearch">搜索</button>
</header>
<img class="m-mall-banner" :src="mobileAssets.mallBanner" alt="精选任务,轻松赚收益" />
<div class="m-mall-tabs">
<button :class="{ active: tab === 'recommended' }" @click="tab = 'recommended'">推荐任务</button>
<button :class="{ active: tab === 'limited' }" @click="tab = 'limited'">限时任务</button>
<n-icon :component="ChevronForwardOutline" />
</div>
<AsyncState
:loading="loading"
:error="error"
:empty="!visibleProducts.length"
:empty-text="tab === 'limited' ? '暂无限时任务' : '暂无推荐任务'"
@retry="loadList"
>
<div class="m-mall-list">
<article v-for="item in visibleProducts" :key="item.id" class="m-mall-task">
<button class="m-mall-cover" @click="openProduct(item.id)">
<img v-if="safeImage(item.cover_url)" :src="safeImage(item.cover_url)" :alt="item.name" />
<n-icon v-else :component="ImageOutline" />
<span v-if="item.is_limited">限时展示</span>
</button>
<div>
<h2>{{ item.name }}</h2>
<strong>{{ money(item.price_amount, item.currency) }}<small>/件</small></strong>
<p>已领取{{ item.claimed_count || 0 }}</p>
</div>
<button class="m-mall-claim" @click="openProduct(item.id)">{{ tab === 'limited' ? '抢单' : '领取' }}</button>
</article>
</div>
</AsyncState>
</section>
<section v-else class="m-page m-page--white m-mall-detail-page">
<PageHeader title="商品详情" />
<AsyncState
:loading="loading"
:error="error"
:empty="!product"
empty-text="商品不存在"
@retry="loadDetail"
>
<article v-if="product" class="m-product-detail">
<div class="m-product-hero">
<img v-if="safeImage(product.cover_url)" :src="safeImage(product.cover_url)" :alt="product.name" />
<n-icon v-else :component="ImageOutline" />
</div>
<section>
<h1>{{ product.name }}</h1>
<div class="m-price"><strong>{{ money(product.price_amount, product.currency) }}</strong><span>已领取 {{ product.claimed_count || 0 }} 份</span></div>
</section>
<section class="m-product-intro"><h2>项目介绍</h2><p>{{ product.detail_text || product.summary || '暂无详细介绍' }}</p></section>
<section class="m-process">
<h2>加工说明</h2>
<div>
<span><img :src="mobileAssets.processSimple" alt="" />操作简单</span>
<span><img :src="mobileAssets.processMaterial" alt="" />材料配发</span>
<span><img :src="mobileAssets.processTime" alt="" />时间自由</span>
<span><img :src="mobileAssets.processReturn" alt="" />完成回收</span>
</div>
</section>
<div class="m-buy-bar"><span>合计 <b>{{ money(product.price_amount, product.currency) }}</b></span><router-link to="/app/claim">立即领取</router-link></div>
</article>
</AsyncState>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ChevronForwardOutline, ImageOutline, SearchOutline } from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { errorText, money, request, safeImage } from '../api';
import { mobileAssets } from '../assets';
const route = useRoute();
const router = useRouter();
const isDetail = computed(() => Boolean(route.params.id));
const loading = ref(true);
const error = ref('');
const products = ref<any[]>([]);
const product = ref<any>();
const keyword = ref('');
const submittedKeyword = ref('');
const tab = ref<'recommended' | 'limited'>('recommended');
let listController: AbortController | undefined;
const isLimited = (item: any) => item.is_limited === true;
const visibleProducts = computed(() => {
let items = products.value;
if (tab.value === 'limited') {
items = items.filter(isLimited);
} else {
const recommended = items.filter((item) => item.is_recommended && !isLimited(item));
items = recommended.length ? recommended : items.filter((item) => !isLimited(item));
}
const query = submittedKeyword.value.toLocaleLowerCase('zh-CN');
return query ? items.filter((item) => String(item.name || '').toLocaleLowerCase('zh-CN').includes(query)) : items;
});
function applySearch() {
submittedKeyword.value = keyword.value;
}
function openProduct(id: number) {
router.push(`/app/mall/${id}`);
}
async function loadList() {
listController?.abort();
const controller = new AbortController();
listController = controller;
loading.value = true;
error.value = '';
try {
const data = await request<any>({ url: '/v1/manual-products', params: { page: 1, page_size: 100 }, signal: controller.signal });
products.value = data.items || [];
} catch (err) {
if (!controller.signal.aborted) error.value = errorText(err);
} finally {
if (listController === controller) loading.value = false;
}
}
async function loadDetail() {
loading.value = true;
error.value = '';
try {
product.value = await request({ url: `/v1/manual-products/${route.params.id}` });
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
function load() {
if (isDetail.value) void loadDetail();
else void loadList();
}
onMounted(load);
onBeforeUnmount(() => listController?.abort());
watch(() => route.params.id, load);
</script>