feat(product-center): implement Figma-designed UI, upload module, and website tests

- Redesign website homepage & product-center per Figma (fonts, logos, hero/footer/customer cases)
- Add API upload module (multer) with static serving for uploads/public assets
- Add OriginGood.delisted flag and SDS request retry logic
- Add admin ImageUpload component and goods import/upload flows
- Add vitest suite for website components and composables (32 tests)
- Add skills, docs, plans and PRODUCT.md
This commit is contained in:
yeuimu
2026-08-20 14:32:03 +08:00
parent b5fc88f3fa
commit 79fabd85f7
107 changed files with 5364 additions and 2601 deletions
@@ -0,0 +1,131 @@
import { mount } from '@vue/test-utils';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import FeatureCards from '../app/components/FeatureCards.vue';
import StepProcess from '../app/components/StepProcess.vue';
const readComponent = (name: string) =>
readFileSync(resolve(process.cwd(), `app/components/${name}.vue`), 'utf8');
describe('homepage navigation and carousel behavior', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('uses current-page login navigation for every homepage CTA', () => {
const components = ['AppHeader', 'HeroBanner', 'StepProcess', 'CtaBanner'];
for (const component of components) {
const source = readComponent(component);
expect(source).not.toMatch(/target="_blank"[^>]*>\s*(|||使)/u);
expect(source).toContain("window.location.href = 'https://inkpod.vip/user/login'");
}
});
it('advances the four-step content automatically', async () => {
vi.useFakeTimers();
vi.stubGlobal('ref', ref);
const wrapper = mount(StepProcess);
expect(wrapper.get('[data-testid="step-image-0"]').classes()).toContain('opacity-100');
await vi.advanceTimersByTimeAsync(2000);
expect(wrapper.get('[data-testid="step-image-1"]').classes()).toContain('opacity-100');
wrapper.unmount();
});
it('fills the active step progress line before advancing', () => {
const source = readComponent('StepProcess');
expect(source).toContain('step-progress');
expect(source).toContain('animation: fill-step-progress 2s linear forwards');
expect(source).toContain('@keyframes fill-step-progress');
expect(source).toContain('to { width: 100%; }');
expect(source).toContain('class="step-title text-xl lg:text-4xl"');
expect(source).not.toContain('step-title text-xl font-bold');
});
it('advances the feature content every two seconds', async () => {
vi.useFakeTimers();
vi.stubGlobal('ref', ref);
const wrapper = mount(FeatureCards);
expect(wrapper.get('[data-testid="feature-image-0"]').classes()).toContain('opacity-100');
await vi.advanceTimersByTimeAsync(2000);
expect(wrapper.get('[data-testid="feature-image-1"]').classes()).toContain('opacity-100');
wrapper.unmount();
});
it('keeps feature items aligned with the image and uses restrained titles', () => {
const source = readComponent('FeatureCards');
expect(source).toContain('feature-list');
expect(source).toContain('feature-item-title text-xl lg:text-3xl');
expect(source).toContain('grid-template-rows: repeat(4, minmax(0, 1fr))');
});
it('runs the customer-case track as an automatic marquee', () => {
const source = readComponent('CustomerCases');
expect(source).toContain('animation: case-marquee 30s');
expect(source).toContain('@media (prefers-reduced-motion: reduce)');
});
it('keeps the hero visual as a capped image instead of a background', () => {
const source = readComponent('HeroBanner');
expect(source).toMatch(/<img[^>]+src="\/\.png"/u);
expect(source).not.toMatch(/background(?:-image)?:\s*[^;]*\.png/u);
expect(source).toContain('hero-visual');
});
it('styles the hero emphasis and separates the three selling points', () => {
const source = readComponent('HeroBanner');
expect(source).toContain('hero-copy');
expect(source).toContain('hero-gradient-text');
expect(source).toContain('linear-gradient(90deg, #111827 0%, #111827 28%, #ff6800 100%)');
expect(source).toContain('.hero-copy { align-items: flex-start; }');
expect(source).toContain('hero-subtitle font-thin');
expect(source).toContain('selling-point-separator');
expect(source).toContain('aria-hidden="true">|</span>');
});
it('loads the supplied PingFang SC font weights globally', () => {
const css = readFileSync(resolve(process.cwd(), 'app/assets/css/tailwind.css'), 'utf8');
expect(css).toContain("font-family: 'PingFang SC'");
expect(css).toContain("url('/fonts/pingfang-sc-regular.ttf')");
expect(css).toContain("url('/fonts/pingfang-sc-thin.ttf')");
expect(css).toContain("url('/fonts/pingfang-sc-medium.ttf')");
expect(css).toContain("url('/fonts/pingfang-sc-semibold.ttf')");
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-regular.ttf'))).toBe(true);
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-thin.ttf'))).toBe(true);
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-medium.ttf'))).toBe(true);
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-semibold.ttf'))).toBe(true);
});
it('uses the theme color for trust statistics', () => {
const source = readComponent('TrustSection');
expect(source).toContain('trust-stat-value text-primary');
expect(source).toContain('trust-illustration');
expect(source).toContain('margin-bottom: -40px');
});
it('temporarily hides recommendation and solution navigation', () => {
const source = readComponent('AppHeader');
expect(source).toContain('v-if="SHOW_EXTENDED_NAV"');
});
it('uses a compact desktop navigation font size', () => {
const source = readComponent('AppHeader');
expect(source).toContain('desktop-nav');
expect(source).toContain('.desktop-nav { font-size: 18px; }');
});
it('aligns country flags and keeps the more-products action at the bottom', () => {
const source = readComponent('PodProducts');
expect(source).toContain('country-button');
expect(source).toContain('country-flag');
expect(source).toContain('more-products');
expect(source).toContain('more-products-link text-primary');
expect(source).not.toContain('fa-arrow-right');
expect(source).toContain('margin-top: auto');
});
});
+19
View File
@@ -0,0 +1,19 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import ProductCard from '../app/components/product/ProductCard.vue';
import type { Product } from '../app/composables/useProductCenter';
describe('ProductCard', () => {
it('links the entire product card to its Inkpod detail page', () => {
const product: Product = {
id: '12345', name: '测试商品', priority: 0, image: null, price: '28',
country: { id: '1', name: '美国', icon: null },
category: { id: '2', name: 'T恤', icon: null, parentId: null, children: [] },
tag: null, tags: [],
};
const wrapper = mount(ProductCard, { props: { product } });
const link = wrapper.get('a.product-card');
expect(link.attributes('href')).toBe('https://inkpod.vip/portal/detail/12345');
expect(link.attributes('target')).toBeUndefined();
});
});
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('product center layout', () => {
it('removes the sidebar left edge and extends filter dividers to the viewport right', () => {
const source = readFileSync(
resolve(process.cwd(), 'app/pages/product-center.vue'),
'utf8',
);
expect(source).not.toMatch(/\.sidebar-shell\s*\{[^}]*border-left:/u);
expect(source).toContain('.countries::after, .filter-tools::after');
expect(source).toContain('right: calc(-1 * var(--page-gutter))');
expect(source).toContain('left: 0');
expect(source).toContain('height: 1px');
expect(source).toContain('background: #e5e7eb');
});
it('adds breathing room between root category rows', () => {
const source = readFileSync(
resolve(process.cwd(), 'app/components/product/ProductSidebar.vue'),
'utf8',
);
expect(source).toMatch(/\.root-row\s*\{[^}]*margin-bottom:\s*10px/u);
});
});
@@ -0,0 +1,16 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import ProductFilterBar from '../app/components/product/ProductFilterBar.vue';
describe('ProductFilterBar', () => {
it('keeps search visible while showing a separate clear button for text', async () => {
const wrapper = mount(ProductFilterBar, {
props: { keyword: '' },
});
await wrapper.get('input').setValue('卫衣');
expect(wrapper.get('button[type="submit"]').text()).toBe('搜索');
expect(wrapper.get('.clear-search').attributes('aria-label')).toBe('清除搜索');
});
});
+25
View File
@@ -0,0 +1,25 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import ProductSidebar from '../app/components/product/ProductSidebar.vue';
import type { Category } from '../app/composables/useProductCenter';
const categories: Category[] = [{
id: 'men',
name: '男士服装',
icon: null,
parentId: null,
children: [{ id: 'men-tee', name: 'T恤', icon: null, parentId: 'men', children: [] }],
}];
describe('ProductSidebar', () => {
it('selects and expands a parent category when it is clicked', async () => {
const wrapper = mount(ProductSidebar, {
props: { categories, activeCategoryId: null },
});
await wrapper.findAll('.root-row')[1]?.trigger('click');
expect(wrapper.emitted('update:activeCategoryId')).toEqual([['men']]);
expect(wrapper.text()).toContain('T恤');
});
});
+100
View File
@@ -0,0 +1,100 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import ProductTagFilter from '../app/components/product/ProductTagFilter.vue';
import type { Tag, TagGroup } from '../app/composables/useProductCenter';
const tagGroups: TagGroup[] = [
{ id: 'shipping', name: '物流渠道', icon: null, color: null, sortOrder: 1 },
{ id: 'position', name: '印刷位置', icon: null, color: null, sortOrder: 2 },
{ id: 'craft', name: '印刷工艺', icon: null, color: null, sortOrder: 3 },
];
const tags: Tag[] = [
{
id: 'free-shipping',
name: '包邮',
color: null,
fontColor: null,
group: { id: 'shipping', name: '物流渠道', sortOrder: 1 },
},
{
id: 'heat-transfer',
name: '烫画',
color: null,
fontColor: null,
group: { id: 'craft', name: '印刷工艺', sortOrder: 3 },
},
];
describe('ProductTagFilter', () => {
it('renders selected logistics and craft tags and removes them through the existing event', async () => {
const wrapper = mount(ProductTagFilter, {
props: {
tags,
tagGroups,
selectedTagIds: ['free-shipping', 'heat-transfer'],
},
});
const selectedTags = wrapper.findAll('.selected-tag');
expect(selectedTags.map((tag) => tag.text())).toEqual(['包邮', '烫画']);
await selectedTags[0]?.find('.selected-tag-remove').trigger('click');
expect(wrapper.emitted('toggle-tag')).toEqual([['free-shipping']]);
});
it('places a craft-only selection at the left edge of the selected tag row', () => {
const wrapper = mount(ProductTagFilter, {
props: { tags, tagGroups, selectedTagIds: ['heat-transfer'] },
});
const selectedRow = wrapper.get('.selected-tags');
expect(selectedRow.findAll('.selected-tag')).toHaveLength(1);
expect(selectedRow.get('.selected-tag').text()).toBe('烫画');
expect(selectedRow.classes()).toContain('craft-only');
});
it('closes the dropdown after selection and when clicking outside', async () => {
const wrapper = mount(ProductTagFilter, {
attachTo: document.body,
props: { tags, tagGroups, selectedTagIds: [] },
});
await wrapper.findAll('.filter-button')[0]?.trigger('click');
expect(wrapper.find('.menu').exists()).toBe(true);
await wrapper.get('.menu button').trigger('click');
expect(wrapper.find('.menu').exists()).toBe(false);
await wrapper.findAll('.filter-button')[0]?.trigger('click');
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await wrapper.vm.$nextTick();
expect(wrapper.find('.menu').exists()).toBe(false);
wrapper.unmount();
});
it('uses backend group membership and excludes the print-position group', async () => {
const craftTags: Tag[] = ['烫画', '直喷', '不打印'].map((name, index) => ({
id: `craft-${index}`,
name,
color: null,
fontColor: null,
group: { id: 'craft', name: '印刷工艺', sortOrder: 3 },
}));
const positionTag: Tag = {
id: 'single-side', name: '单面印', color: null, fontColor: null,
group: { id: 'position', name: '印刷位置', sortOrder: 2 },
};
const wrapper = mount(ProductTagFilter, {
props: { tags: [tags[0]!, ...craftTags, positionTag], tagGroups, selectedTagIds: [] },
});
expect(wrapper.findAll('.filter-button').map((item) => item.text())).toEqual(['物流', '工艺']);
await wrapper.findAll('.filter-button')[1]?.trigger('click');
expect(wrapper.findAll('.menu button').map((item) => item.text())).toEqual([
'烫画',
'直喷',
'不打印',
]);
});
});
+5
View File
@@ -0,0 +1,5 @@
import { config } from '@vue/test-utils';
config.global.stubs = {
NuxtLink: { template: '<a><slot /></a>' },
};
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import type { Category, Tag } from '../app/composables/useProductCenter';
import { findCategoryIdByName, findCategoryIdForPodName, resolveBackendAssetUrl, resolveCategoryIdFromQuery, resolveCountryIdFromQuery, resolveDefaultProductFilters, selectExclusiveTagIds } from '../app/composables/useProductCenter';
describe('resolveCountryIdFromQuery', () => {
const countries = [{ id: '45', name: '美国', icon: null }];
it('accepts only a configured backend country id', () => {
expect(resolveCountryIdFromQuery('45', countries)).toBe('45');
expect(resolveCountryIdFromQuery('999', countries)).toBeUndefined();
expect(resolveCountryIdFromQuery(['45'], countries)).toBe('45');
});
});
describe('product-center category routing', () => {
it('finds and validates nested backend category ids', () => {
expect(findCategoryIdByName('T恤', categories)).toBe('tee');
expect(resolveCategoryIdFromQuery('tee', categories)).toBe('tee');
expect(resolveCategoryIdFromQuery('unknown', categories)).toBeUndefined();
});
it('maps compound homepage POD names into the backend category tree', () => {
const backendCategories: Category[] = [{
id: 'women', name: '女士服装', icon: null, parentId: null,
children: [{ id: 'tee', name: 'T恤', icon: null, parentId: 'women', children: [] }],
}];
expect(findCategoryIdForPodName('女士T恤', backendCategories)).toBe('tee');
expect(findCategoryIdForPodName('女士背心', backendCategories)).toBe('women');
});
});
describe('resolveBackendAssetUrl', () => {
it('resolves backend-relative uploaded and static assets', () => {
expect(resolveBackendAssetUrl('/assets/product-center/countries/us.png', 'http://localhost:3001'))
.toBe('http://localhost:3001/assets/product-center/countries/us.png');
});
it('preserves absolute, data, and empty asset values', () => {
expect(resolveBackendAssetUrl('https://cdn.example/icon.svg', 'http://localhost:3001')).toBe('https://cdn.example/icon.svg');
expect(resolveBackendAssetUrl('data:image/svg+xml;base64,AA==', 'http://localhost:3001')).toBe('data:image/svg+xml;base64,AA==');
expect(resolveBackendAssetUrl(null, 'http://localhost:3001')).toBeNull();
});
});
const categories: Category[] = [
{
id: 'women',
name: '女式服装',
icon: null,
parentId: null,
children: [
{ id: 'tee', name: 'T恤', icon: null, parentId: 'women', children: [] },
],
},
];
const productionCategories: Category[] = [
{
...categories[0],
name: '女士服装',
},
];
const tags: Tag[] = [
{ id: 'shipping', name: '包邮', color: null, fontColor: null, group: null },
{ id: 'transfer', name: '烫画', color: null, fontColor: null, group: null },
];
describe('resolveDefaultProductFilters', () => {
it('defaults to all products with no tag filters', () => {
expect(resolveDefaultProductFilters(categories, tags)).toEqual({
categoryId: undefined,
tagIds: [],
});
});
it('omits filters that are unavailable from the backend', () => {
expect(resolveDefaultProductFilters([], [])).toEqual({
categoryId: undefined,
tagIds: [],
});
});
it('does not infer a category from production category wording', () => {
expect(resolveDefaultProductFilters(productionCategories, tags).categoryId).toBeUndefined();
});
});
describe('selectExclusiveTagIds', () => {
const groupedTags: Tag[] = [
{ id: 'free-shipping', name: '包邮', color: null, fontColor: null, group: { id: 'shipping', name: '物流', sortOrder: 1 } },
{ id: 'paid-shipping', name: '不包邮', color: null, fontColor: null, group: { id: 'shipping', name: '物流', sortOrder: 1 } },
{ id: 'transfer', name: '烫画', color: null, fontColor: null, group: { id: 'craft', name: '工艺', sortOrder: 2 } },
];
it('replaces the selected tag within the same group', () => {
expect(selectExclusiveTagIds(['free-shipping', 'transfer'], groupedTags, 'paid-shipping')).toEqual(['transfer', 'paid-shipping']);
});
it('keeps selections from other groups and toggles the active item off', () => {
expect(selectExclusiveTagIds(['free-shipping', 'transfer'], groupedTags, 'free-shipping')).toEqual(['transfer']);
});
});