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
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
### 1. 梳理需求
|
### 1. 梳理需求
|
||||||
|
|
||||||
使用 brainstorming 进行头脑风暴, 文档存放与命名规则如下:
|
使用 `brainstorming` 进行头脑风暴, 文档存放与命名规则如下:
|
||||||
|
|
||||||
| 需求类型 | 计划目录 | 命名规则 | 示例 |
|
| 需求类型 | 计划目录 | 命名规则 | 示例 |
|
||||||
| -------- | ----------------- | --------------------- | ----------------------- |
|
| -------- | ----------------- | --------------------- | ----------------------- |
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
brand
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
跨境电商、POD 卖家和需要小批量定制履约能力的商家。他们通过官网了解 InkReach 的商品、生产、设计与全球配送能力,并进入 InkPOD 完成实际业务操作。
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
官网用于清晰展示 InkReach 的一站式 POD 能力,让访客快速浏览货盘和案例,并以最短路径进入 InkPOD 登录及商品详情页面。
|
||||||
|
|
||||||
|
## Brand Personality
|
||||||
|
|
||||||
|
直接、可靠、务实。页面应保留鲜明的橙色品牌识别,同时让商品和真实业务数据成为视觉重点。
|
||||||
|
|
||||||
|
## Anti-references
|
||||||
|
|
||||||
|
避免与 Figma 设计稿无关的通用 SaaS 模板、纯装饰性渐变、过度卡片化、被拉伸模糊的位图,以及拥挤到妨碍浏览的桌面布局。
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- 商品与业务信息优先于装饰。
|
||||||
|
- 关键操作即时、明确,并保持当前页面跳转语义一致。
|
||||||
|
- 宽屏增加呼吸空间,不通过无限放大素材填满画面。
|
||||||
|
- 动效用于展示连续内容,不影响用户主动操作。
|
||||||
|
- 后台配置是商品、国家、品类和标签的唯一数据来源。
|
||||||
|
|
||||||
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
以 WCAG 2.1 AA 为基准,维持键盘可操作性、足够对比度,并为持续动画提供 `prefers-reduced-motion` 降级。
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<svg preserveAspectRatio="xMidYMid meet" width="97" height="56" viewBox="0 0 97 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Frame" clip-path="url(#clip0_0_30)">
|
||||||
|
<g id="Group">
|
||||||
|
<path id="Vector" d="M16.318 51.2332V53.8323C17.2443 53.6294 18.0909 53.4188 18.8525 53.1979L19.0969 54.0481C17.9416 54.3665 16.7117 54.6542 15.4072 54.9085L15.0341 54.1354C15.2683 54.0044 15.3866 53.8169 15.3866 53.5678V47.6478C16.632 47.4526 17.7486 47.1957 18.7341 46.8747L19.3105 47.617C18.4614 47.8918 17.463 48.1383 16.3155 48.3592V50.342H19.0171V51.2306H16.318V51.2332ZM21.4666 54.6131L21.2119 53.7142L22.0842 53.7347C22.3389 53.7347 22.465 53.5935 22.465 53.3135V48.2513H20.5378V55.9307H19.5986V47.373H23.4067V53.5087C23.4067 54.2458 23.0671 54.6131 22.3878 54.6131H21.4666Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
<path id="Vector_2" d="M34.4941 52.1013C34.4606 52.2965 34.4221 52.4788 34.3757 52.6483H38.0474V53.5267H34.8183C35.4847 54.2895 36.7275 54.8057 38.5466 55.0805L38.0577 55.9589C35.9633 55.548 34.5893 54.8032 33.9358 53.7219C33.7325 54.0737 33.4855 54.3794 33.1922 54.6414C32.5463 55.209 31.4142 55.6584 29.7958 55.9897L29.415 55.0805C30.8173 54.834 31.8233 54.477 32.4305 54.0147C32.6209 53.858 32.783 53.6962 32.9194 53.5267H29.8755V52.6483H33.398C33.4572 52.4583 33.5086 52.2451 33.5549 52.0037L34.4941 52.1013ZM29.9733 47.8224H32.078C31.9468 47.5219 31.8079 47.2548 31.6561 47.0211L32.6338 46.8644C32.7522 47.111 32.8808 47.432 33.0249 47.8224H34.9135C35.0627 47.4962 35.1785 47.1675 35.2557 46.8362L36.254 46.962C36.1691 47.2548 36.0508 47.5425 35.9015 47.8224H37.9368V48.6623H34.4272V49.4045H37.4222V50.2058H34.4272V50.9584H38.2636V51.8188H29.6491V50.9584H33.4855V50.2058H30.5188V49.4045H33.4855V48.6623H29.9707V47.8224H29.9733Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
<path id="Vector_3" d="M50.8993 55.756C50.5597 55.756 49.9525 55.7483 49.0776 55.7355C48.419 55.7278 47.876 55.6738 47.4541 55.5685C47.0372 55.4504 46.677 55.2244 46.3786 54.8853C46.2422 54.7235 46.111 54.6414 45.9875 54.6414C45.779 54.6414 45.4188 55.0934 44.9119 56L44.2275 55.3656C44.7241 54.5592 45.1692 54.0506 45.5681 53.8426V51.097H44.2378V50.1981H46.4609V53.9094C46.5123 53.9479 46.5612 53.9967 46.6076 54.0558C46.8623 54.3152 47.1093 54.5078 47.3512 54.6311C47.6522 54.7672 48.1076 54.8468 48.7226 54.8648C49.4919 54.8776 50.1763 54.8853 50.7784 54.8853L52.1395 54.8751C52.5898 54.8545 52.9449 54.8365 53.2073 54.816L52.9835 55.7534H50.8993V55.756ZM45.0174 47.0006C45.6838 47.5091 46.2473 48.0125 46.7105 48.5159L46.0261 49.199C45.635 48.7111 45.0869 48.182 44.3819 47.617L45.0174 47.0006ZM47.2096 49.0039H49.2938C49.3452 48.5338 49.3761 48.0561 49.3813 47.5682V46.9132H50.3101V47.3627C50.3101 47.9354 50.2818 48.4825 50.2226 49.0039H52.5718V49.9028H50.3899C50.7167 51.1073 51.5426 52.3221 52.8651 53.5472L52.2193 54.2304C51.0511 53.0644 50.2689 51.9189 49.8701 50.7914C49.4199 52.3221 48.6274 53.4676 47.4927 54.2304L46.924 53.4676C48.0664 52.7382 48.8101 51.5491 49.1548 49.9028H47.2071V49.0039H47.2096Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
<path id="Vector_4" d="M63.6535 46.8953C64.8808 48.1589 66.2908 49.1528 67.8809 49.8745L67.4203 50.7041C66.9186 50.4627 66.4606 50.2135 66.0489 49.9516V50.8299H63.7873V52.2965H66.5378V53.1954H63.7873V54.739H67.4281V55.6481H59.215V54.739H62.8455V53.1954H60.0847V52.2965H62.8455V50.8299H60.5839V49.9516C60.167 50.2058 59.6962 50.4627 59.1738 50.7246L58.7236 49.913C60.4269 49.0989 61.8446 48.0921 62.9819 46.8953H63.6535ZM66.0309 49.9413C65.012 49.3172 64.1089 48.5801 63.319 47.7325C62.5882 48.5339 61.6851 49.271 60.607 49.9413H66.0309Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
<path id="Vector_5" d="M73.6859 50.4319H74.7229V48.3618H73.5779V47.4911H76.7401V48.3618H75.6646V50.4319H76.5368V51.3102H75.6646V53.031C76.0505 52.8615 76.4082 52.6946 76.7401 52.5328V53.4317C75.8061 53.894 74.7769 54.2972 73.6473 54.6439L73.4132 53.7245C73.9355 53.6063 74.3729 53.4933 74.7254 53.3829V51.3128H73.6885V50.4319H73.6859ZM76.8276 48.5159H78.9812V46.9235H79.8715V48.5159H81.2017C80.9341 48.2102 80.5893 47.8815 80.1648 47.5296L80.6922 47.0211C81.2017 47.4064 81.616 47.7762 81.935 48.1358L81.5645 48.5159H82.1126V49.4045H79.8715V50.2752C80.0027 50.6861 80.1519 51.0688 80.3217 51.4284C80.7077 50.9789 81.0628 50.4832 81.3895 49.9439L82.0842 50.4627C81.634 51.1664 81.1811 51.7494 80.7231 52.2117C81.2069 53.0464 81.7986 53.7758 82.5036 54.3999L81.8861 55.1319C81.037 54.2972 80.3655 53.3212 79.8689 52.2014V54.9085C79.8689 55.5274 79.5498 55.8356 78.9092 55.8356H77.8851L77.6895 54.9573C78.0549 55.0035 78.3585 55.0266 78.6004 55.0266C78.8551 55.0266 78.9812 54.9136 78.9812 54.685V52.6714C78.3817 53.5113 77.687 54.2381 76.8971 54.8494L76.4082 54.0481C77.3679 53.4163 78.2247 52.543 78.9812 51.4309V49.4097H76.8276V48.5159ZM77.512 50.0209C77.9546 50.6733 78.2942 51.228 78.5309 51.6929L77.8851 52.1424C77.6098 51.6338 77.2496 51.0765 76.7993 50.4729L77.512 50.0209Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
</g>
|
||||||
|
<path id="Vector_6" d="M11.1801 50.889H0.165039V51.4386H11.1801V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
<path id="Vector_7" d="M97 50.889H85.7508V51.5671H97V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
<g id="Group_2">
|
||||||
|
<path id="Vector_8" d="M64.2864 27.2064C55.1033 35.3686 44.6466 38.6072 40.9337 34.4414C39.4748 32.8054 39.2741 30.273 40.1207 27.304C37.5091 32.1016 36.9481 36.4293 39.0863 38.8281C42.8017 42.994 53.2585 39.7527 62.439 31.5931C68.0173 26.6362 71.8408 21.0039 73.1504 16.4117C71.2232 19.9508 68.1845 23.7443 64.2864 27.2064Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_9" d="M3.07218 12.536H0.810499C0.362795 12.536 0 12.8982 0 13.3451V15.6026C0 16.0495 0.362795 16.4117 0.810499 16.4117H3.07218C3.51988 16.4117 3.88268 16.0495 3.88268 15.6026V13.3451C3.88268 12.8982 3.51988 12.536 3.07218 12.536Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_10" d="M3.07218 17.6804H0.810499C0.362795 17.6804 0 18.0451 0 18.492V30.1702C0 30.6171 0.362795 30.9793 0.810499 30.9793H3.07218C3.51988 30.9793 3.88268 30.6171 3.88268 30.1702V18.492C3.88268 18.0451 3.51988 17.6804 3.07218 17.6804Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_11" d="M11.6889 17.6907C10.9711 17.701 10.2841 17.8397 9.64853 18.0811C9.50701 17.8422 9.24714 17.6804 8.94867 17.6804H6.5146C6.0669 17.6804 5.7041 18.0426 5.7041 18.4895V23.847V23.9806V30.2113C5.7041 30.6582 6.0669 31.0204 6.5146 31.0204H8.94867C9.39637 31.0204 9.75917 30.6582 9.75917 30.2113V23.9831V23.7571C9.75917 22.6193 10.7086 21.6973 11.8587 21.741C12.9523 21.7821 13.8039 22.7144 13.8039 23.8085V30.2447C13.8039 30.6916 14.1667 31.0538 14.6144 31.0538H17.0485C17.4962 31.0538 17.859 30.6916 17.859 30.2447V23.7571C17.8616 20.3797 15.0853 17.6393 11.6889 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_12" d="M90.8296 17.6907C90.1606 17.701 89.5199 17.8217 88.9204 18.0349V10.6303C88.9204 10.1835 88.5576 9.82132 88.1099 9.82132H85.6758C85.2281 9.82132 84.8653 10.1835 84.8653 10.6303V22.907C84.8653 22.9969 84.873 23.0842 84.8833 23.169C84.8576 23.3924 84.8447 23.6184 84.8447 23.847V30.2113C84.8447 30.6582 85.2075 31.0204 85.6552 31.0204H88.0893C88.537 31.0204 88.8998 30.6582 88.8998 30.2113V23.7571C88.8998 22.6193 89.8492 21.6973 90.9994 21.741C92.0929 21.7821 92.9446 22.7144 92.9446 23.8085V30.2447C92.9446 30.6916 93.3074 31.0538 93.7551 31.0538H96.1891C96.6368 31.0538 96.9996 30.6916 96.9996 30.2447V23.7571C96.9996 20.3797 94.2234 17.6393 90.8296 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_13" d="M31.0412 17.7909H28.6072C28.1595 17.7909 27.7967 18.153 27.7967 18.5999V19.4089C27.7967 20.4748 26.9656 21.3506 25.9184 21.4276C25.8386 21.4251 25.7563 21.4251 25.6765 21.4251C25.6559 21.4251 25.6379 21.4276 25.6173 21.4276C25.0898 21.3891 24.5238 21.1477 23.9886 20.6314C23.8316 20.4799 23.7416 20.2693 23.7416 20.051V10.6303C23.7416 10.1835 23.3788 9.82132 22.9311 9.82132H20.497C20.0493 9.82132 19.6865 10.1835 19.6865 10.6303V18.5999V21.985V27.5865V30.3269C19.6865 30.7738 20.0493 31.1359 20.497 31.1359H22.9311C23.3788 31.1359 23.7416 30.7738 23.7416 30.3269V30.2653V27.494C23.7416 26.4307 24.5701 25.5549 25.6147 25.4779C25.7125 25.4805 25.8103 25.4805 25.9081 25.4779C26.9759 25.5524 27.7967 26.4693 27.7967 27.5454V30.3295C27.7967 30.7764 28.1595 31.1385 28.6072 31.1385H31.0412C31.4889 31.1385 31.8517 30.7764 31.8517 30.3295V27.4966C31.8517 25.9453 31.2651 24.5302 30.3028 23.454C31.2651 22.3805 31.8517 20.9628 31.8517 19.4089V18.5999C31.8517 18.153 31.4889 17.7909 31.0412 17.7909Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_14" d="M39.8324 21.7744C39.8761 21.7769 39.9173 21.7821 39.9585 21.7846C40.7355 20.6546 41.6052 19.5271 42.5598 18.4124C41.6927 17.9604 40.7046 17.7087 39.66 17.7267C38.9807 17.7369 38.3323 17.8628 37.7277 18.0785C37.6633 17.8628 37.4678 17.7061 37.2311 17.7061H34.5062C34.0585 17.7061 33.6957 18.0682 33.6957 18.5151V23.4181C33.6855 23.5722 33.6777 23.7263 33.6777 23.883V30.2473C33.6777 30.4759 33.7729 30.6839 33.9273 30.8303C34.074 30.9818 34.2798 31.0769 34.5088 31.0769H36.9429C37.3906 31.0769 37.7534 30.7147 37.7534 30.2678V23.5285C37.8846 22.514 38.7723 21.7358 39.8324 21.7744Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_15" d="M41.5516 25.4805C41.7291 26.5746 42.1948 27.6199 42.8844 28.4854H42.8818C45.3493 31.6239 50.2304 31.891 53.4003 29.1943C53.5701 29.0504 53.5881 28.7936 53.444 28.6241L51.5709 26.4256C51.432 26.2612 51.1875 26.2381 51.0151 26.3665C49.6257 27.4016 48.1925 27.643 46.7362 26.9008L51.3316 25.0079L54.6611 23.6338C54.8695 23.5491 54.9673 23.3102 54.8798 23.1048C54.5299 22.2675 54.0847 21.1246 53.6087 20.5056C52.3685 18.7617 50.3461 17.6907 48.1488 17.6933H48.1514C44.0628 17.6624 40.8466 21.4662 41.5516 25.4805ZM45.3262 23.9215C45.4909 21.7384 48.262 20.5698 49.9164 22.0312C48.738 22.5166 46.5021 23.4386 45.3262 23.9241V23.9215Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_16" d="M77.6686 21.6125C78.5975 21.2299 79.6396 21.3352 80.4604 21.8668C80.7768 22.0723 81.1988 22.0209 81.4664 21.7538L82.9433 20.2796C83.2932 19.9303 83.2495 19.355 82.8533 19.0571C81.7006 18.1864 80.2854 17.7087 78.8213 17.7112C75.7929 17.7112 73.1401 19.7428 72.3579 22.663C71.5732 25.5832 72.852 28.6652 75.4764 30.1779C77.8256 31.5315 80.7305 31.3234 82.8507 29.7285C83.2469 29.4306 83.2932 28.8553 82.9433 28.5034L81.4664 27.0292C81.1988 26.7621 80.7794 26.7107 80.4604 26.9162C79.6396 27.4478 78.5975 27.5531 77.6686 27.1704C76.5417 26.7056 75.8084 25.6089 75.8084 24.3915C75.8084 23.1767 76.5417 22.0774 77.6686 21.6125Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_17" d="M69.361 19.617V18.1299C69.361 17.9064 69.1783 17.7241 68.9545 17.7241H65.9132C65.6893 17.7241 65.5067 17.9064 65.5067 18.1299V18.3225C64.5907 17.8936 63.564 17.665 62.4808 17.6933C58.9481 17.7909 56.0663 20.67 55.9788 24.1989C55.9145 26.7107 57.2421 28.9169 59.2465 30.1086C60.2757 29.3278 61.2921 28.488 62.2904 27.5942C62.4345 27.4632 62.5786 27.3322 62.7227 27.2012C62.7047 27.2012 62.6892 27.2038 62.6712 27.2038C60.9987 27.2038 59.6582 25.7527 59.8512 24.0448C59.9953 22.7452 61.0451 21.6973 62.347 21.5535C64.0581 21.3634 65.5118 22.699 65.5118 24.3684C65.5118 24.3889 65.5118 24.4095 65.5092 24.4326C66.9681 22.8556 68.2649 21.235 69.361 19.617Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_18" d="M65.5089 30.3166V30.9536H68.9567C69.1806 30.9536 69.3633 30.7712 69.3633 30.5478V26.102C68.0356 27.6173 67.0836 28.9066 65.5089 30.3166Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_19" d="M48.9692 12.2458C57.8229 3.65217 68.4906 0.272244 72.7979 4.69492C74.6762 6.62374 75.0338 9.71602 74.1101 13.304C76.2791 8.46524 76.467 4.15814 74.159 1.78756C70.037 -2.44249 59.5185 1.09411 50.6674 9.6852C47.765 12.5027 45.4004 15.5153 43.6816 18.4381C45.1431 16.3372 46.9185 14.2389 48.9692 12.2458Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_0_30">
|
||||||
|
<rect width="97" height="56" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
@@ -4,7 +4,7 @@ import type {
|
|||||||
CreateGoodRequest,
|
CreateGoodRequest,
|
||||||
UpdateGoodRequest,
|
UpdateGoodRequest,
|
||||||
BatchCreateGoodsRequest,
|
BatchCreateGoodsRequest,
|
||||||
UpdatePriorityRequest,
|
BatchPriorityRequest,
|
||||||
GoodsFilter,
|
GoodsFilter,
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
@@ -40,8 +40,8 @@ export const goodsApi = {
|
|||||||
return request.post<any, Good[]>('/goods/batch', data)
|
return request.post<any, Good[]>('/goods/batch', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update priority
|
// Batch update priority
|
||||||
updatePriority: (data: UpdatePriorityRequest) => {
|
batchUpdatePriority: (data: BatchPriorityRequest) => {
|
||||||
return request.patch<any, Good[]>('/goods/priority', data)
|
return request.patch<any, { count: number }>('/goods/batch-priority', data)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,11 @@ import type { SyncLog } from '@/types'
|
|||||||
|
|
||||||
export const syncApi = {
|
export const syncApi = {
|
||||||
syncProducts: () => {
|
syncProducts: () => {
|
||||||
return request.post<any, SyncLog>('/sync/products')
|
return request.post<any, { message: string }>('/sync/products')
|
||||||
|
},
|
||||||
|
|
||||||
|
syncCategories: () => {
|
||||||
|
return request.post<any, { message: string }>('/sync/categories')
|
||||||
},
|
},
|
||||||
|
|
||||||
getSyncStatus: (limit?: number) => {
|
getSyncStatus: (limit?: number) => {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
url: string
|
||||||
|
filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadApi = {
|
||||||
|
uploadImage: (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return request.post<any, UploadResult>('/upload/image', formData)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ declare module 'vue' {
|
|||||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
|
||||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
@@ -30,23 +29,24 @@ declare module 'vue' {
|
|||||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElImage: typeof import('element-plus/es')['ElImage']
|
ElImage: typeof import('element-plus/es')['ElImage']
|
||||||
|
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
|
||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
|
||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
ElTree: typeof import('element-plus/es')['ElTree']
|
ElTree: typeof import('element-plus/es')['ElTree']
|
||||||
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
|
ImageUpload: typeof import('./components/ImageUpload.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Plus, Loading } from '@element-plus/icons-vue'
|
||||||
|
import type { UploadProps } from 'element-plus'
|
||||||
|
import { uploadApi } from '@/api/upload'
|
||||||
|
|
||||||
|
const model = defineModel<string>({ default: '' })
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
shape?: 'square' | 'circle'
|
||||||
|
label?: string
|
||||||
|
}>(), {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
shape: 'square',
|
||||||
|
label: '点击上传',
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploading = ref(false)
|
||||||
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
|
const customUpload: UploadProps['httpRequest'] = async (options) => {
|
||||||
|
const file = options.file as File
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const result = await uploadApi.uploadImage(file)
|
||||||
|
model.value = result.url
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || '上传失败')
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage() {
|
||||||
|
model.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImgError() {
|
||||||
|
console.warn('Image failed to load:', model.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="img-upload">
|
||||||
|
<el-upload
|
||||||
|
class="img-uploader"
|
||||||
|
:show-file-list="false"
|
||||||
|
:http-request="customUpload"
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
<div v-if="model" class="img-preview-wrap" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
||||||
|
<img :src="model" class="img-preview-img" @click.stop="previewVisible = true" @error="onImgError" />
|
||||||
|
<div class="img-overlay">
|
||||||
|
<span @click.stop="previewVisible = true">预览</span>
|
||||||
|
<span @click.stop="removeImage">删除</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="img-placeholder" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
||||||
|
<el-icon v-if="!uploading" :size="20"><Plus /></el-icon>
|
||||||
|
<el-icon v-else :size="20" class="is-loading"><Loading /></el-icon>
|
||||||
|
<span class="img-placeholder-text">{{ uploading ? '上传中' : label }}</span>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
|
||||||
|
<el-image-viewer v-if="previewVisible" :url-list="[model]" @close="previewVisible = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.img-upload {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-uploader {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px dashed #d9d9d9;
|
||||||
|
background: #fafafa;
|
||||||
|
color: #a8abb2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-placeholder:hover {
|
||||||
|
border-color: var(--brand-color, #ff6800);
|
||||||
|
color: var(--brand-color, #ff6800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-placeholder-text {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-preview-wrap {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-preview-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-preview-wrap:hover .img-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-overlay span {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-overlay span:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-loading {
|
||||||
|
animation: rotating 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotating {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -71,11 +71,8 @@ function handleCommand(command: string) {
|
|||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
|
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
|
||||||
<div class="brand" :class="{ collapsed }">
|
<div class="brand" :class="{ collapsed }">
|
||||||
<div class="brand-mark">IR</div>
|
<img src="/logo-unified.svg" class="brand-logo" alt="InkReach" />
|
||||||
<div v-if="!collapsed" class="brand-text">
|
<span v-if="!collapsed" class="brand-text">印美达官网后台</span>
|
||||||
<div class="brand-name">Inkreach</div>
|
|
||||||
<div class="brand-tag">官网后台</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-menu
|
<el-menu
|
||||||
@@ -162,9 +159,9 @@ function handleCommand(command: string) {
|
|||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
padding: 0 16px;
|
padding: 0 20px;
|
||||||
border-bottom: 1px solid #2b2b33;
|
border-bottom: 1px solid #2b2b33;
|
||||||
background: #16161a;
|
background: #16161a;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -175,37 +172,18 @@ function handleCommand(command: string) {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-logo {
|
||||||
flex: 0 0 auto;
|
height: 24px;
|
||||||
width: 32px;
|
width: auto;
|
||||||
height: 32px;
|
object-fit: contain;
|
||||||
border-radius: 6px;
|
flex-shrink: 0;
|
||||||
background: var(--brand-color);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 13px;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-text {
|
.brand-text {
|
||||||
display: flex;
|
color: #e4e4e7;
|
||||||
flex-direction: column;
|
font-size: 13px;
|
||||||
line-height: 1.2;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-name {
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 15px;
|
white-space: nowrap;
|
||||||
}
|
|
||||||
|
|
||||||
.brand-tag {
|
|
||||||
color: #9ca3af;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-aside :deep(.el-menu) {
|
.layout-aside :deep(.el-menu) {
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ export interface UpdatePriorityRequest {
|
|||||||
priority: number
|
priority: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BatchPriorityItem {
|
||||||
|
id: number
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchPriorityRequest {
|
||||||
|
items: BatchPriorityItem[]
|
||||||
|
}
|
||||||
|
|
||||||
// Country types
|
// Country types
|
||||||
export interface Country {
|
export interface Country {
|
||||||
id: string
|
id: string
|
||||||
@@ -234,6 +243,7 @@ export interface OriginGood {
|
|||||||
goodPrice: string | null
|
goodPrice: string | null
|
||||||
sdsGoodId: string
|
sdsGoodId: string
|
||||||
sdsCategoryId: string | null
|
sdsCategoryId: string | null
|
||||||
|
delisted?: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -245,6 +255,7 @@ export interface OriginGoodsTreeNode {
|
|||||||
goodImage: string | null
|
goodImage: string | null
|
||||||
goodPrice: string | null
|
goodPrice: string | null
|
||||||
sdsGoodId: string
|
sdsGoodId: string
|
||||||
|
delisted: boolean
|
||||||
configuredCount: number
|
configuredCount: number
|
||||||
configuredCountries: string[]
|
configuredCountries: string[]
|
||||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
|
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useVirtualList } from '@vueuse/core'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Plus, Edit, Delete, Search,
|
Plus, Edit, Delete, Search, Top,
|
||||||
FolderAdd, Aim,
|
FolderAdd, Aim, ArrowDown,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import type {
|
import type {
|
||||||
CategoryTree, Country, Tag, TagGroup, Good, Position,
|
CategoryTree, Country, Tag, TagGroup, Good, Position,
|
||||||
@@ -17,7 +18,7 @@ import { tagGroupsApi } from '@/api/tag-groups'
|
|||||||
import { positionsApi } from '@/api/positions'
|
import { positionsApi } from '@/api/positions'
|
||||||
import { originGoodsApi } from '@/api/origin-goods'
|
import { originGoodsApi } from '@/api/origin-goods'
|
||||||
|
|
||||||
const mode = ref<'category' | 'country'>('category')
|
const mode = ref<'category' | 'country' | 'global'>('category')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const leftTreeRef = ref()
|
const leftTreeRef = ref()
|
||||||
@@ -32,12 +33,26 @@ const allGoods = ref<Good[]>([])
|
|||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const selectedCountryIds = ref<string[]>([])
|
const selectedCountryIds = ref<string[]>([])
|
||||||
const selectedTagIds = ref<string[]>([])
|
const selectedTagIds = ref<string[]>([])
|
||||||
|
const sortBy = ref(localStorage.getItem('goods-sort-by') || 'default')
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ label: '默认排序', value: 'default' },
|
||||||
|
{ label: '优先级 高→低', value: 'priority-desc' },
|
||||||
|
{ label: '优先级 低→高', value: 'priority-asc' },
|
||||||
|
{ label: '名称 A→Z', value: 'name-asc' },
|
||||||
|
{ label: '名称 Z→A', value: 'name-desc' },
|
||||||
|
{ label: '最新创建', value: 'created-desc' },
|
||||||
|
{ label: '最早创建', value: 'created-asc' },
|
||||||
|
]
|
||||||
|
|
||||||
|
watch(sortBy, (val) => localStorage.setItem('goods-sort-by', val))
|
||||||
|
|
||||||
const treeProps = { label: 'label', children: 'children' }
|
const treeProps = { label: 'label', children: 'children' }
|
||||||
|
|
||||||
const leftPct = ref(55)
|
const leftPct = ref(55)
|
||||||
const showAllLeft = ref(false)
|
const showAllLeft = ref(false)
|
||||||
const showAllRight = ref(false)
|
const showAllRight = ref(false)
|
||||||
|
const showUnconfiguredOnly = ref(false)
|
||||||
|
|
||||||
function onSplitterMouseDown(e: MouseEvent) {
|
function onSplitterMouseDown(e: MouseEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -62,16 +77,21 @@ function onSplitterMouseDown(e: MouseEvent) {
|
|||||||
|
|
||||||
function setExpand(treeRef: any, data: any[], expand: boolean) {
|
function setExpand(treeRef: any, data: any[], expand: boolean) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (!treeRef.value) return
|
const tree = treeRef?.value ?? treeRef
|
||||||
if (expand) {
|
if (!tree?.getNode) return
|
||||||
const keys: string[] = []
|
const keys: string[] = []
|
||||||
function collect(arr: any[]) {
|
function collect(arr: any[]) {
|
||||||
for (const n of arr) { keys.push(n.id); if (n.children) collect(n.children) }
|
for (const n of arr) {
|
||||||
|
keys.push(n.id)
|
||||||
|
if (n.children?.length) collect(n.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collect(data)
|
||||||
|
for (const key of keys) {
|
||||||
|
const node = tree.getNode(key)
|
||||||
|
if (node && node.childNodes?.length) {
|
||||||
|
node.expanded = expand
|
||||||
}
|
}
|
||||||
collect(data)
|
|
||||||
treeRef.value.setExpandedKeys(keys)
|
|
||||||
} else {
|
|
||||||
treeRef.value.setExpandedKeys([])
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -106,6 +126,31 @@ async function loadAll() {
|
|||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set of origin good IDs currently visible in the right tree (active/non-delisted)
|
||||||
|
const activeOriginGoodIds = computed(() => {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
function traverse(nodes: any[]) {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.isOG && n.rawId) ids.add(String(n.rawId))
|
||||||
|
if (n.children?.length) traverse(n.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traverse(rightTreeData.value)
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort tags by their group's sortOrder, then by tag's sortOrder within group
|
||||||
|
function sortTagsByGroup(tags: Tag[]): Tag[] {
|
||||||
|
const groupOrder = new Map<string, number>()
|
||||||
|
allTagGroups.value.forEach((g, i) => groupOrder.set(g.id, i))
|
||||||
|
return [...tags].sort((a, b) => {
|
||||||
|
const ga = a.tagGroupId ? (groupOrder.get(a.tagGroupId) ?? 999) : 999
|
||||||
|
const gb = b.tagGroupId ? (groupOrder.get(b.tagGroupId) ?? 999) : 999
|
||||||
|
if (ga !== gb) return ga - gb
|
||||||
|
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function goodToNode(g: Good): any {
|
function goodToNode(g: Good): any {
|
||||||
return {
|
return {
|
||||||
id: 'good-' + g.id,
|
id: 'good-' + g.id,
|
||||||
@@ -116,17 +161,18 @@ function goodToNode(g: Good): any {
|
|||||||
goodName: g.goodName,
|
goodName: g.goodName,
|
||||||
goodImage: g.goodImage || g.originGood?.goodImage || null,
|
goodImage: g.goodImage || g.originGood?.goodImage || null,
|
||||||
country: g.country?.countryName || g.countryId,
|
country: g.country?.countryName || g.countryId,
|
||||||
tags: g.tags || [],
|
tags: sortTagsByGroup(g.tags || []),
|
||||||
priority: g.goodPriority,
|
priority: g.goodPriority,
|
||||||
originGoodId: g.originGoodId,
|
originGoodId: g.originGoodId,
|
||||||
originGoodName: g.originGood?.goodName || null,
|
originGoodName: g.originGood?.goodName || null,
|
||||||
originGoodImage: g.originGood?.goodImage || null,
|
originGoodImage: g.originGood?.goodImage || null,
|
||||||
originGoodPrice: g.originGood?.goodPrice || null,
|
originGoodPrice: g.originGood?.goodPrice || null,
|
||||||
sdsGoodId: g.originGood?.sdsGoodId || null,
|
sdsGoodId: g.originGood?.sdsGoodId || null,
|
||||||
|
originDelisted: !!g.originGoodId && !activeOriginGoodIds.value.has(String(g.originGoodId)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed: goods after applying search + country + tag filters
|
// Computed: goods after applying search + country + tag filters + sort
|
||||||
const filteredGoods = computed(() => {
|
const filteredGoods = computed(() => {
|
||||||
let result = allGoods.value
|
let result = allGoods.value
|
||||||
if (searchKeyword.value) {
|
if (searchKeyword.value) {
|
||||||
@@ -141,7 +187,28 @@ const filteredGoods = computed(() => {
|
|||||||
return selectedTagIds.value.some(id => ids.includes(id))
|
return selectedTagIds.value.some(id => ids.includes(id))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
const sorted = [...result]
|
||||||
|
switch (sortBy.value) {
|
||||||
|
case 'priority-desc':
|
||||||
|
sorted.sort((a, b) => (b.goodPriority || 0) - (a.goodPriority || 0) || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
break
|
||||||
|
case 'priority-asc':
|
||||||
|
sorted.sort((a, b) => (a.goodPriority || 0) - (b.goodPriority || 0) || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
break
|
||||||
|
case 'name-asc':
|
||||||
|
sorted.sort((a, b) => (a.goodName || '').localeCompare(b.goodName || '', 'zh-Hans-CN'))
|
||||||
|
break
|
||||||
|
case 'name-desc':
|
||||||
|
sorted.sort((a, b) => (b.goodName || '').localeCompare(a.goodName || '', 'zh-Hans-CN'))
|
||||||
|
break
|
||||||
|
case 'created-desc':
|
||||||
|
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
break
|
||||||
|
case 'created-asc':
|
||||||
|
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return sorted
|
||||||
})
|
})
|
||||||
|
|
||||||
function mapCatNodes(nodes: CategoryTree[], goods: Good[]): any[] {
|
function mapCatNodes(nodes: CategoryTree[], goods: Good[]): any[] {
|
||||||
@@ -195,22 +262,37 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
|
|||||||
goodImage: og.goodImage,
|
goodImage: og.goodImage,
|
||||||
goodPrice: og.goodPrice,
|
goodPrice: og.goodPrice,
|
||||||
sdsGoodId: og.sdsGoodId,
|
sdsGoodId: og.sdsGoodId,
|
||||||
|
configuredCount: og.configuredCount ?? 0,
|
||||||
|
configuredCountries: og.configuredCountries ?? [],
|
||||||
}))
|
}))
|
||||||
return {
|
return {
|
||||||
id: 'rc-' + node.categoryId,
|
id: 'rc-' + node.categoryId,
|
||||||
label: node.categoryName,
|
label: node.categoryName,
|
||||||
|
configuredCount: node.configuredCount ?? 0,
|
||||||
|
totalCount: node.totalCount ?? 0,
|
||||||
children: [...children, ...goods],
|
children: [...children, ...goods],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rightTreeData.value = tree.tree.map(mapCat)
|
rightTreeData.value = tree.tree.map(mapCat)
|
||||||
}
|
}
|
||||||
|
|
||||||
function rightFilterNode(value: string, data: any) {
|
function rightFilterNode(_value: string, data: any) {
|
||||||
if (!value) return true
|
if (data.isOG) {
|
||||||
if (data.isOG) return data.label.includes(value)
|
if (showUnconfiguredOnly.value && data.configuredCount > 0) return false
|
||||||
|
if (searchKeyword.value && !data.label.includes(searchKeyword.value)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleUnconfiguredFilter() {
|
||||||
|
showUnconfiguredOnly.value = !showUnconfiguredOnly.value
|
||||||
|
rightTreeRef.value?.filter?.('')
|
||||||
|
if (showUnconfiguredOnly.value) {
|
||||||
|
nextTick(() => setExpand(rightTreeRef.value, rightTreeData.value, true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Right tree search filter
|
// Right tree search filter
|
||||||
watch(searchKeyword, () => {
|
watch(searchKeyword, () => {
|
||||||
rightTreeRef.value?.filter?.('')
|
rightTreeRef.value?.filter?.('')
|
||||||
@@ -273,24 +355,36 @@ const configOG = ref<any>(null)
|
|||||||
const configDropTarget = ref<any>(null)
|
const configDropTarget = ref<any>(null)
|
||||||
const configForm = ref({
|
const configForm = ref({
|
||||||
countryId: '', cascaderCategory: [] as string[], categoryId: '',
|
countryId: '', cascaderCategory: [] as string[], categoryId: '',
|
||||||
tagIds: [] as string[], positionId: '', priority: 0, goodImage: '',
|
tagIds: [] as string[], positionId: '', goodImage: '',
|
||||||
})
|
})
|
||||||
const configPositions = ref<Position[]>([])
|
const configPositions = ref<Position[]>([])
|
||||||
|
|
||||||
function openConfigModal(og: any, dropTarget: any) {
|
function openConfigModal(og: any, dropTarget: any) {
|
||||||
configOG.value = og
|
configOG.value = og
|
||||||
configDropTarget.value = dropTarget
|
configDropTarget.value = dropTarget
|
||||||
configForm.value = { countryId: '', cascaderCategory: [], categoryId: '', tagIds: [], positionId: '', priority: 0, goodImage: og.goodImage || '' }
|
configForm.value = { countryId: '', cascaderCategory: [], categoryId: '', tagIds: [], positionId: '', goodImage: og.goodImage || '' }
|
||||||
if (mode.value === 'category') {
|
if (dropTarget) {
|
||||||
configForm.value.categoryId = dropTarget.id
|
if (mode.value === 'category') {
|
||||||
configForm.value.cascaderCategory = findCategoryPath(allCategories.value, dropTarget.id)
|
configForm.value.categoryId = dropTarget.id
|
||||||
} else {
|
configForm.value.cascaderCategory = findCategoryPath(allCategories.value, dropTarget.id)
|
||||||
configForm.value.countryId = dropTarget.id
|
} else {
|
||||||
|
configForm.value.countryId = dropTarget.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loadConfigPositions()
|
loadConfigPositions()
|
||||||
configVisible.value = true
|
configVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openConfigFromRightTree(data: any) {
|
||||||
|
openConfigModal({
|
||||||
|
rawId: data.rawId,
|
||||||
|
goodName: data.goodName,
|
||||||
|
goodImage: data.goodImage,
|
||||||
|
goodPrice: data.goodPrice,
|
||||||
|
sdsGoodId: data.sdsGoodId,
|
||||||
|
}, null)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadConfigPositions() {
|
async function loadConfigPositions() {
|
||||||
const params: any = { page: 1, pageSize: 200 }
|
const params: any = { page: 1, pageSize: 200 }
|
||||||
if (configForm.value.countryId) params.countryId = configForm.value.countryId
|
if (configForm.value.countryId) params.countryId = configForm.value.countryId
|
||||||
@@ -319,11 +413,11 @@ async function handleConfigSubmit() {
|
|||||||
categoryId: Number(configForm.value.categoryId),
|
categoryId: Number(configForm.value.categoryId),
|
||||||
tagIds: configForm.value.tagIds.map(Number),
|
tagIds: configForm.value.tagIds.map(Number),
|
||||||
positionId: configForm.value.positionId ? Number(configForm.value.positionId) : undefined,
|
positionId: configForm.value.positionId ? Number(configForm.value.positionId) : undefined,
|
||||||
goodPriority: configForm.value.priority,
|
|
||||||
} as any)
|
} as any)
|
||||||
ElMessage.success('配置成功')
|
ElMessage.success('配置成功')
|
||||||
configVisible.value = false
|
configVisible.value = false
|
||||||
refreshLeftTree()
|
refreshLeftTree()
|
||||||
|
refreshRightTree()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || '配置失败')
|
ElMessage.error(e?.response?.data?.message || '配置失败')
|
||||||
} finally { configLoading.value = false }
|
} finally { configLoading.value = false }
|
||||||
@@ -335,7 +429,7 @@ const editLoading = ref(false)
|
|||||||
const editGood = ref<Good | null>(null)
|
const editGood = ref<Good | null>(null)
|
||||||
const editForm = ref({
|
const editForm = ref({
|
||||||
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
|
||||||
categoryId: '', tagIds: [] as string[], positionId: '', priority: 0,
|
categoryId: '', tagIds: [] as string[], positionId: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
function openEdit(g: Good) {
|
function openEdit(g: Good) {
|
||||||
@@ -347,7 +441,7 @@ function openEdit(g: Good) {
|
|||||||
cascaderCategory: findCategoryPath(allCategories.value, g.categoryId),
|
cascaderCategory: findCategoryPath(allCategories.value, g.categoryId),
|
||||||
categoryId: g.categoryId,
|
categoryId: g.categoryId,
|
||||||
tagIds: (g.tags || []).map(t => t.id),
|
tagIds: (g.tags || []).map(t => t.id),
|
||||||
positionId: g.positionId || '', priority: g.goodPriority,
|
positionId: g.positionId || '',
|
||||||
}
|
}
|
||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -362,11 +456,11 @@ async function handleEditSubmit() {
|
|||||||
categoryId: Number(editForm.value.categoryId),
|
categoryId: Number(editForm.value.categoryId),
|
||||||
tagIds: editForm.value.tagIds.map(Number),
|
tagIds: editForm.value.tagIds.map(Number),
|
||||||
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
|
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
|
||||||
goodPriority: editForm.value.priority,
|
|
||||||
} as any)
|
} as any)
|
||||||
ElMessage.success('更新成功')
|
ElMessage.success('更新成功')
|
||||||
editVisible.value = false
|
editVisible.value = false
|
||||||
refreshLeftTree()
|
refreshLeftTree()
|
||||||
|
refreshRightTree()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || '更新失败')
|
ElMessage.error(e?.response?.data?.message || '更新失败')
|
||||||
} finally { editLoading.value = false }
|
} finally { editLoading.value = false }
|
||||||
@@ -386,6 +480,7 @@ async function handleDeleteGood(g: Good) {
|
|||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
editVisible.value = false
|
editVisible.value = false
|
||||||
refreshLeftTree()
|
refreshLeftTree()
|
||||||
|
refreshRightTree()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Right tree locate ───
|
// ─── Right tree locate ───
|
||||||
@@ -424,6 +519,41 @@ function locateInRightTree(originGoodId: string) {
|
|||||||
}, 250)
|
}, 250)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function locateInLeftTree(originGoodId: string) {
|
||||||
|
const good = allGoods.value.find(g => g.originGoodId === originGoodId)
|
||||||
|
if (!good) {
|
||||||
|
ElMessage.warning('该原产品尚未配置到官网')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const targetKey = 'good-' + good.id
|
||||||
|
const tree = leftTreeRef.value
|
||||||
|
if (!tree?.getNode) return
|
||||||
|
// Build the category path to expand
|
||||||
|
const catPath: string[] = []
|
||||||
|
function findCatPath(nodes: CategoryTree[], targetCatId: string): boolean {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.id === targetCatId) { catPath.push(n.id); return true }
|
||||||
|
if (n.children?.length && findCatPath(n.children, targetCatId)) {
|
||||||
|
catPath.unshift(n.id); return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
findCatPath(allCategories.value, good.categoryId)
|
||||||
|
// Expand ancestors
|
||||||
|
for (const keyId of catPath) {
|
||||||
|
const node = tree.getNode(keyId)
|
||||||
|
if (node) node.expanded = true
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
tree.setCurrentKey(targetKey)
|
||||||
|
nextTick(() => {
|
||||||
|
const el = document.querySelector('.gv-left .el-tree-node.is-current') as HTMLElement
|
||||||
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
})
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Left Tree: Category CRUD ───
|
// ─── Left Tree: Category CRUD ───
|
||||||
const catEditVisible = ref(false)
|
const catEditVisible = ref(false)
|
||||||
const catEditMode = ref<'create' | 'edit'>('create')
|
const catEditMode = ref<'create' | 'edit'>('create')
|
||||||
@@ -732,6 +862,26 @@ const tagTreeData = computed<TreeNode[]>(() => {
|
|||||||
return groupNodes
|
return groupNodes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const groupedTagOptions = computed(() => {
|
||||||
|
const groups = allTagGroups.value
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
label: g.groupName,
|
||||||
|
tags: allTags.value
|
||||||
|
.filter((t) => t.tagGroupId === g.id)
|
||||||
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)),
|
||||||
|
}))
|
||||||
|
const ungrouped = allTags.value
|
||||||
|
.filter((t) => !t.tagGroupId)
|
||||||
|
.sort((a, b) => a.tagName.localeCompare(b.tagName))
|
||||||
|
if (ungrouped.length > 0) {
|
||||||
|
groups.push({ id: 'ungrouped', label: '未分组', tags: ungrouped })
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
function toggleTagInSelection(tagId: string): void {
|
function toggleTagInSelection(tagId: string): void {
|
||||||
const idx = selectedTagIds.value.indexOf(tagId)
|
const idx = selectedTagIds.value.indexOf(tagId)
|
||||||
if (idx >= 0) selectedTagIds.value.splice(idx, 1)
|
if (idx >= 0) selectedTagIds.value.splice(idx, 1)
|
||||||
@@ -799,8 +949,150 @@ async function quickCreateTag(targetForm: () => void) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function quickCreateTagGroup() {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt('请输入分组名称', '新建分组', {
|
||||||
|
confirmButtonText: '新建', cancelButtonText: '取消', inputPlaceholder: '分组名称',
|
||||||
|
})
|
||||||
|
if (!value.trim()) return
|
||||||
|
const maxSort = Math.max(0, ...allTagGroups.value.map(g => g.sortOrder))
|
||||||
|
await tagGroupsApi.createTagGroup({ groupName: value.trim(), sortOrder: maxSort + 1 } as any)
|
||||||
|
await reloadTags()
|
||||||
|
ElMessage.success('已创建分组')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function onModeChange() {}
|
function onModeChange() {}
|
||||||
|
|
||||||
|
// ─── Global mode: flat list with move-to-top ───
|
||||||
|
const globalGoodsNodes = computed(() =>
|
||||||
|
filteredGoods.value.map(g => goodToNode(g))
|
||||||
|
)
|
||||||
|
|
||||||
|
const { list: virtualList, containerProps: virtualContainer, wrapperProps: virtualWrapper } = useVirtualList(
|
||||||
|
globalGoodsNodes,
|
||||||
|
{ itemHeight: 64, overscan: 5 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const moveTopLoading = ref<string | null>(null)
|
||||||
|
const globalSelectedIds = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// ─── Drag-to-reorder in global mode ───
|
||||||
|
const dragGoodId = ref<string | null>(null)
|
||||||
|
const dragOverGoodId = ref<string | null>(null)
|
||||||
|
const dragOverPos = ref<'before' | 'after'>('before')
|
||||||
|
|
||||||
|
function onGlobalDragStart(_e: DragEvent, goodId: string) {
|
||||||
|
dragGoodId.value = goodId
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalDragOver(e: DragEvent, goodId: string) {
|
||||||
|
if (!dragGoodId.value || dragGoodId.value === goodId) return
|
||||||
|
e.preventDefault()
|
||||||
|
dragOverGoodId.value = goodId
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
dragOverPos.value = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalDragLeave(_e: DragEvent, goodId: string) {
|
||||||
|
if (dragOverGoodId.value === goodId) dragOverGoodId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onGlobalDrop(_e: DragEvent) {
|
||||||
|
const draggedId = dragGoodId.value
|
||||||
|
const targetId = dragOverGoodId.value
|
||||||
|
const pos = dragOverPos.value
|
||||||
|
dragGoodId.value = null
|
||||||
|
dragOverGoodId.value = null
|
||||||
|
if (!draggedId || !targetId || draggedId === targetId) return
|
||||||
|
|
||||||
|
// Build the new order
|
||||||
|
const nodes = [...globalGoodsNodes.value]
|
||||||
|
const fromIdx = nodes.findIndex(n => n.goodId === draggedId)
|
||||||
|
const toIdx = nodes.findIndex(n => n.goodId === targetId)
|
||||||
|
if (fromIdx === -1 || toIdx === -1) return
|
||||||
|
|
||||||
|
const [moved] = nodes.splice(fromIdx, 1)
|
||||||
|
let insertIdx = nodes.findIndex(n => n.goodId === targetId)
|
||||||
|
if (pos === 'after') insertIdx++
|
||||||
|
nodes.splice(insertIdx, 0, moved)
|
||||||
|
|
||||||
|
// Recalculate priorities: first item gets highest priority
|
||||||
|
const items = nodes.map((n, i) => ({
|
||||||
|
id: Number(n.goodId),
|
||||||
|
priority: nodes.length - i,
|
||||||
|
}))
|
||||||
|
|
||||||
|
moveTopLoading.value = 'drag'
|
||||||
|
try {
|
||||||
|
await goodsApi.batchUpdatePriority({ items })
|
||||||
|
await refreshLeftTree()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('排序失败')
|
||||||
|
} finally {
|
||||||
|
moveTopLoading.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalDragEnd() {
|
||||||
|
dragGoodId.value = null
|
||||||
|
dragOverGoodId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDragGlobal = computed(() => {
|
||||||
|
return sortBy.value === 'default' || sortBy.value === 'priority-desc'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAllSelected = computed(() => {
|
||||||
|
const nodes = globalGoodsNodes.value
|
||||||
|
return nodes.length > 0 && nodes.every(n => globalSelectedIds.value.has(n.goodId))
|
||||||
|
})
|
||||||
|
const isIndeterminate = computed(() => {
|
||||||
|
const c = globalGoodsNodes.value.filter(n => globalSelectedIds.value.has(n.goodId)).length
|
||||||
|
return c > 0 && c < globalGoodsNodes.value.length
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
if (isAllSelected.value) globalSelectedIds.value.clear()
|
||||||
|
else globalGoodsNodes.value.forEach(n => globalSelectedIds.value.add(n.goodId))
|
||||||
|
}
|
||||||
|
function toggleSelectItem(goodId: string) {
|
||||||
|
if (globalSelectedIds.value.has(goodId)) globalSelectedIds.value.delete(goodId)
|
||||||
|
else globalSelectedIds.value.add(goodId)
|
||||||
|
}
|
||||||
|
function clearSelection() { globalSelectedIds.value.clear() }
|
||||||
|
|
||||||
|
async function moveToTop(goodId: string) {
|
||||||
|
moveTopLoading.value = goodId
|
||||||
|
try {
|
||||||
|
const maxPriority = Math.max(0, ...allGoods.value.map(g => g.goodPriority || 0))
|
||||||
|
await goodsApi.batchUpdatePriority({ items: [{ id: Number(goodId), priority: maxPriority + 1 }] })
|
||||||
|
ElMessage.success('已置顶')
|
||||||
|
await refreshLeftTree()
|
||||||
|
} catch { ElMessage.error('操作失败') }
|
||||||
|
finally { moveTopLoading.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchMoveToTop() {
|
||||||
|
const ids = [...globalSelectedIds.value]
|
||||||
|
if (!ids.length) return
|
||||||
|
moveTopLoading.value = 'batch'
|
||||||
|
try {
|
||||||
|
const maxPriority = Math.max(0, ...allGoods.value.map(g => g.goodPriority || 0))
|
||||||
|
await goodsApi.batchUpdatePriority({
|
||||||
|
items: ids.map((id, i) => ({ id: Number(id), priority: maxPriority + ids.length - i })),
|
||||||
|
})
|
||||||
|
ElMessage.success(`已置顶 ${ids.length} 个商品`)
|
||||||
|
clearSelection()
|
||||||
|
await refreshLeftTree()
|
||||||
|
} catch { ElMessage.error('操作失败') }
|
||||||
|
finally { moveTopLoading.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
rightTreeRef.value?.filter?.('')
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => loadAll())
|
onMounted(() => loadAll())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -808,9 +1100,9 @@ onMounted(() => loadAll())
|
|||||||
<div class="gv-root" v-loading="loading">
|
<div class="gv-root" v-loading="loading">
|
||||||
<!-- Filter Bar -->
|
<!-- Filter Bar -->
|
||||||
<div class="gv-filter">
|
<div class="gv-filter">
|
||||||
<el-input v-model="searchKeyword" size="small" style="width: 160px" placeholder="搜索..." clearable>
|
<el-select v-model="sortBy" size="small" style="width: 140px">
|
||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<el-option v-for="opt in SORT_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
</el-input>
|
</el-select>
|
||||||
|
|
||||||
<el-select v-model="selectedCountryIds" multiple collapse-tags collapse-tags-tooltip size="small" placeholder="国家筛选" style="width: 150px" popper-class="filter-popper">
|
<el-select v-model="selectedCountryIds" multiple collapse-tags collapse-tags-tooltip size="small" placeholder="国家筛选" style="width: 150px" popper-class="filter-popper">
|
||||||
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id">
|
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id">
|
||||||
@@ -826,12 +1118,13 @@ onMounted(() => loadAll())
|
|||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
:width="280"
|
:width="280"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
|
transition="el-zoom-in-top"
|
||||||
popper-class="tag-filter-popper"
|
popper-class="tag-filter-popper"
|
||||||
>
|
>
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<div
|
<div
|
||||||
class="tag-select-trigger"
|
class="tag-select-trigger"
|
||||||
:class="{ 'is-filled': selectedTagIds.length > 0 }"
|
:class="{ 'is-filled': selectedTagIds.length > 0, 'is-active': tagPopoverVisible }"
|
||||||
>
|
>
|
||||||
<template v-if="selectedTagIds.length === 0">
|
<template v-if="selectedTagIds.length === 0">
|
||||||
<span class="placeholder">标签筛选</span>
|
<span class="placeholder">标签筛选</span>
|
||||||
@@ -849,11 +1142,14 @@ onMounted(() => loadAll())
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
<span v-if="hiddenSelectedCount > 0" class="more-tag">+{{ hiddenSelectedCount }}</span>
|
<span v-if="hiddenSelectedCount > 0" class="more-tag">+{{ hiddenSelectedCount }}</span>
|
||||||
</template>
|
</template>
|
||||||
<i class="fa-solid fa-chevron-down arrow"></i>
|
<el-icon class="tag-arrow"><ArrowDown /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="tag-tree-panel">
|
<div class="tag-tree-panel">
|
||||||
|
<div class="tag-tree-toolbar">
|
||||||
|
<el-button text size="small" :icon="Plus" @click="quickCreateTagGroup">新建分组</el-button>
|
||||||
|
</div>
|
||||||
<el-tree
|
<el-tree
|
||||||
:data="tagTreeData"
|
:data="tagTreeData"
|
||||||
node-key="id"
|
node-key="id"
|
||||||
@@ -900,15 +1196,21 @@ onMounted(() => loadAll())
|
|||||||
</div>
|
</div>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
|
|
||||||
|
<el-input v-model="searchKeyword" size="small" style="width: 160px" placeholder="搜索商品..." clearable @keyup.enter="onSearch">
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<el-button size="small" type="primary" :icon="Search" @click="onSearch">搜索</el-button>
|
||||||
|
|
||||||
<div class="gv-filter-spacer" />
|
<div class="gv-filter-spacer" />
|
||||||
<el-radio-group v-model="mode" size="small" @change="onModeChange">
|
<el-radio-group v-model="mode" size="small" @change="onModeChange">
|
||||||
<el-radio-button value="category">品类</el-radio-button>
|
<el-radio-button value="category">品类</el-radio-button>
|
||||||
<el-radio-button value="country">国家</el-radio-button>
|
<el-radio-button value="country">国家</el-radio-button>
|
||||||
|
<el-radio-button value="global">全局</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dual Tree -->
|
<!-- Dual Tree (hidden in global mode) -->
|
||||||
<div class="gv-trees">
|
<div v-show="mode !== 'global'" class="gv-trees">
|
||||||
<!-- Left: 官网分类 + 商品 -->
|
<!-- Left: 官网分类 + 商品 -->
|
||||||
<div class="gv-panel gv-left" :style="{ width: leftPct + '%', flexShrink: 0 }">
|
<div class="gv-panel gv-left" :style="{ width: leftPct + '%', flexShrink: 0 }">
|
||||||
<div class="gv-panel-head">
|
<div class="gv-panel-head">
|
||||||
@@ -935,6 +1237,9 @@ onMounted(() => loadAll())
|
|||||||
@dragover="onCatDragOver($event)"
|
@dragover="onCatDragOver($event)"
|
||||||
@drop="onCatDrop($event, data)"
|
@drop="onCatDrop($event, data)"
|
||||||
>
|
>
|
||||||
|
<el-image v-if="data.icon" :src="data.icon" fit="cover" class="cat-icon">
|
||||||
|
<template #error><div class="cat-icon-placeholder" /></template>
|
||||||
|
</el-image>
|
||||||
<span class="cat-label">{{ data.label }}</span>
|
<span class="cat-label">{{ data.label }}</span>
|
||||||
<span v-if="data.goodCount > 0" class="cat-count">{{ data.goodCount }}</span>
|
<span v-if="data.goodCount > 0" class="cat-count">{{ data.goodCount }}</span>
|
||||||
<span class="cat-actions" @click.stop>
|
<span class="cat-actions" @click.stop>
|
||||||
@@ -973,10 +1278,6 @@ onMounted(() => loadAll())
|
|||||||
>{{ t.tagName }}</span>
|
>{{ t.tagName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="gt-row">
|
|
||||||
<div class="gt-label">优先级</div>
|
|
||||||
<div class="gt-val">{{ data.priority }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="data.originGoodName" class="gt-row">
|
<div v-if="data.originGoodName" class="gt-row">
|
||||||
<div class="gt-label">原产品</div>
|
<div class="gt-label">原产品</div>
|
||||||
<div class="gt-val gt-val-ellipsis">{{ data.originGoodName }}</div>
|
<div class="gt-val gt-val-ellipsis">{{ data.originGoodName }}</div>
|
||||||
@@ -993,7 +1294,8 @@ onMounted(() => loadAll())
|
|||||||
<el-button size="small" link type="danger" :icon="Delete" @click="handleDeleteGood(data.raw)" />
|
<el-button size="small" link type="danger" :icon="Delete" @click="handleDeleteGood(data.raw)" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="data.country || data.tags?.length" class="good-meta">
|
<div v-if="data.country || data.tags?.length || data.originDelisted" class="good-meta">
|
||||||
|
<span v-if="data.originDelisted" class="good-delisted-badge">下架</span>
|
||||||
<span v-if="data.country" class="good-country">{{ data.country }}</span>
|
<span v-if="data.country" class="good-country">{{ data.country }}</span>
|
||||||
<span
|
<span
|
||||||
v-for="t in (data.tags || []).slice(0, 3)" :key="t.id"
|
v-for="t in (data.tags || []).slice(0, 3)" :key="t.id"
|
||||||
@@ -1017,9 +1319,14 @@ onMounted(() => loadAll())
|
|||||||
<div class="gv-panel gv-right">
|
<div class="gv-panel gv-right">
|
||||||
<div class="gv-panel-head">
|
<div class="gv-panel-head">
|
||||||
<span>原产品库</span>
|
<span>原产品库</span>
|
||||||
<el-button size="small" link @click="showAllRight = !showAllRight; setExpand(rightTreeRef, rightTreeData, showAllRight)">
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
{{ showAllRight ? '收起' : '展开' }}
|
<el-button size="small" link :type="showUnconfiguredOnly ? 'primary' : ''" @click="toggleUnconfiguredFilter">
|
||||||
</el-button>
|
{{ showUnconfiguredOnly ? '✓ 仅未配置' : '仅未配置' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" link @click="showAllRight = !showAllRight; setExpand(rightTreeRef, rightTreeData, showAllRight)">
|
||||||
|
{{ showAllRight ? '收起' : '展开' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-tree
|
<el-tree
|
||||||
ref="rightTreeRef"
|
ref="rightTreeRef"
|
||||||
@@ -1033,15 +1340,119 @@ onMounted(() => loadAll())
|
|||||||
<template #default="{ data }">
|
<template #default="{ data }">
|
||||||
<div v-if="data.isOG" class="og-node" draggable="true" @dragstart="onOGDragStart($event, data)">
|
<div v-if="data.isOG" class="og-node" draggable="true" @dragstart="onOGDragStart($event, data)">
|
||||||
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="og-thumb" />
|
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="og-thumb" />
|
||||||
|
<div v-else class="og-thumb og-thumb-placeholder" />
|
||||||
<span class="og-name">{{ data.label }}</span>
|
<span class="og-name">{{ data.label }}</span>
|
||||||
|
<span
|
||||||
|
v-if="data.configuredCount > 0"
|
||||||
|
class="og-badge og-badge--ok"
|
||||||
|
title="已配置 {{ data.configuredCount }} 国"
|
||||||
|
>已配置{{ data.configuredCount > 1 ? ' ' + data.configuredCount : '' }}</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="og-badge og-badge--warn"
|
||||||
|
>未配置</span>
|
||||||
<span v-if="data.goodPrice" class="og-price">¥{{ data.goodPrice }}</span>
|
<span v-if="data.goodPrice" class="og-price">¥{{ data.goodPrice }}</span>
|
||||||
|
<el-button
|
||||||
|
v-if="data.configuredCount > 0"
|
||||||
|
size="small" link :icon="Aim" title="定位到官网商品"
|
||||||
|
@click.stop="locateInLeftTree(data.rawId)"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
v-if="!data.configuredCount"
|
||||||
|
size="small" type="primary" link class="og-config-btn"
|
||||||
|
@click.stop="openConfigFromRightTree(data)"
|
||||||
|
>配置</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="og-cat-node">
|
||||||
|
<span>{{ data.label }}</span>
|
||||||
|
<span v-if="data.totalCount" class="og-cat-count">
|
||||||
|
<template v-if="data.configuredCount < data.totalCount">
|
||||||
|
{{ data.configuredCount }}/{{ data.totalCount }}
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ data.totalCount }}</template>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-else>{{ data.label }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-tree>
|
</el-tree>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Mode: flat list with same panel styling -->
|
||||||
|
<div v-if="mode === 'global'" class="gv-panel gv-global">
|
||||||
|
<div class="gv-panel-head">
|
||||||
|
<template v-if="globalSelectedIds.size > 0">
|
||||||
|
<el-checkbox :model-value="isAllSelected" :indeterminate="isIndeterminate" @change="toggleSelectAll">全选</el-checkbox>
|
||||||
|
<span class="gl-batch-count">已选 {{ globalSelectedIds.size }} 项</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>全部商品 ({{ globalGoodsNodes.length }})</span>
|
||||||
|
<span v-if="!canDragGlobal" class="gl-drag-hint">切换为「默认排序」可拖拽排序</span>
|
||||||
|
</template>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
|
<template v-if="globalSelectedIds.size > 0">
|
||||||
|
<el-button size="small" type="primary" :icon="Top" :loading="moveTopLoading === 'batch'" @click="batchMoveToTop">批量置顶</el-button>
|
||||||
|
<el-button size="small" text @click="clearSelection">取消</el-button>
|
||||||
|
</template>
|
||||||
|
<el-button v-else size="small" link @click="setExpand(virtualContainer.ref ?? null, [], true)">展开</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gv-global-list" v-bind="virtualContainer">
|
||||||
|
<div v-bind="virtualWrapper">
|
||||||
|
<div
|
||||||
|
v-for="{ data } in virtualList"
|
||||||
|
:key="data.id"
|
||||||
|
class="gl-item"
|
||||||
|
:class="{
|
||||||
|
'is-checked': globalSelectedIds.has(data.goodId),
|
||||||
|
'is-dragging': dragGoodId === data.goodId,
|
||||||
|
'is-drop-before': dragOverGoodId === data.goodId && dragOverPos === 'before',
|
||||||
|
'is-drop-after': dragOverGoodId === data.goodId && dragOverPos === 'after',
|
||||||
|
}"
|
||||||
|
:draggable="canDragGlobal"
|
||||||
|
@dragstart="onGlobalDragStart($event, data.goodId)"
|
||||||
|
@dragover="onGlobalDragOver($event, data.goodId)"
|
||||||
|
@dragleave="onGlobalDragLeave($event, data.goodId)"
|
||||||
|
@drop="onGlobalDrop"
|
||||||
|
@dragend="onGlobalDragEnd"
|
||||||
|
>
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="globalSelectedIds.has(data.goodId)"
|
||||||
|
class="gl-checkbox"
|
||||||
|
@change="toggleSelectItem(data.goodId)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="gl-thumb" />
|
||||||
|
<div v-else class="gl-thumb gl-thumb-placeholder" />
|
||||||
|
<div class="gl-info">
|
||||||
|
<div class="gl-name">{{ data.label }}</div>
|
||||||
|
<div class="gl-meta">
|
||||||
|
<span v-if="data.originDelisted" class="gl-delisted-badge">下架</span>
|
||||||
|
<span v-if="data.country" class="gl-country">{{ data.country }}</span>
|
||||||
|
<span
|
||||||
|
v-for="t in (data.tags || []).slice(0, 3)" :key="t.id"
|
||||||
|
class="gl-tag" :style="{ '--tag-color': t.tagColor || '#ccc' }"
|
||||||
|
>{{ t.tagName }}</span>
|
||||||
|
<span v-if="(data.tags?.length || 0) > 3" class="gl-tag-more">+{{ data.tags!.length - 3 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gl-actions">
|
||||||
|
<el-button
|
||||||
|
size="small" link :icon="Top"
|
||||||
|
:loading="moveTopLoading === data.goodId"
|
||||||
|
:title="data.priority > 0 ? '已置顶' : '置顶'"
|
||||||
|
@click.stop="moveToTop(data.goodId)"
|
||||||
|
/>
|
||||||
|
<el-button size="small" link :icon="Edit" @click.stop="openEdit(data.raw)" />
|
||||||
|
<el-button size="small" link type="danger" :icon="Delete" @click.stop="handleDeleteGood(data.raw)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="globalGoodsNodes.length === 0" class="gv-global-empty">
|
||||||
|
<el-empty description="没有匹配的商品" :image-size="60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Config Modal -->
|
<!-- Config Modal -->
|
||||||
<el-dialog v-model="configVisible" title="配置原产品" width="520px" destroy-on-close>
|
<el-dialog v-model="configVisible" title="配置原产品" width="520px" destroy-on-close>
|
||||||
<div v-if="configOG" class="config-og-info">
|
<div v-if="configOG" class="config-og-info">
|
||||||
@@ -1052,7 +1463,7 @@ onMounted(() => loadAll())
|
|||||||
</div>
|
</div>
|
||||||
<el-form label-width="80px" style="margin-top: 16px">
|
<el-form label-width="80px" style="margin-top: 16px">
|
||||||
<el-form-item label="国家">
|
<el-form-item label="国家">
|
||||||
<div v-if="mode === 'category'" class="select-inline">
|
<div v-if="mode === 'category' || !configDropTarget" class="select-inline">
|
||||||
<el-select v-model="configForm.countryId" placeholder="请选择国家" filterable @change="loadConfigPositions">
|
<el-select v-model="configForm.countryId" placeholder="请选择国家" filterable @change="loadConfigPositions">
|
||||||
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
|
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -1061,19 +1472,18 @@ onMounted(() => loadAll())
|
|||||||
<el-tag v-else>{{ allCountries.find(c => c.id === configForm.countryId)?.countryName }}</el-tag>
|
<el-tag v-else>{{ allCountries.find(c => c.id === configForm.countryId)?.countryName }}</el-tag>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="分类">
|
<el-form-item label="分类">
|
||||||
<el-cascader v-if="mode === 'country'" v-model="configForm.cascaderCategory" :options="categoryCascader" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onConfigCascaderChange" />
|
<el-cascader v-if="mode === 'country' || !configDropTarget" v-model="configForm.cascaderCategory" :options="categoryCascader" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onConfigCascaderChange" style="width:100%" />
|
||||||
<el-tag v-else>{{ configDropTarget?.label }}</el-tag>
|
<el-tag v-else>{{ configDropTarget?.label }}</el-tag>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="图片">
|
<el-form-item label="图片">
|
||||||
<div class="img-edit-row">
|
<ImageUpload v-model="configForm.goodImage" label="上传图片" />
|
||||||
<el-image v-if="configForm.goodImage" :src="configForm.goodImage" fit="cover" class="img-preview" />
|
|
||||||
<el-input v-model="configForm.goodImage" placeholder="图片 URL(可选)" />
|
|
||||||
</div>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="标签">
|
<el-form-item label="标签">
|
||||||
<div class="select-inline">
|
<div class="select-inline">
|
||||||
<el-select v-model="configForm.tagIds" multiple filterable placeholder="选择标签" style="flex:1">
|
<el-select v-model="configForm.tagIds" multiple filterable placeholder="选择标签" style="flex:1">
|
||||||
<el-option v-for="t in allTags" :key="t.id" :label="t.tagName" :value="t.id" />
|
<el-option-group v-for="g in groupedTagOptions" :key="g.id" :label="g.label">
|
||||||
|
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
||||||
|
</el-option-group>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) configForm.value.tagIds.push(latest.id) })" />
|
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) configForm.value.tagIds.push(latest.id) })" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1083,9 +1493,6 @@ onMounted(() => loadAll())
|
|||||||
<el-option v-for="p in configPositions" :key="p.id" :label="`#${p.indexVal}`" :value="p.id" />
|
<el-option v-for="p in configPositions" :key="p.id" :label="`#${p.indexVal}`" :value="p.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="优先级">
|
|
||||||
<el-input-number v-model="configForm.priority" :min="0" :max="9999" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="configVisible = false">取消</el-button>
|
<el-button @click="configVisible = false">取消</el-button>
|
||||||
@@ -1107,10 +1514,7 @@ onMounted(() => loadAll())
|
|||||||
<el-form label-width="80px" style="margin-top: 16px">
|
<el-form label-width="80px" style="margin-top: 16px">
|
||||||
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
|
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
|
||||||
<el-form-item label="图片">
|
<el-form-item label="图片">
|
||||||
<div class="img-edit-row">
|
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
|
||||||
<el-image v-if="editForm.goodImage" :src="editForm.goodImage" fit="cover" class="img-preview" />
|
|
||||||
<el-input v-model="editForm.goodImage" placeholder="图片 URL(留空则使用原产品图片)" />
|
|
||||||
</div>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="国家">
|
<el-form-item label="国家">
|
||||||
<div class="select-inline">
|
<div class="select-inline">
|
||||||
@@ -1125,13 +1529,14 @@ onMounted(() => loadAll())
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="标签">
|
<el-form-item label="标签">
|
||||||
<div class="select-inline">
|
<div class="select-inline">
|
||||||
<el-select v-model="editForm.tagIds" multiple filterable style="flex:1">
|
<el-select v-model="editForm.tagIds" multiple filterable placeholder="选择标签" style="flex:1">
|
||||||
<el-option v-for="t in allTags" :key="t.id" :label="t.tagName" :value="t.id" />
|
<el-option-group v-for="g in groupedTagOptions" :key="g.id" :label="g.label">
|
||||||
|
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
||||||
|
</el-option-group>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) editForm.value.tagIds.push(latest.id) })" />
|
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) editForm.value.tagIds.push(latest.id) })" />
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="优先级"><el-input-number v-model="editForm.priority" :min="0" :max="9999" /></el-form-item>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button type="danger" @click="editGood && handleDeleteGood(editGood)">删除</el-button>
|
<el-button type="danger" @click="editGood && handleDeleteGood(editGood)">删除</el-button>
|
||||||
@@ -1144,7 +1549,7 @@ onMounted(() => loadAll())
|
|||||||
<el-dialog v-model="catEditVisible" :title="catEditMode === 'create' ? '新增分类' : '编辑分类'" width="420px" destroy-on-close>
|
<el-dialog v-model="catEditVisible" :title="catEditMode === 'create' ? '新增分类' : '编辑分类'" width="420px" destroy-on-close>
|
||||||
<el-form label-width="80px">
|
<el-form label-width="80px">
|
||||||
<el-form-item label="名称"><el-input v-model="catEditForm.categoryName" placeholder="分类名称" /></el-form-item>
|
<el-form-item label="名称"><el-input v-model="catEditForm.categoryName" placeholder="分类名称" /></el-form-item>
|
||||||
<el-form-item label="图标"><el-input v-model="catEditForm.categoryIcon" placeholder="图标 URL(可选)" /></el-form-item>
|
<el-form-item label="图标"><ImageUpload v-model="catEditForm.categoryIcon" label="上传图标" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="catEditVisible = false">取消</el-button>
|
<el-button @click="catEditVisible = false">取消</el-button>
|
||||||
@@ -1156,7 +1561,7 @@ onMounted(() => loadAll())
|
|||||||
<el-dialog v-model="countryEditVisible" :title="countryEditMode === 'create' ? '新增国家' : '编辑国家'" width="420px" destroy-on-close>
|
<el-dialog v-model="countryEditVisible" :title="countryEditMode === 'create' ? '新增国家' : '编辑国家'" width="420px" destroy-on-close>
|
||||||
<el-form label-width="80px">
|
<el-form label-width="80px">
|
||||||
<el-form-item label="名称"><el-input v-model="countryEditForm.countryName" placeholder="国家名称" /></el-form-item>
|
<el-form-item label="名称"><el-input v-model="countryEditForm.countryName" placeholder="国家名称" /></el-form-item>
|
||||||
<el-form-item label="图标"><el-input v-model="countryEditForm.countryIcon" placeholder="图标 URL(可选)" /></el-form-item>
|
<el-form-item label="图标"><ImageUpload v-model="countryEditForm.countryIcon" label="上传图标" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="countryEditVisible = false">取消</el-button>
|
<el-button @click="countryEditVisible = false">取消</el-button>
|
||||||
@@ -1233,6 +1638,77 @@ onMounted(() => loadAll())
|
|||||||
font-weight: 600; font-size: 14px; color: #303133;
|
font-weight: 600; font-size: 14px; color: #303133;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Global mode ─── */
|
||||||
|
.gv-global {
|
||||||
|
flex: 1; min-height: 0; display: flex; flex-direction: column;
|
||||||
|
background: #fff; border-radius: 8px; overflow: hidden;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gv-global-list {
|
||||||
|
flex: 1; min-height: 0; overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Virtual list item */
|
||||||
|
.gl-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
height: 64px; padding: 0 12px;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
cursor: pointer; transition: background 0.12s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.gl-item:hover { background: #f5f7fa; }
|
||||||
|
.gl-item.is-checked { background: #ecf5ff; }
|
||||||
|
.gl-item.is-dragging { opacity: 0.4; }
|
||||||
|
.gl-item.is-drop-before { box-shadow: inset 0 2px 0 0 var(--brand-color, #ff6800); }
|
||||||
|
.gl-item.is-drop-after { box-shadow: inset 0 -2px 0 0 var(--brand-color, #ff6800); }
|
||||||
|
|
||||||
|
.gl-checkbox { flex-shrink: 0; margin-right: -4px; }
|
||||||
|
.gl-batch-count { font-size: 13px; font-weight: 600; color: var(--brand-color, #ff6800); }
|
||||||
|
.gl-drag-hint { font-size: 11px; color: #c0c4cc; margin-left: 4px; }
|
||||||
|
|
||||||
|
.gl-thumb {
|
||||||
|
width: 40px; height: 40px; border-radius: 6px; flex-shrink: 0; object-fit: cover;
|
||||||
|
}
|
||||||
|
.gl-thumb-placeholder {
|
||||||
|
background: linear-gradient(135deg, #f5f7fa, #e9ecef);
|
||||||
|
}
|
||||||
|
.gl-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.gl-name {
|
||||||
|
font-size: 13px; font-weight: 500; color: #303133;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.gl-meta { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.gl-country {
|
||||||
|
font-size: 11px; color: #909399; line-height: 1;
|
||||||
|
background: #f5f7fa; padding: 2px 6px; border-radius: 8px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.gl-tag {
|
||||||
|
font-size: 11px; color: #606266; line-height: 1;
|
||||||
|
padding: 2px 6px 2px 12px; border-radius: 8px;
|
||||||
|
background: #fafafa; position: relative; flex-shrink: 0;
|
||||||
|
max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.gl-tag::before {
|
||||||
|
content: ''; position: absolute; left: 5px; top: 50%;
|
||||||
|
transform: translateY(-50%); width: 5px; height: 5px; border-radius: 50%;
|
||||||
|
background: var(--tag-color, #ccc);
|
||||||
|
}
|
||||||
|
.gl-tag-more { font-size: 11px; color: #c0c4cc; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.gl-delisted-badge {
|
||||||
|
font-size: 10px; font-weight: 600; color: #f56c6c;
|
||||||
|
background: #fef0f0;
|
||||||
|
padding: 2px 8px; border-radius: 10px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.gl-actions {
|
||||||
|
display: flex; gap: 2px; flex-shrink: 0;
|
||||||
|
opacity: 0; transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.gl-item:hover .gl-actions { opacity: 1; }
|
||||||
|
|
||||||
|
.gv-global-empty { padding: 40px 0; }
|
||||||
|
|
||||||
/* Splitter */
|
/* Splitter */
|
||||||
.gv-splitter {
|
.gv-splitter {
|
||||||
flex-shrink: 0; width: 8px; cursor: col-resize;
|
flex-shrink: 0; width: 8px; cursor: col-resize;
|
||||||
@@ -1252,7 +1728,10 @@ onMounted(() => loadAll())
|
|||||||
.gv-left :deep(.el-tree-node__content > .el-tree-node__expand-icon) { flex-shrink: 0; }
|
.gv-left :deep(.el-tree-node__content > .el-tree-node__expand-icon) { flex-shrink: 0; }
|
||||||
|
|
||||||
/* Left tree: Category node */
|
/* Left tree: Category node */
|
||||||
.cat-node { display: flex; align-items: center; flex: 1; min-width: 0; padding: 2px 8px; }
|
.cat-node { display: flex; align-items: center; flex: 1; min-width: 0; padding: 2px 8px; gap: 6px; }
|
||||||
|
.cat-icon { width: 20px; height: 20px; border-radius: 4px; flex-shrink: 0; object-fit: cover; }
|
||||||
|
.cat-icon :deep(img) { width: 20px; height: 20px; border-radius: 4px; }
|
||||||
|
.cat-icon-placeholder { width: 20px; height: 20px; border-radius: 4px; background: linear-gradient(135deg, #f5f7fa, #e9ecef); }
|
||||||
.cat-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
.cat-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
||||||
.cat-count {
|
.cat-count {
|
||||||
background: #f0f0f0; color: #909399; border-radius: 10px;
|
background: #f0f0f0; color: #909399; border-radius: 10px;
|
||||||
@@ -1299,6 +1778,11 @@ onMounted(() => loadAll())
|
|||||||
background: #f5f7fa; padding: 2px 6px; border-radius: 8px;
|
background: #f5f7fa; padding: 2px 6px; border-radius: 8px;
|
||||||
flex-shrink: 0; white-space: nowrap;
|
flex-shrink: 0; white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.good-delisted-badge {
|
||||||
|
font-size: 11px; color: #f56c6c; line-height: 1;
|
||||||
|
background: #fef0f0; padding: 2px 6px; border-radius: 8px;
|
||||||
|
flex-shrink: 0; white-space: nowrap;
|
||||||
|
}
|
||||||
.good-tag {
|
.good-tag {
|
||||||
font-size: 11px; color: #606266; line-height: 1;
|
font-size: 11px; color: #606266; line-height: 1;
|
||||||
padding: 2px 6px 2px 12px; border-radius: 8px;
|
padding: 2px 6px 2px 12px; border-radius: 8px;
|
||||||
@@ -1327,8 +1811,32 @@ onMounted(() => loadAll())
|
|||||||
.og-node:active { cursor: grabbing; }
|
.og-node:active { cursor: grabbing; }
|
||||||
.og-node:hover { background: #f5f7fa; }
|
.og-node:hover { background: #f5f7fa; }
|
||||||
.og-thumb { width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; object-fit: cover; }
|
.og-thumb { width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; object-fit: cover; }
|
||||||
|
.og-thumb-placeholder {
|
||||||
|
background: linear-gradient(135deg, #f5f7fa, #e9ecef);
|
||||||
|
}
|
||||||
.og-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
|
.og-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
|
||||||
.og-price { color: #909399; font-size: 12px; flex-shrink: 0; }
|
.og-price { color: #909399; font-size: 12px; flex-shrink: 0; }
|
||||||
|
.og-badge {
|
||||||
|
font-size: 10px; line-height: 1; padding: 3px 6px; border-radius: 8px;
|
||||||
|
flex-shrink: 0; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.og-badge--ok {
|
||||||
|
color: #67c23a; background: #f0f9eb;
|
||||||
|
}
|
||||||
|
.og-badge--warn {
|
||||||
|
color: #ff6800; background: #fff2e8;
|
||||||
|
}
|
||||||
|
.og-config-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.og-cat-node {
|
||||||
|
display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
.og-cat-count {
|
||||||
|
font-size: 10px; color: #909399; background: #f0f0f0;
|
||||||
|
padding: 1px 6px; border-radius: 8px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Image edit row */
|
/* Image edit row */
|
||||||
.img-edit-row { display: flex; align-items: center; gap: 8px; width: 100%; }
|
.img-edit-row { display: flex; align-items: center; gap: 8px; width: 100%; }
|
||||||
@@ -1441,21 +1949,26 @@ onMounted(() => loadAll())
|
|||||||
.tag-select-trigger:hover {
|
.tag-select-trigger:hover {
|
||||||
border-color: #c0c4cc;
|
border-color: #c0c4cc;
|
||||||
}
|
}
|
||||||
|
.tag-select-trigger.is-active {
|
||||||
|
border-color: var(--brand-color);
|
||||||
|
}
|
||||||
.tag-select-trigger .placeholder {
|
.tag-select-trigger .placeholder {
|
||||||
color: #a8abb2;
|
color: #a8abb2;
|
||||||
}
|
}
|
||||||
.tag-select-trigger .arrow {
|
.tag-select-trigger .tag-arrow {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
color: #c0c4cc;
|
color: #a8abb2;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.3s ease, color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tag-select-trigger.is-active .tag-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
color: var(--brand-color);
|
||||||
}
|
}
|
||||||
.tag-select-trigger.is-filled {
|
.tag-select-trigger.is-filled {
|
||||||
color: #111;
|
color: #111;
|
||||||
}
|
}
|
||||||
.tag-select-trigger.is-filled .arrow {
|
|
||||||
color: #909399;
|
|
||||||
}
|
|
||||||
.tag-select-trigger .more-tag {
|
.tag-select-trigger .more-tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1476,6 +1989,10 @@ onMounted(() => loadAll())
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
.tag-tree-toolbar {
|
||||||
|
padding: 4px 8px 6px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
.tag-filter-popper .el-tree {
|
.tag-filter-popper .el-tree {
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Refresh, Box } from '@element-plus/icons-vue'
|
import { Refresh, Box, Clock, CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue'
|
||||||
import type { SyncLog } from '@/types'
|
import type { SyncLog } from '@/types'
|
||||||
import { syncApi } from '@/api/sync'
|
import { syncApi } from '@/api/sync'
|
||||||
|
|
||||||
const logs = ref<SyncLog[]>([])
|
const logs = ref<SyncLog[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
|
const currentType = ref<'PRODUCTS' | 'CATEGORIES'>('PRODUCTS')
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
async function refreshLogs() {
|
async function refreshLogs() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -23,138 +25,427 @@ async function refreshLogs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSync() {
|
async function pollUntilDone(type: 'PRODUCTS' | 'CATEGORIES') {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const data = await syncApi.getSyncStatus(5) as any
|
||||||
|
const latest = Array.isArray(data) ? data : []
|
||||||
|
if (latest.length > 0) logs.value = [...latest, ...logs.value.slice(latest.length)]
|
||||||
|
const top = latest.find((l: SyncLog) => l.type === type)
|
||||||
|
if (top && top.status !== 'RUNNING') {
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
|
syncing.value = false
|
||||||
|
if (top.status === 'SUCCESS') {
|
||||||
|
ElMessage.success(`${type === 'PRODUCTS' ? '产品' : '分类'}同步完成`)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(`${type === 'PRODUCTS' ? '产品' : '分类'}同步失败`)
|
||||||
|
}
|
||||||
|
await refreshLogs()
|
||||||
|
}
|
||||||
|
} catch { /* ignore poll errors */ }
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncProducts() {
|
||||||
|
await doSync('PRODUCTS')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncCategories() {
|
||||||
|
await doSync('CATEGORIES')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSync(type: 'PRODUCTS' | 'CATEGORIES') {
|
||||||
|
const label = type === 'PRODUCTS' ? '产品' : '分类'
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'确定立即执行产品同步吗?此操作可能需要一些时间。',
|
`确定立即执行${label}同步吗?${type === 'PRODUCTS' ? '此操作可能需要几分钟。' : ''}`,
|
||||||
'确认',
|
'确认',
|
||||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||||
)
|
)
|
||||||
} catch { return }
|
} catch { return }
|
||||||
|
|
||||||
syncing.value = true
|
syncing.value = true
|
||||||
|
currentType.value = type
|
||||||
try {
|
try {
|
||||||
await syncApi.syncProducts()
|
if (type === 'PRODUCTS') {
|
||||||
ElMessage.success('产品同步完成')
|
await syncApi.syncProducts()
|
||||||
await refreshLogs()
|
} else {
|
||||||
|
await syncApi.syncCategories()
|
||||||
|
}
|
||||||
|
ElMessage.info(`${label}同步已开始`)
|
||||||
|
pollUntilDone(type)
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('产品同步失败')
|
ElMessage.error(`${label}同步启动失败`)
|
||||||
} finally {
|
|
||||||
syncing.value = false
|
syncing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(s?: string): string {
|
function formatTime(s?: string): string {
|
||||||
if (!s) return '-'
|
if (!s) return '-'
|
||||||
return new Date(s).toLocaleString()
|
const d = new Date(s)
|
||||||
|
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(start?: string, end?: string): string | null {
|
||||||
|
if (!start || !end) return null
|
||||||
|
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = computed(() => {
|
||||||
|
const total = logs.value.length
|
||||||
|
const success = logs.value.filter(l => l.status === 'SUCCESS').length
|
||||||
|
const failed = total - success
|
||||||
|
const lastLog = logs.value[0]
|
||||||
|
return { total, success, failed, lastLog }
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshLogs()
|
refreshLogs()
|
||||||
timer = setInterval(refreshLogs, 30_000)
|
timer = setInterval(refreshLogs, 60_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (timer) clearInterval(timer)
|
if (timer) clearInterval(timer)
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="sync-page" v-loading="loading">
|
||||||
<div class="sync-cards">
|
<!-- Action Card -->
|
||||||
<el-card class="sync-card">
|
<div class="sync-action-card">
|
||||||
<template #header>
|
<div class="sync-action-info">
|
||||||
<div class="sync-card-header">
|
<div class="sync-action-icon">
|
||||||
<div class="sync-card-title">
|
<el-icon :size="28"><Box /></el-icon>
|
||||||
<el-icon><Box /></el-icon>
|
</div>
|
||||||
<span>产品同步</span>
|
<div>
|
||||||
</div>
|
<h2 class="sync-action-title">数据同步</h2>
|
||||||
</div>
|
<p class="sync-action-desc">从上游 SDS 系统拉取最新分类和产品数据并同步到本地数据库</p>
|
||||||
</template>
|
</div>
|
||||||
<p class="sync-card-desc">
|
</div>
|
||||||
从上游拉取最新产品和原产品并与本地数据库进行同步。
|
<div class="sync-action-buttons">
|
||||||
</p>
|
<el-button
|
||||||
<el-button type="primary" :loading="syncing" @click="handleSync">
|
size="large"
|
||||||
<el-icon><Refresh /></el-icon>
|
:loading="syncing && currentType === 'CATEGORIES'"
|
||||||
<span>执行产品同步</span>
|
:disabled="syncing"
|
||||||
|
@click="handleSyncCategories"
|
||||||
|
>
|
||||||
|
同步分类
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-card>
|
<el-button
|
||||||
</div>
|
type="primary"
|
||||||
|
size="large"
|
||||||
<div class="page-card logs-card">
|
:loading="syncing && currentType === 'PRODUCTS'"
|
||||||
<div class="logs-toolbar">
|
:disabled="syncing"
|
||||||
<h3 class="logs-title">同步日志</h3>
|
:icon="Refresh"
|
||||||
<el-button @click="refreshLogs">
|
@click="handleSyncProducts"
|
||||||
<el-icon><Refresh /></el-icon>
|
>
|
||||||
<span>刷新</span>
|
同步产品
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="logs" border stripe>
|
<!-- Stats Row -->
|
||||||
<el-table-column label="状态" width="100">
|
<div class="sync-stats">
|
||||||
<template #default="{ row }: { row: SyncLog }">
|
<div class="stat-item">
|
||||||
<el-tag :type="row.status === 'SUCCESS' ? 'success' : 'danger'" size="small">
|
<span class="stat-value">{{ stats.total }}</span>
|
||||||
{{ row.status === 'SUCCESS' ? '成功' : '失败' }}
|
<span class="stat-label">总同步次数</span>
|
||||||
</el-tag>
|
</div>
|
||||||
</template>
|
<div class="stat-divider" />
|
||||||
</el-table-column>
|
<div class="stat-item">
|
||||||
<el-table-column label="时间" width="200">
|
<span class="stat-value stat-success">{{ stats.success }}</span>
|
||||||
<template #default="{ row }: { row: SyncLog }">
|
<span class="stat-label">成功</span>
|
||||||
{{ formatDate(row.startTime) }}
|
</div>
|
||||||
</template>
|
<div class="stat-divider" />
|
||||||
</el-table-column>
|
<div class="stat-item">
|
||||||
<el-table-column prop="message" label="信息" min-width="200" show-overflow-tooltip />
|
<span class="stat-value stat-failed">{{ stats.failed }}</span>
|
||||||
<template #empty>
|
<span class="stat-label">失败</span>
|
||||||
<el-empty description="暂无同步记录" />
|
</div>
|
||||||
</template>
|
<div class="stat-divider" />
|
||||||
</el-table>
|
<div class="stat-item">
|
||||||
|
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startTime) : '-' }}</span>
|
||||||
|
<span class="stat-label">最近同步</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Timeline -->
|
||||||
|
<div class="sync-logs">
|
||||||
|
<div class="sync-logs-head">
|
||||||
|
<h3 class="sync-logs-title">同步日志</h3>
|
||||||
|
<el-button text :icon="Refresh" @click="refreshLogs">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="logs.length === 0 && !loading" class="sync-empty">
|
||||||
|
<el-empty description="暂无同步记录" :image-size="80" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="sync-timeline">
|
||||||
|
<div
|
||||||
|
v-for="log in logs"
|
||||||
|
:key="log.id"
|
||||||
|
class="timeline-item"
|
||||||
|
>
|
||||||
|
<div class="timeline-dot" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||||
|
<el-icon :size="12">
|
||||||
|
<Loading v-if="log.status === 'RUNNING'" />
|
||||||
|
<CircleCheck v-else-if="log.status === 'SUCCESS'" />
|
||||||
|
<CircleClose v-else />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="timeline-header">
|
||||||
|
<span class="timeline-type">{{ log.type === 'PRODUCTS' ? '产品' : '分类' }}</span>
|
||||||
|
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||||
|
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="formatDuration(log.startTime, log.endTime)" class="timeline-duration">
|
||||||
|
<el-icon :size="11"><Clock /></el-icon>
|
||||||
|
{{ formatDuration(log.startTime, log.endTime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
|
||||||
|
<span class="timeline-time">{{ formatTime(log.startTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.sync-cards {
|
.sync-page {
|
||||||
display: grid;
|
height: 100%;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
overflow-y: auto;
|
||||||
gap: 16px;
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Card */
|
||||||
|
.sync-action-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 28px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-card-header {
|
.sync-action-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-action-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #fff2e8, #ffe0c2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--brand-color, #ff6800);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-action-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-action-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.sync-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
padding: 16px 28px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.stat-success { color: #67c23a; }
|
||||||
|
.stat-value.stat-failed { color: #f56c6c; }
|
||||||
|
.stat-value.stat-time { font-size: 14px; font-weight: 600; color: #606266; }
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 32px;
|
||||||
|
background: #ebeef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logs */
|
||||||
|
.sync-logs {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-logs-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-card-title {
|
.sync-logs-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-empty {
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline */
|
||||||
|
.sync-timeline {
|
||||||
|
padding: 16px 20px;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:not(:last-child)::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 7px;
|
||||||
|
top: 22px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot.is-success {
|
||||||
|
background: #f0f9eb;
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot.is-failed {
|
||||||
|
background: #fef0f0;
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot.is-running {
|
||||||
|
background: #ecf5ff;
|
||||||
|
color: #409eff;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-weight: 600;
|
margin-bottom: 4px;
|
||||||
font-size: 15px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-card-title :deep(.el-icon) {
|
.timeline-status {
|
||||||
color: var(--brand-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sync-card-desc {
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin: 0 0 16px;
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-status.is-success { color: #67c23a; }
|
||||||
|
.timeline-status.is-failed { color: #f56c6c; }
|
||||||
|
.timeline-status.is-running { color: #409eff; }
|
||||||
|
|
||||||
|
.timeline-type {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #606266;
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-duration {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #909399;
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-message {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
@@ -32,6 +33,14 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
},
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/assets': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
coverage
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
.env
|
|
||||||
@@ -43,3 +43,6 @@ lerna-debug.log*
|
|||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
/uploads
|
||||||
@@ -20,7 +20,8 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio"
|
"prisma:studio": "prisma studio",
|
||||||
|
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^3.0.1",
|
"@nestjs/axios": "^3.0.1",
|
||||||
@@ -33,11 +34,13 @@
|
|||||||
"@nestjs/schedule": "^4.0.0",
|
"@nestjs/schedule": "^4.0.0",
|
||||||
"@nestjs/swagger": "^7.1.17",
|
"@nestjs/swagger": "^7.1.17",
|
||||||
"@prisma/client": "^5.8.0",
|
"@prisma/client": "^5.8.0",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
"multer": "^2.2.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.1",
|
"reflect-metadata": "^0.2.1",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const countryIcons: Record<string, string> = {
|
||||||
|
美国: '/assets/product-center/countries/us.png',
|
||||||
|
日本: '/assets/product-center/countries/jp.png',
|
||||||
|
墨西哥: '/assets/product-center/countries/mx.png',
|
||||||
|
巴西: '/assets/product-center/countries/br.png',
|
||||||
|
中东: '/assets/product-center/countries/middle-east.png',
|
||||||
|
波兰: '/assets/product-center/countries/pl.png',
|
||||||
|
西班牙: '/assets/product-center/countries/es.png',
|
||||||
|
德国: '/assets/product-center/countries/de.png',
|
||||||
|
意大利: '/assets/product-center/countries/it.png',
|
||||||
|
英国: '/assets/product-center/countries/gb.png',
|
||||||
|
加拿大: '/assets/product-center/countries/ca.png',
|
||||||
|
澳大利亚: '/assets/product-center/countries/au.png',
|
||||||
|
韩国: '/assets/product-center/countries/kr.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryIcons: Record<string, string> = {
|
||||||
|
男士服装: '/assets/product-center/categories/men.svg',
|
||||||
|
女士服装: '/assets/product-center/categories/women.svg',
|
||||||
|
儿童服装: '/assets/product-center/categories/children.svg',
|
||||||
|
家居配饰: '/assets/product-center/categories/home.svg',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function updateExistingIcons(
|
||||||
|
entries: Record<string, string>,
|
||||||
|
update: (name: string, icon: string) => Promise<{ count: number }>,
|
||||||
|
): Promise<number> {
|
||||||
|
let updated = 0;
|
||||||
|
for (const [name, icon] of Object.entries(entries)) {
|
||||||
|
const result = await update(name, icon);
|
||||||
|
updated += result.count;
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const countries = await updateExistingIcons(countryIcons, (countryName, countryIcon) =>
|
||||||
|
prisma.country.updateMany({ where: { countryName }, data: { countryIcon } }),
|
||||||
|
);
|
||||||
|
const categories = await updateExistingIcons(categoryIcons, (categoryName, categoryIcon) =>
|
||||||
|
prisma.category.updateMany({
|
||||||
|
where: { categoryName, parentCategoryId: null },
|
||||||
|
data: { categoryIcon },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.log(`Configured ${countries} countries and ${categories} root categories.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.finally(async () => prisma.$disconnect());
|
||||||
@@ -22,6 +22,7 @@ model OriginGood {
|
|||||||
goodName String? @map("good_name")
|
goodName String? @map("good_name")
|
||||||
goodImage String? @map("good_image")
|
goodImage String? @map("good_image")
|
||||||
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
||||||
|
delisted Boolean @default(false)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="毛毯">
|
||||||
|
<path id="Vector" d="M4.88633 1.98665C5.33119 1.97156 5.8398 1.98301 6.28931 1.98301L8.87332 1.98303L16.492 1.98247C17.189 1.98315 17.7065 2.1872 18.0772 2.81271C18.3143 3.2131 18.2842 3.61555 18.2841 4.05998L18.2832 4.9648C18.2977 4.96406 18.3123 4.96311 18.327 4.96317C18.946 4.96544 19.5651 4.96377 20.1842 4.96464C20.35 4.96487 20.5158 4.9649 20.6812 4.97965C21.3291 5.03911 21.9365 5.32106 22.4002 5.77761C22.9854 6.35146 23.2455 7.05899 23.2511 7.8691L23.2513 15.9239L23.2516 18.1581C23.2517 18.5872 23.2748 19.2749 23.1974 19.6785C23.0883 20.23 22.8164 20.7362 22.417 21.1318C22.2073 21.3409 21.9682 21.518 21.7072 21.6575C20.8557 22.1159 20.0448 22.0235 19.1195 22.0226H8.94568L6.28116 22.0228C5.66064 22.0231 4.88519 22.0568 4.28608 21.9464C3.4868 21.8008 2.74539 21.4309 2.14822 20.88C1.30557 20.1054 0.806162 19.027 0.760423 17.8834C0.744748 17.5372 0.750489 17.1759 0.750662 16.8281L0.750771 15.2893L0.750812 10.4876L0.750558 7.56548C0.750482 6.91186 0.712701 6.06771 0.837323 5.44551C0.997082 4.66425 1.36911 3.94214 1.91253 3.35852C2.68497 2.52818 3.75329 2.03535 4.88633 1.98665ZM1.50862 15.253C2.14832 14.3061 3.14214 13.6566 4.26615 13.4506C4.77379 13.3528 5.22789 13.3733 5.74026 13.3733L7.22112 13.3737L12.4984 13.3738L15.4964 13.3742L16.3552 13.3732C16.8991 13.3726 17.0379 13.3561 17.5334 13.5986V6.61544V4.56193C17.5335 4.30358 17.549 3.72192 17.5225 3.49302C17.5006 3.30198 17.4139 3.12419 17.2769 2.98926C16.9857 2.69911 16.6673 2.73987 16.2868 2.74018L15.5406 2.7411L13.0675 2.74118L5.14866 2.74166C5.07448 2.74247 5.00032 2.74432 4.92619 2.7472C3.92002 2.80855 3.077 3.17975 2.40127 3.93833C1.97103 4.42125 1.68113 5.01268 1.563 5.64857C1.47184 6.14183 1.50058 6.87789 1.50091 7.39632L1.50111 9.95416L1.50107 13.4367L1.50105 14.5679C1.50109 14.7909 1.49296 15.0324 1.50862 15.253ZM8.99453 18.9369L16.2925 18.9372L18.6071 18.9366L19.3445 18.9363C19.5484 18.9363 19.9029 18.898 20.056 19.0438C20.1299 19.1145 20.1708 19.2128 20.1691 19.3151C20.1667 19.4209 20.1207 19.5211 20.0418 19.5917C19.9477 19.6764 19.8889 19.6822 19.7676 19.6861C19.4157 19.6972 19.0567 19.6923 18.7049 19.6923L16.8063 19.6924H11.0456L6.92691 19.6928L5.64741 19.6927C5.38641 19.6927 5.09412 19.7061 4.83641 19.6802C3.90898 19.587 3.25249 18.7671 3.34189 17.8533C3.38568 17.4162 3.6029 17.0151 3.94493 16.7394C4.20443 16.5318 4.51749 16.402 4.84778 16.3651C5.04529 16.3417 5.27545 16.3472 5.47726 16.3473L6.33113 16.3475L9.20144 16.3477L14.7026 16.3475C15.6297 16.3475 16.6073 16.3314 17.5311 16.3491L17.5349 15.4343C17.535 15.2346 17.5476 14.9099 17.5039 14.73C17.4709 14.5924 17.402 14.4661 17.3042 14.3639C17.0397 14.0929 16.7182 14.1312 16.371 14.1312L15.6384 14.1317H13.1882L5.32143 14.1324C5.24029 14.1331 5.15918 14.1352 5.07812 14.1387C4.01313 14.198 3.10203 14.573 2.38161 15.3836C1.76329 16.0772 1.44917 16.99 1.50965 17.9172C1.56819 18.8651 2.00149 19.7507 2.71393 20.3786C3.22367 20.8289 3.85296 21.1221 4.52573 21.2224C4.85955 21.2734 5.19037 21.2625 5.52735 21.2625L6.74896 21.2624L11.0247 21.2619L17.3304 21.2618L19.3445 21.2623C19.7137 21.2624 20.0973 21.2727 20.4665 21.2537C21.0698 21.2227 21.64 20.8929 22.0275 20.4408C22.7492 19.599 22.6019 18.3111 21.764 17.5974C21.451 17.33 21.0641 17.164 20.6547 17.1215C20.4169 17.0944 20.0379 17.1047 19.7895 17.1046H18.3545L13.6232 17.105L7.80963 17.1052L5.98936 17.1046C5.66054 17.1045 5.3165 17.0978 4.98927 17.1128C4.72169 17.1251 4.46675 17.2485 4.29114 17.4549C4.13868 17.6329 4.06436 17.8648 4.08495 18.0982C4.10752 18.3431 4.22707 18.5688 4.41697 18.725C4.72065 18.9786 5.05403 18.9374 5.4256 18.9375H6.19901L8.99453 18.9369ZM18.2839 16.3473C18.5978 16.3331 19.0591 16.3473 19.3878 16.3477C19.8266 16.3481 20.4738 16.3234 20.8846 16.3909C21.1822 16.4405 21.4698 16.5372 21.7369 16.6773C21.9299 16.7794 22.1104 16.9035 22.2749 17.0472C22.3199 17.0859 22.4581 17.2153 22.4969 17.2411C22.5077 16.9956 22.5012 16.7118 22.501 16.4625L22.5009 15.1418L22.5006 11.0416L22.5007 8.79514C22.5011 8.43763 22.5203 7.7808 22.4675 7.45223C22.403 7.05879 22.2247 6.69285 21.9545 6.3996C21.09 5.45927 20.0243 5.7749 18.8906 5.72076C18.7008 5.71171 18.4688 5.7379 18.2855 5.71609C18.2818 6.29028 18.2808 6.8645 18.2829 7.4387L18.2832 10.5551L18.2839 16.3473Z" fill="var(--fill-0, black)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 16.9444 21.2465" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Group 40">
|
||||||
|
<path id="Vector" d="M4.60937 9.39516C4.83089 9.41263 5.19797 9.40155 5.42769 9.40155L6.94956 9.40133C8.71577 9.40094 10.5191 9.38265 12.2815 9.40662C12.9508 11.3307 14.3581 13.4287 15.5839 15.0446C15.8822 15.4378 16.7974 16.3807 16.9444 16.6953C16.6383 17.2507 15.7458 17.7321 15.2033 18.0528C14.5811 18.4207 14.5649 18.082 14.2723 17.5918C14.0964 17.2971 13.9313 16.9779 13.7623 16.6745L12.1526 13.8128C11.8994 13.3696 11.5575 12.7309 11.217 12.3736C11.7063 14.5828 12.9504 16.8791 14.0188 18.8723C14.1871 19.1861 13.9309 19.3898 13.694 19.5564C13.4559 19.7238 12.6714 20.2953 12.3918 20.2153C12.2449 20.0934 12.0742 19.5867 11.9879 19.4213C11.7179 18.9037 10.0267 14.7534 9.73361 14.6621C9.63034 14.8155 9.77541 15.2831 9.80986 15.4684C10.1336 17.2094 10.7771 18.8659 11.4514 20.4962C11.5693 20.9293 9.64682 21.1744 9.30522 21.2057C8.24188 21.3076 7.16888 21.2186 6.13686 20.9428C5.9456 20.8923 5.66846 20.8251 5.5108 20.6989C5.46617 20.6631 5.41691 20.5888 5.43238 20.5306C5.54204 20.1186 5.80851 19.6085 5.95323 19.214C6.38496 18.0368 7.18965 15.777 7.16438 14.5671C6.91075 14.8989 6.6099 15.5769 6.42632 15.9742C6.019 16.8664 5.62029 17.7625 5.23025 18.6625C5.02017 19.1417 4.74544 19.823 4.49277 20.2564C4.44175 20.2465 4.39117 20.2343 4.34122 20.2198C3.94495 20.1042 2.97693 19.496 2.76767 19.1178C2.92234 18.6804 3.44618 17.8446 3.66926 17.3829C4.44438 15.7788 5.25941 14.108 5.66802 12.3696C4.94019 13.1618 2.49025 18.0769 2.21709 18.2064C2.15024 18.238 2.05775 18.2215 1.99036 18.1973C1.55572 18.0414 0.201822 17.2085 0.0193916 16.8083C-0.1163 16.5107 0.497699 15.9933 0.69442 15.7758C2.28029 14.0222 3.77967 11.6073 4.60937 9.39516Z" fill="var(--fill-0, #000000)"/>
|
||||||
|
<path id="Vector_2" d="M4.62793 0.0189548C4.95631 0.0052677 5.36925 0.0045545 5.69706 0.0201891C6.26604 0.0473438 5.85348 1.03493 6.14374 1.42184C6.3697 1.72304 6.60509 1.99478 6.84768 2.28177C7.27209 2.78427 7.69139 3.29099 8.10563 3.80189C9.04203 4.96817 9.85815 6.15223 10.759 7.30756L9.78755 7.31091L4.67883 7.30962C4.61824 6.73838 4.55875 6.30262 4.40945 5.73701C4.12639 4.6645 3.51922 3.65092 3.76517 2.51157C3.87777 1.99001 4.27615 1.65282 4.41615 1.17841C4.48472 0.946111 4.42237 0.412836 4.49314 0.150148C4.51632 0.0640753 4.55721 0.0571635 4.62793 0.0189548Z" fill="var(--fill-0, #000000)"/>
|
||||||
|
<path id="Vector_3" d="M11.061 0.016756C11.3249 0.0125319 12.1975 -0.0521733 12.3509 0.104885C12.4479 0.279115 12.3539 0.945805 12.4893 1.27531C12.6708 1.71711 12.9577 1.96767 13.086 2.42878C13.4027 3.56613 12.7585 4.65918 12.4803 5.74364C12.3332 6.31713 12.2859 6.72258 12.2107 7.30762C11.8729 7.31264 11.5351 7.31283 11.1973 7.30816C11.1476 7.22593 11.0856 7.14044 11.0303 7.06084C10.5544 6.38636 10.0687 5.71895 9.57305 5.05887C9.31283 4.71083 8.96544 4.22429 8.67958 3.91519C8.93354 3.69005 10.7286 1.47014 10.7952 1.27473C10.9056 0.950687 10.8169 0.581355 10.8834 0.247187C10.9088 0.11967 10.9592 0.085164 11.061 0.016756Z" fill="var(--fill-0, #000000)"/>
|
||||||
|
<path id="Vector_4" d="M4.75302 7.85951L12.2149 7.8591C12.2154 8.05077 12.2291 8.77411 12.2066 8.92294L12.1674 8.93525C10.0843 8.9821 7.97963 8.91638 5.8947 8.94109C5.51017 8.94565 5.11366 8.92598 4.73124 8.94817C4.77096 8.6908 4.75466 8.13501 4.75302 7.85951Z" fill="var(--fill-0, #000000)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 292 B |
|
After Width: | Height: | Size: 13 KiB |
@@ -11,6 +11,7 @@ import { OriginGoodsModule } from './origin-goods/origin-goods.module';
|
|||||||
import { GoodsModule } from './goods/goods.module';
|
import { GoodsModule } from './goods/goods.module';
|
||||||
import { SyncModule } from './sync/sync.module';
|
import { SyncModule } from './sync/sync.module';
|
||||||
import { PublicModule } from './public/public.module';
|
import { PublicModule } from './public/public.module';
|
||||||
|
import { UploadModule } from './upload/upload.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -28,6 +29,7 @@ import { PublicModule } from './public/public.module';
|
|||||||
GoodsModule,
|
GoodsModule,
|
||||||
SyncModule,
|
SyncModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
|
UploadModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import { json } from 'express';
|
import { json } from 'express';
|
||||||
|
import { join } from 'path';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, { bodyParser: false });
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
|
||||||
|
|
||||||
// Replace Express's JSON parser with one that stringifies BigInt.
|
// Replace Express's JSON parser with one that stringifies BigInt.
|
||||||
// Express's default `json()` throws "Do not know how to serialize a BigInt".
|
// Express's default `json()` throws "Do not know how to serialize a BigInt".
|
||||||
@@ -28,10 +30,18 @@ async function bootstrap() {
|
|||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: ['http://localhost:5173', 'http://localhost:3000'],
|
origin: true,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve uploaded files
|
||||||
|
app.useStaticAssets(join(process.cwd(), 'uploads'), {
|
||||||
|
prefix: '/uploads/',
|
||||||
|
});
|
||||||
|
app.useStaticAssets(join(process.cwd(), 'public'), {
|
||||||
|
prefix: '/assets/',
|
||||||
|
});
|
||||||
|
|
||||||
// Global pipes
|
// Global pipes
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
@@ -57,9 +67,9 @@ async function bootstrap() {
|
|||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
const port = process.env.PORT ?? 3001;
|
const port = process.env.PORT ?? 3001;
|
||||||
await app.listen(port);
|
await app.listen(port, '0.0.0.0');
|
||||||
console.log(`🚀 Application is running on: http://localhost:${port}`);
|
console.log(`🚀 Application is running on: http://0.0.0.0:${port}`);
|
||||||
console.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
|
console.log(`📚 Swagger documentation: http://0.0.0.0:${port}/api/docs`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make JSON.stringify aware of BigInt so outgoing responses containing
|
// Make JSON.stringify aware of BigInt so outgoing responses containing
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface OriginGoodsTreeNode {
|
|||||||
goodImage: string | null;
|
goodImage: string | null;
|
||||||
goodPrice: string | null;
|
goodPrice: string | null;
|
||||||
sdsGoodId: string;
|
sdsGoodId: string;
|
||||||
|
delisted: boolean;
|
||||||
configuredCount: number;
|
configuredCount: number;
|
||||||
configuredCountries: string[];
|
configuredCountries: string[];
|
||||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[];
|
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[];
|
||||||
@@ -110,7 +111,7 @@ export class OriginGoodsService {
|
|||||||
parentCategoryId: true,
|
parentCategoryId: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.originGood.findMany({ orderBy: { goodName: 'asc' } }),
|
this.prisma.originGood.findMany({ where: { delisted: false }, orderBy: { goodName: 'asc' } }),
|
||||||
this.prisma.good.groupBy({
|
this.prisma.good.groupBy({
|
||||||
by: ['originGoodId'],
|
by: ['originGoodId'],
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
@@ -194,7 +195,9 @@ export class OriginGoodsService {
|
|||||||
const childrenCats = allCategories.filter(
|
const childrenCats = allCategories.filter(
|
||||||
(c) => c.parentCategoryId !== null && c.parentCategoryId === cat.id,
|
(c) => c.parentCategoryId !== null && c.parentCategoryId === cat.id,
|
||||||
);
|
);
|
||||||
const childNodes = childrenCats.map(buildNode);
|
const childNodes = childrenCats
|
||||||
|
.map(buildNode)
|
||||||
|
.filter((n) => n.totalCount > 0);
|
||||||
|
|
||||||
const ogsForThisCat = allOriginGoods.filter(
|
const ogsForThisCat = allOriginGoods.filter(
|
||||||
(og) => ogToCategory.get(og.id.toString()) === cat.id.toString(),
|
(og) => ogToCategory.get(og.id.toString()) === cat.id.toString(),
|
||||||
@@ -205,6 +208,7 @@ export class OriginGoodsService {
|
|||||||
goodImage: og.goodImage,
|
goodImage: og.goodImage,
|
||||||
goodPrice: og.goodPrice?.toString() ?? null,
|
goodPrice: og.goodPrice?.toString() ?? null,
|
||||||
sdsGoodId: og.sdsGoodId,
|
sdsGoodId: og.sdsGoodId,
|
||||||
|
delisted: og.delisted,
|
||||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||||
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
||||||
@@ -229,7 +233,7 @@ export class OriginGoodsService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const roots = allCategories.filter((c) => c.parentCategoryId === null);
|
const roots = allCategories.filter((c) => c.parentCategoryId === null);
|
||||||
const tree = roots.map(buildNode);
|
const tree = roots.map(buildNode).filter((n) => n.totalCount > 0);
|
||||||
|
|
||||||
const unmapped = allOriginGoods.filter(
|
const unmapped = allOriginGoods.filter(
|
||||||
(og) => !ogToCategory.has(og.id.toString()),
|
(og) => !ogToCategory.has(og.id.toString()),
|
||||||
@@ -250,6 +254,7 @@ export class OriginGoodsService {
|
|||||||
goodImage: og.goodImage,
|
goodImage: og.goodImage,
|
||||||
goodPrice: og.goodPrice?.toString() ?? null,
|
goodPrice: og.goodPrice?.toString() ?? null,
|
||||||
sdsGoodId: og.sdsGoodId,
|
sdsGoodId: og.sdsGoodId,
|
||||||
|
delisted: og.delisted,
|
||||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||||
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
||||||
|
|||||||
@@ -193,6 +193,19 @@ describe('PublicService', () => {
|
|||||||
expect(priorities).toEqual(sorted);
|
expect(priorities).toEqual(sorted);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the SDS product id as the public product id', async () => {
|
||||||
|
const result = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
countryId: Number(countryId),
|
||||||
|
keyword: `Pub High ${stamp}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0].id).toBe(`pub-sds-${stamp}`);
|
||||||
|
expect(result.items[0].id).not.toBe(goodIds[0].toString());
|
||||||
|
});
|
||||||
|
|
||||||
it('getGood returns detail and 404 for unknown id', async () => {
|
it('getGood returns detail and 404 for unknown id', async () => {
|
||||||
const first = await service.getGoods({
|
const first = await service.getGoods({
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -201,7 +214,7 @@ describe('PublicService', () => {
|
|||||||
keyword: `Pub `,
|
keyword: `Pub `,
|
||||||
});
|
});
|
||||||
expect(first.items.length).toBe(1);
|
expect(first.items.length).toBe(1);
|
||||||
const detail = await service.getGood(BigInt(first.items[0].id));
|
const detail = await service.getGood(goodIds[0]);
|
||||||
expect(detail.id).toBe(first.items[0].id);
|
expect(detail.id).toBe(first.items[0].id);
|
||||||
|
|
||||||
await expect(service.getGood(BigInt(99999999))).rejects.toBeInstanceOf(
|
await expect(service.getGood(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ export class PublicService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
||||||
const where: Prisma.GoodWhereInput = {};
|
const where: Prisma.GoodWhereInput = {
|
||||||
|
originGood: { delisted: false },
|
||||||
|
};
|
||||||
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId);
|
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId);
|
||||||
if (query.tagIds) {
|
if (query.tagIds) {
|
||||||
const ids = query.tagIds
|
const ids = query.tagIds
|
||||||
@@ -154,9 +156,10 @@ export class PublicService {
|
|||||||
tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } | null;
|
tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } | null;
|
||||||
position: { id: bigint; indexVal: number } | null;
|
position: { id: bigint; indexVal: number } | null;
|
||||||
originGood: {
|
originGood: {
|
||||||
|
sdsGoodId: string;
|
||||||
goodImage: string | null;
|
goodImage: string | null;
|
||||||
goodPrice: { toString(): string } | null;
|
goodPrice: { toString(): string } | null;
|
||||||
} | null;
|
};
|
||||||
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } }[];
|
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } }[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): PublicGoodDto {
|
}): PublicGoodDto {
|
||||||
@@ -169,7 +172,7 @@ export class PublicService {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
id: good.id.toString(),
|
id: good.originGood.sdsGoodId,
|
||||||
goodName: good.goodName,
|
goodName: good.goodName,
|
||||||
goodPriority: good.goodPriority,
|
goodPriority: good.goodPriority,
|
||||||
country: {
|
country: {
|
||||||
|
|||||||
@@ -65,10 +65,39 @@ export class SdsClientService {
|
|||||||
'https://mapi.sdspod.com';
|
'https://mapi.sdspod.com';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async request<T>(
|
||||||
* Fetches the SDS category tree of type 3 (products category).
|
method: 'post' | 'get',
|
||||||
* Body matches the legacy inkpod client.
|
url: string,
|
||||||
*/
|
body?: unknown,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const config = {
|
||||||
|
headers: POD_HEADERS,
|
||||||
|
timeout: 30000,
|
||||||
|
...(params ? { params } : {}),
|
||||||
|
};
|
||||||
|
const obs =
|
||||||
|
method === 'post'
|
||||||
|
? this.http.post<T>(url, body, config)
|
||||||
|
: this.http.get<T>(url, config);
|
||||||
|
const { data } = await firstValueFrom(obs);
|
||||||
|
return data as T;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
this.logger.warn(`SDS request attempt ${attempt}/${MAX_RETRIES} failed: ${msg}`);
|
||||||
|
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
async fetchCategoryTree(): Promise<SdsCategoryTreeNode[]> {
|
async fetchCategoryTree(): Promise<SdsCategoryTreeNode[]> {
|
||||||
const url = `${this.baseUrl}/category/tree/3`;
|
const url = `${this.baseUrl}/category/tree/3`;
|
||||||
const body = {
|
const body = {
|
||||||
@@ -76,27 +105,23 @@ export class SdsClientService {
|
|||||||
withPrivate: true,
|
withPrivate: true,
|
||||||
onlyHaveProduct: true,
|
onlyHaveProduct: true,
|
||||||
};
|
};
|
||||||
const { data } = await firstValueFrom(
|
const data = await this.request<unknown>('post', url, body);
|
||||||
this.http.post<SdsCategoryTreeNode[]>(url, body, { headers: POD_HEADERS }),
|
if (!Array.isArray(data)) {
|
||||||
);
|
throw new Error(`SDS category tree returned ${typeof data}, expected array`);
|
||||||
return Array.isArray(data) ? data : [];
|
}
|
||||||
|
return data as SdsCategoryTreeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches one page of products for a given SDS category.
|
|
||||||
*/
|
|
||||||
async fetchProductsPage(
|
async fetchProductsPage(
|
||||||
categoryId: string | number,
|
categoryId: string | number,
|
||||||
page = 1,
|
page = 1,
|
||||||
size = 50,
|
size = 50,
|
||||||
): Promise<SdsProductsPage> {
|
): Promise<SdsProductsPage> {
|
||||||
const url = `${this.baseUrl}/products/page`;
|
const url = `${this.baseUrl}/products/page`;
|
||||||
const { data } = await firstValueFrom(
|
const data = await this.request<unknown>('get', url, undefined, { categoryId, page, size });
|
||||||
this.http.get<SdsProductsPage>(url, {
|
if (!data || typeof data !== 'object') {
|
||||||
headers: POD_HEADERS,
|
throw new Error(`SDS products page returned ${typeof data}, expected object`);
|
||||||
params: { categoryId, page, size },
|
}
|
||||||
}),
|
return data as SdsProductsPage;
|
||||||
);
|
|
||||||
return data ?? {};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,15 +25,15 @@ export class SyncController {
|
|||||||
constructor(private readonly service: SyncService) {}
|
constructor(private readonly service: SyncService) {}
|
||||||
|
|
||||||
@Post('categories')
|
@Post('categories')
|
||||||
@ApiOperation({ summary: 'Manually trigger category sync' })
|
@ApiOperation({ summary: 'Manually trigger category sync (async)' })
|
||||||
syncCategories() {
|
async syncCategories() {
|
||||||
return this.service.syncCategories();
|
return this.service.startCategorySync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('products')
|
@Post('products')
|
||||||
@ApiOperation({ summary: 'Manually trigger product sync' })
|
@ApiOperation({ summary: 'Manually trigger product sync (async)' })
|
||||||
syncProducts() {
|
async syncProducts() {
|
||||||
return this.service.syncProducts();
|
return this.service.startProductSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('status')
|
@Get('status')
|
||||||
|
|||||||
@@ -42,6 +42,32 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget wrappers for manual triggers via HTTP. */
|
||||||
|
async startCategorySync(): Promise<{ message: string }> {
|
||||||
|
if (this.running.categories) {
|
||||||
|
return { message: 'Category sync already in progress' };
|
||||||
|
}
|
||||||
|
void this.syncCategories().catch((err) =>
|
||||||
|
this.logger.error('Category sync failed', err as Error),
|
||||||
|
);
|
||||||
|
return { message: 'Category sync started' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async startProductSync(): Promise<{ message: string }> {
|
||||||
|
if (this.running.products) {
|
||||||
|
return { message: 'Product sync already in progress' };
|
||||||
|
}
|
||||||
|
void this.syncProducts().catch((err) =>
|
||||||
|
this.logger.error('Product sync failed', err as Error),
|
||||||
|
);
|
||||||
|
return { message: 'Product sync started' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a sync type is currently running. */
|
||||||
|
isRunning(type: 'categories' | 'products'): boolean {
|
||||||
|
return this.running[type];
|
||||||
|
}
|
||||||
|
|
||||||
async syncCategories(): Promise<CategorySyncResult> {
|
async syncCategories(): Promise<CategorySyncResult> {
|
||||||
if (this.running.categories) {
|
if (this.running.categories) {
|
||||||
throw new Error('Category sync already in progress');
|
throw new Error('Category sync already in progress');
|
||||||
@@ -54,57 +80,111 @@ export class SyncService {
|
|||||||
const tree = await this.sds.fetchCategoryTree();
|
const tree = await this.sds.fetchCategoryTree();
|
||||||
const flat = this.flattenCategoryTree(tree);
|
const flat = this.flattenCategoryTree(tree);
|
||||||
this.logger.log(`Fetched ${flat.length} SDS categories`);
|
this.logger.log(`Fetched ${flat.length} SDS categories`);
|
||||||
|
const seenSdsIds = new Set(flat.map((n) => n.sdsId));
|
||||||
|
|
||||||
let inserted = 0;
|
// Single transaction: upsert + wire parents + delete stale.
|
||||||
let updated = 0;
|
// SDS tree is the source of truth — anything not in the response gets deleted.
|
||||||
for (const node of flat) {
|
const { inserted, updated, deletedStale } = await this.prisma.$transaction(async (tx) => {
|
||||||
const existing = await this.prisma.category.findUnique({
|
let ins = 0;
|
||||||
where: { sdsCategoryId: node.sdsId },
|
let upd = 0;
|
||||||
});
|
|
||||||
if (!existing) {
|
|
||||||
await this.prisma.category.create({
|
|
||||||
data: {
|
|
||||||
sdsCategoryId: node.sdsId,
|
|
||||||
categoryName: node.name,
|
|
||||||
categoryIcon: node.icon ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
inserted++;
|
|
||||||
} else {
|
|
||||||
await this.prisma.category.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: {
|
|
||||||
categoryName: node.name,
|
|
||||||
categoryIcon: node.icon ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second pass: wire up parents by sdsCategoryId.
|
// 1. Upsert all SDS categories
|
||||||
for (const node of flat) {
|
for (const node of flat) {
|
||||||
if (!node.parentSdsId) continue;
|
const existing = await tx.category.findUnique({
|
||||||
const child = await this.prisma.category.findUnique({
|
where: { sdsCategoryId: node.sdsId },
|
||||||
where: { sdsCategoryId: node.sdsId },
|
|
||||||
});
|
|
||||||
const parent = await this.prisma.category.findUnique({
|
|
||||||
where: { sdsCategoryId: node.parentSdsId },
|
|
||||||
});
|
|
||||||
if (child && parent && child.parentCategoryId !== parent.id) {
|
|
||||||
await this.prisma.category.update({
|
|
||||||
where: { id: child.id },
|
|
||||||
data: { parentCategoryId: parent.id },
|
|
||||||
});
|
});
|
||||||
|
if (!existing) {
|
||||||
|
await tx.category.create({
|
||||||
|
data: {
|
||||||
|
sdsCategoryId: node.sdsId,
|
||||||
|
categoryName: node.name,
|
||||||
|
categoryIcon: node.icon ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ins++;
|
||||||
|
} else {
|
||||||
|
await tx.category.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
categoryName: node.name,
|
||||||
|
categoryIcon: node.icon ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
upd++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// 2. Wire parent-child relationships
|
||||||
|
for (const node of flat) {
|
||||||
|
if (!node.parentSdsId) continue;
|
||||||
|
const child = await tx.category.findUnique({
|
||||||
|
where: { sdsCategoryId: node.sdsId },
|
||||||
|
});
|
||||||
|
const parent = await tx.category.findUnique({
|
||||||
|
where: { sdsCategoryId: node.parentSdsId },
|
||||||
|
});
|
||||||
|
if (child && parent && child.parentCategoryId !== parent.id) {
|
||||||
|
await tx.category.update({
|
||||||
|
where: { id: child.id },
|
||||||
|
data: { parentCategoryId: parent.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete stale categories (in DB but not in SDS response)
|
||||||
|
// Detach parent links first, then delete leaf-first to respect FK constraints.
|
||||||
|
const staleCats = await tx.category.findMany({
|
||||||
|
where: { sdsCategoryId: { notIn: [...seenSdsIds] } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const staleIds = staleCats.map((c) => c.id);
|
||||||
|
|
||||||
|
// Protect categories that have configured goods — onDelete: Restrict
|
||||||
|
const goodsInStale = await tx.good.groupBy({
|
||||||
|
by: ['categoryId'],
|
||||||
|
where: { categoryId: { in: staleIds } },
|
||||||
|
});
|
||||||
|
const protectedIds = new Set(goodsInStale.map((g) => g.categoryId));
|
||||||
|
const deletableIds = staleIds.filter((id) => !protectedIds.has(id));
|
||||||
|
|
||||||
|
// Detach all deletable categories from their parents
|
||||||
|
if (deletableIds.length > 0) {
|
||||||
|
await tx.category.updateMany({
|
||||||
|
where: { id: { in: deletableIds } },
|
||||||
|
data: { parentCategoryId: null },
|
||||||
|
});
|
||||||
|
// Also detach any non-deletable children pointing to deletable parents
|
||||||
|
await tx.category.updateMany({
|
||||||
|
where: { parentCategoryId: { in: deletableIds } },
|
||||||
|
data: { parentCategoryId: null },
|
||||||
|
});
|
||||||
|
// Delete leaf-first (repeatedly remove nodes with no children)
|
||||||
|
let remaining = [...deletableIds];
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
const withChildren = await tx.category.findMany({
|
||||||
|
where: { parentCategoryId: { in: remaining } },
|
||||||
|
select: { parentCategoryId: true },
|
||||||
|
distinct: ['parentCategoryId'],
|
||||||
|
});
|
||||||
|
const hasChildSet = new Set(
|
||||||
|
withChildren.filter((c) => c.parentCategoryId).map((c) => c.parentCategoryId!.toString()),
|
||||||
|
);
|
||||||
|
const leaves = remaining.filter((id) => !hasChildSet.has(id.toString()));
|
||||||
|
if (leaves.length === 0) break; // safety: circular dependency
|
||||||
|
await tx.category.deleteMany({ where: { id: { in: leaves } } });
|
||||||
|
remaining = remaining.filter((id) => !leaves.some((l) => l === id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inserted: ins, updated: upd, deletedStale: deletableIds.length };
|
||||||
|
});
|
||||||
|
|
||||||
await this.prisma.syncLog.update({
|
await this.prisma.syncLog.update({
|
||||||
where: { id: log.id },
|
where: { id: log.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
message: `inserted=${inserted} updated=${updated} total=${flat.length}`,
|
message: `inserted=${inserted} updated=${updated} total=${flat.length} staleDeleted=${deletedStale}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { inserted, updated, total: flat.length };
|
return { inserted, updated, total: flat.length };
|
||||||
@@ -147,6 +227,7 @@ export class SyncService {
|
|||||||
let inserted = 0;
|
let inserted = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
const seenSdsGoodIds = new Set<string>();
|
||||||
|
|
||||||
for (const leaf of leafRows) {
|
for (const leaf of leafRows) {
|
||||||
const sdsCategoryId = leaf.sdsCategoryId!;
|
const sdsCategoryId = leaf.sdsCategoryId!;
|
||||||
@@ -157,6 +238,7 @@ export class SyncService {
|
|||||||
const products = resp.items ?? resp.content ?? [];
|
const products = resp.items ?? resp.content ?? [];
|
||||||
if (products.length === 0) break;
|
if (products.length === 0) break;
|
||||||
for (const product of products) {
|
for (const product of products) {
|
||||||
|
seenSdsGoodIds.add(String(product.id));
|
||||||
const upserted = await this.upsertOriginGood(product, sdsCategoryId);
|
const upserted = await this.upsertOriginGood(product, sdsCategoryId);
|
||||||
if (upserted === 'inserted') inserted++;
|
if (upserted === 'inserted') inserted++;
|
||||||
else updated++;
|
else updated++;
|
||||||
@@ -172,12 +254,31 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect delisted products: mark origin goods not seen in upstream as delisted,
|
||||||
|
// and re-activate any previously delisted goods that reappeared.
|
||||||
|
let delistedCount = 0;
|
||||||
|
let reactivatedCount = 0;
|
||||||
|
if (seenSdsGoodIds.size > 0) {
|
||||||
|
const delistedResult = await this.prisma.originGood.updateMany({
|
||||||
|
where: { sdsGoodId: { notIn: [...seenSdsGoodIds] }, delisted: false },
|
||||||
|
data: { delisted: true },
|
||||||
|
});
|
||||||
|
const reactivatedResult = await this.prisma.originGood.updateMany({
|
||||||
|
where: { sdsGoodId: { in: [...seenSdsGoodIds] }, delisted: true },
|
||||||
|
data: { delisted: false },
|
||||||
|
});
|
||||||
|
delistedCount = delistedResult.count;
|
||||||
|
reactivatedCount = reactivatedResult.count;
|
||||||
|
} else {
|
||||||
|
this.logger.warn('No products seen from SDS — skipping delist detection');
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.syncLog.update({
|
await this.prisma.syncLog.update({
|
||||||
where: { id: log.id },
|
where: { id: log.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
message: `inserted=${inserted} updated=${updated} total=${total} leafCategories=${leafRows.length}`,
|
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { inserted, updated, total, leafCategories: leafRows.length };
|
return { inserted, updated, total, leafCategories: leafRows.length };
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { diskStorage } from 'multer';
|
||||||
|
import { extname, join } from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
const UPLOAD_DIR = join(process.cwd(), 'uploads');
|
||||||
|
|
||||||
|
@Controller('upload')
|
||||||
|
export class UploadController {
|
||||||
|
@Post('image')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: UPLOAD_DIR,
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const ext = extname(file.originalname) || '.png';
|
||||||
|
cb(null, `${randomUUID()}${ext}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (!file.mimetype.startsWith('image/')) {
|
||||||
|
return cb(new BadRequestException('仅支持图片文件'), false);
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
uploadImage(@UploadedFile() file: Express.Multer.File) {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('请选择要上传的文件');
|
||||||
|
}
|
||||||
|
return { url: `/uploads/${file.filename}`, filename: file.filename };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UploadController } from './upload.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [UploadController],
|
||||||
|
})
|
||||||
|
export class UploadModule {}
|
||||||
@@ -1,5 +1,42 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'PingFang SC';
|
||||||
|
src: url('/fonts/pingfang-sc-thin.ttf') format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'PingFang SC';
|
||||||
|
src: url('/fonts/pingfang-sc-regular.ttf') format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'PingFang SC';
|
||||||
|
src: url('/fonts/pingfang-sc-medium.ttf') format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'PingFang SC';
|
||||||
|
src: url('/fonts/pingfang-sc-semibold.ttf') format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-inkreach-homepage-1: #f3f4f5;
|
--color-inkreach-homepage-1: #f3f4f5;
|
||||||
--color-inkreach-homepage-2: #f8f9fb;
|
--color-inkreach-homepage-2: #f8f9fb;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="max-w-7xl mx-auto">
|
<div class="max-w-7xl mx-auto">
|
||||||
<div class="space-y-6 md:grid md:grid-cols-2 lg:grid-cols-4 lg:gap-8">
|
<div class="space-y-6 md:grid md:grid-cols-2 lg:grid-cols-4 lg:gap-8">
|
||||||
<div class="md:col-span-2 lg:col-span-1">
|
<div class="md:col-span-2 lg:col-span-1">
|
||||||
<div class="text-2xl font-bold mb-4">Inkreach</div>
|
<img src="/logo-unified.svg" alt="Inkreach" class="mb-4 h-[56px] w-[97px] object-contain brightness-0 invert" />
|
||||||
<p class="text-gray-400 text-sm">全球POD定制平台, 服务中国卖家出海</p>
|
<p class="text-gray-400 text-sm">全球POD定制平台, 服务中国卖家出海</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-4 md:col-span-2 lg:col-span-3">
|
<div class="grid grid-cols-3 gap-4 md:col-span-2 lg:col-span-3">
|
||||||
@@ -45,10 +45,3 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@media (min-width: 901px) {
|
|
||||||
.product-footer {
|
|
||||||
margin-left: 240px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { RecommendCategory, SolutionColumn, NavBanner } from '~/composables/useNavData';
|
import type { RecommendCategory, SolutionColumn, NavBanner } from '~/composables/useNavData';
|
||||||
|
|
||||||
const PORTAL_URL = 'https://inkpod.vip/portal/search';
|
const PORTAL_URL = 'https://inkpod.vip/portal/search';
|
||||||
const LOGIN_URL = 'https://inkpod.vip/user/login';
|
const SHOW_EXTENDED_NAV = false;
|
||||||
|
|
||||||
const mobileMenuOpen = ref(false);
|
const mobileMenuOpen = ref(false);
|
||||||
const recommendMenuOpen = ref(false);
|
const recommendMenuOpen = ref(false);
|
||||||
@@ -60,6 +60,10 @@ function closeMobileMenu(): void {
|
|||||||
mobileSolutionOpen.value = false;
|
mobileSolutionOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goToLogin(): void {
|
||||||
|
window.location.href = 'https://inkpod.vip/user/login';
|
||||||
|
}
|
||||||
|
|
||||||
function toggleMobileRecommend(): void {
|
function toggleMobileRecommend(): void {
|
||||||
mobileRecommendOpen.value = !mobileRecommendOpen.value;
|
mobileRecommendOpen.value = !mobileRecommendOpen.value;
|
||||||
mobileSolutionOpen.value = false;
|
mobileSolutionOpen.value = false;
|
||||||
@@ -80,11 +84,11 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="pl-6 md:pl-10 lg:pl-16 pr-6 md:pr-10 lg:pr-20 flex items-center h-20 fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-100">
|
<header class="pl-6 md:pl-10 lg:pl-16 pr-6 md:pr-10 lg:pr-20 flex items-center h-20 fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-100">
|
||||||
<div>
|
<NuxtLink to="/" aria-label="返回首页">
|
||||||
<img src="/logo-橙@2x.png" alt="Inkreach" class="h-12 lg:h-14" />
|
<img src="/logo-unified.svg" alt="Inkreach" class="h-12 w-[83px] lg:h-[56px] lg:w-[97px] object-contain" />
|
||||||
</div>
|
</NuxtLink>
|
||||||
|
|
||||||
<nav class="hidden lg:flex gap-6 text-xl flex-1 ml-8 items-center h-20">
|
<nav class="desktop-nav hidden lg:flex gap-6 flex-1 ml-8 items-center h-20">
|
||||||
<NuxtLink to="/product-center"
|
<NuxtLink to="/product-center"
|
||||||
class="transition-colors"
|
class="transition-colors"
|
||||||
:class="isProductCenter
|
:class="isProductCenter
|
||||||
@@ -92,13 +96,11 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
|||||||
: 'text-gray-400 hover:text-primary hover:underline underline-offset-14 decoration-primary decoration-2'"
|
: 'text-gray-400 hover:text-primary hover:underline underline-offset-14 decoration-primary decoration-2'"
|
||||||
>产品中心</NuxtLink>
|
>产品中心</NuxtLink>
|
||||||
<a :href="PORTAL_URL" target="_blank"
|
<a :href="PORTAL_URL" target="_blank"
|
||||||
class="transition-colors"
|
class="text-gray-400 hover:text-primary hover:underline underline-offset-14 decoration-primary decoration-2 transition-colors"
|
||||||
:class="isProductCenter
|
|
||||||
? 'text-gray-400'
|
|
||||||
: 'hover:text-primary hover:underline underline-offset-14 decoration-primary decoration-2'"
|
|
||||||
>在线设计</a>
|
>在线设计</a>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
v-if="SHOW_EXTENDED_NAV"
|
||||||
class="relative h-full flex items-center"
|
class="relative h-full flex items-center"
|
||||||
@mouseenter="openRecommendMenu"
|
@mouseenter="openRecommendMenu"
|
||||||
@mouseleave="closeRecommendMenu"
|
@mouseleave="closeRecommendMenu"
|
||||||
@@ -107,7 +109,7 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
|||||||
class="transition-colors flex items-center gap-1"
|
class="transition-colors flex items-center gap-1"
|
||||||
:class="[
|
:class="[
|
||||||
recommendMenuOpen ? 'text-primary' : '',
|
recommendMenuOpen ? 'text-primary' : '',
|
||||||
isProductCenter && !recommendMenuOpen ? 'text-gray-400' : '',
|
!recommendMenuOpen ? 'text-gray-400' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">选品推荐</span>
|
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">选品推荐</span>
|
||||||
@@ -122,6 +124,7 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
v-if="SHOW_EXTENDED_NAV"
|
||||||
class="relative h-full flex items-center"
|
class="relative h-full flex items-center"
|
||||||
@mouseenter="openSolutionMenu"
|
@mouseenter="openSolutionMenu"
|
||||||
@mouseleave="closeSolutionMenu"
|
@mouseleave="closeSolutionMenu"
|
||||||
@@ -130,7 +133,7 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
|||||||
class="transition-colors flex items-center gap-1"
|
class="transition-colors flex items-center gap-1"
|
||||||
:class="[
|
:class="[
|
||||||
solutionMenuOpen ? 'text-primary' : '',
|
solutionMenuOpen ? 'text-primary' : '',
|
||||||
isProductCenter && !solutionMenuOpen ? 'text-gray-400' : '',
|
!solutionMenuOpen ? 'text-gray-400' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">解决方案</span>
|
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">解决方案</span>
|
||||||
@@ -144,13 +147,13 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="hidden lg:flex gap-6 text-xl items-center">
|
<div class="desktop-nav hidden lg:flex gap-6 items-center">
|
||||||
<a :href="LOGIN_URL" target="_blank"
|
<button type="button" @click="goToLogin"
|
||||||
class="hover:text-primary hover:underline underline-offset-12 decoration-primary decoration-2 transition-colors"
|
class="hover:text-primary hover:underline underline-offset-12 decoration-primary decoration-2 transition-colors"
|
||||||
>登录</a>
|
>登录</button>
|
||||||
<a :href="LOGIN_URL" target="_blank"
|
<button type="button" @click="goToLogin"
|
||||||
class="text-white rounded-lg px-6 py-1.5 self-center hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 pulse-animation bg-primary"
|
class="text-white rounded-lg px-6 py-1.5 self-center hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 pulse-animation bg-primary"
|
||||||
>立即开始</a>
|
>立即开始</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -178,7 +181,9 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
|||||||
class="fixed top-0 right-0 h-full w-80 bg-white shadow-2xl z-50 lg:hidden flex flex-col overflow-y-auto"
|
class="fixed top-0 right-0 h-full w-80 bg-white shadow-2xl z-50 lg:hidden flex flex-col overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-center p-6 pb-4">
|
<div class="flex justify-between items-center p-6 pb-4">
|
||||||
<img src="/logo-橙.png" alt="Inkreach" class="h-8" />
|
<NuxtLink to="/" aria-label="返回首页" @click="closeMobileMenu">
|
||||||
|
<img src="/logo-unified.svg" alt="Inkreach" class="h-[56px] w-[97px] object-contain" />
|
||||||
|
</NuxtLink>
|
||||||
<button @click="closeMobileMenu" class="p-2" aria-label="Close menu">
|
<button @click="closeMobileMenu" class="p-2" aria-label="Close menu">
|
||||||
<i class="fa-solid fa-xmark text-2xl"></i>
|
<i class="fa-solid fa-xmark text-2xl"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -194,7 +199,8 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
|||||||
class="py-3 hover:text-primary transition-colors"
|
class="py-3 hover:text-primary transition-colors"
|
||||||
>在线设计</a>
|
>在线设计</a>
|
||||||
|
|
||||||
<div>
|
<!-- Temporarily hidden until recommendation content is ready. -->
|
||||||
|
<div v-if="SHOW_EXTENDED_NAV">
|
||||||
<button
|
<button
|
||||||
class="w-full py-3 flex items-center justify-between hover:text-primary transition-colors"
|
class="w-full py-3 flex items-center justify-between hover:text-primary transition-colors"
|
||||||
@click="toggleMobileRecommend"
|
@click="toggleMobileRecommend"
|
||||||
@@ -244,7 +250,8 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
|||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- Temporarily hidden until solution content is ready. -->
|
||||||
|
<div v-if="SHOW_EXTENDED_NAV">
|
||||||
<button
|
<button
|
||||||
class="w-full py-3 flex items-center justify-between hover:text-primary transition-colors"
|
class="w-full py-3 flex items-center justify-between hover:text-primary transition-colors"
|
||||||
@click="toggleMobileSolution"
|
@click="toggleMobileSolution"
|
||||||
@@ -291,12 +298,12 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 px-6 mt-auto pb-6 pt-4">
|
<div class="flex flex-col gap-4 px-6 mt-auto pb-6 pt-4">
|
||||||
<a :href="LOGIN_URL" target="_blank"
|
<button type="button" @click="goToLogin"
|
||||||
class="py-2.5 text-lg hover:text-primary transition-colors text-center"
|
class="py-2.5 text-lg hover:text-primary transition-colors text-center"
|
||||||
>登录</a>
|
>登录</button>
|
||||||
<a :href="LOGIN_URL" target="_blank"
|
<button type="button" @click="goToLogin"
|
||||||
class="text-white rounded-lg px-6 py-3 text-center hover:shadow-lg transition-all duration-300 pulse-animation bg-primary"
|
class="text-white rounded-lg px-6 py-3 text-center hover:shadow-lg transition-all duration-300 pulse-animation bg-primary"
|
||||||
>立即开始</a>
|
>立即开始</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -304,6 +311,8 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.desktop-nav { font-size: 18px; }
|
||||||
|
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="py-10 lg:py-16 px-4 lg:px-6 bg-cover bg-center bg-no-repeat" style="background-image: url('/公司简介背景.png')">
|
<section class="company-section py-10 lg:py-16 px-4 lg:px-6 bg-cover bg-center bg-no-repeat" style="background-image: url('/公司简介背景.png')">
|
||||||
<div class="max-w-7xl mx-auto">
|
<div class="max-w-7xl mx-auto">
|
||||||
<h2 class="text-[clamp(1.5rem,3vw,2.5rem)] font-bold text-gray-900 text-center mb-8 lg:mb-12">公司简介</h2>
|
<h2 class="text-[clamp(1.5rem,3vw,2.5rem)] font-bold text-gray-900 text-center mb-8 lg:mb-12">公司简介</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 mb-8 lg:mb-12">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 mb-8 lg:mb-12">
|
||||||
@@ -48,3 +48,7 @@ const capabilities = [
|
|||||||
{ title: '一站式柔性交付', desc: '覆盖生产至物流, 助力品牌从概念到商业落地', icon: 'fa-solid fa-handshake' },
|
{ title: '一站式柔性交付', desc: '覆盖生产至物流, 助力品牌从概念到商业落地', icon: 'fa-solid fa-handshake' },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@media (min-width: 1024px) { .company-section { height: 600px; } }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
function goToLogin(): void {
|
||||||
|
window.location.href = 'https://inkpod.vip/user/login';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="py-10 lg:py-16 px-4 lg:px-6 bg-primary">
|
<section class="cta-section py-10 lg:py-16 px-4 lg:px-6 bg-primary">
|
||||||
<div class="max-w-4xl mx-auto text-center">
|
<div class="max-w-4xl mx-auto text-center">
|
||||||
<h2 class="text-[clamp(1.5rem,4vw,3rem)] font-bold text-white mb-3 lg:mb-4">开始你的POD生意, 现在就行动</h2>
|
<h2 class="text-[clamp(1.5rem,4vw,3rem)] font-bold text-white mb-3 lg:mb-4">开始你的POD生意, 现在就行动</h2>
|
||||||
<p class="text-white text-base lg:text-xl mb-6 lg:mb-8">加入成千上万的成功卖家, 开始你的定制商品生意</p>
|
<p class="text-white text-base lg:text-xl mb-6 lg:mb-8">加入成千上万的成功卖家, 开始你的定制商品生意</p>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="goToLogin"
|
||||||
class="bg-white text-primary px-8 lg:px-10 py-3 lg:py-4 rounded-lg text-base lg:text-lg font-bold hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 pulse-animation"
|
class="bg-white text-primary px-8 lg:px-10 py-3 lg:py-4 rounded-lg text-base lg:text-lg font-bold hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 pulse-animation"
|
||||||
>
|
>
|
||||||
立即免费使用
|
立即免费使用
|
||||||
@@ -12,3 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@media (min-width: 1024px) { .cta-section { height: 338px; padding-top: 72px; } }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const customerCases = ref([
|
const customerCases = ref([
|
||||||
{ id: 1, platform: 'TEMU卖家', isPrimary: true, title: '复古水洗 经典重塑', description: '巡演灵感做旧款,粉丝自发应援首选', salesGrowth: '260%', monthlySales: '45W美元', image: 'https://picsum.photos/seed/case1/400/300' },
|
{ id: 1, platform: 'TEMU卖家', isPrimary: true, title: '复古水洗 经典重塑', description: '巡演灵感做旧款,粉丝自发应援首选', salesGrowth: '260%', monthlySales: '45W美元', image: '/case-temu-washed.png' },
|
||||||
{ id: 2, platform: 'TikTok卖家', isPrimary: false, title: '冠军荣耀 同款套装', description: '夺冠纪念球衣,球迷珍藏爆款', salesGrowth: '300%', monthlySales: '68W美元', image: 'https://picsum.photos/seed/case2/400/300' },
|
{ id: 2, platform: 'TikTok卖家', isPrimary: false, title: '冠军荣耀 同款套装', description: '夺冠纪念球衣,球迷珍藏爆款', salesGrowth: '300%', monthlySales: '68W美元', image: '/case-tiktok-kit.png' },
|
||||||
{ id: 3, platform: 'SHEIN卖家', isPrimary: false, title: '潮流印花 全棉舒适', description: '自制设计印花图案,无尾货压力轻松售', salesGrowth: '180%', monthlySales: '22W美元', image: 'https://picsum.photos/seed/case3/400/300' },
|
{ id: 3, platform: 'SHEIN卖家', isPrimary: false, title: '潮流印花 全棉舒适', description: '自制设计印花图案,无尾货压力轻松售', salesGrowth: '180%', monthlySales: '22W美元', image: '/case-shein-print.png' },
|
||||||
{ id: 4, platform: 'TEMU卖家', isPrimary: true, title: '复古水洗 经典重塑', description: '巡演灵感做旧款,粉丝自发应援首选', salesGrowth: '260%', monthlySales: '45W美元', image: 'https://picsum.photos/seed/case1/400/300' },
|
{ id: 4, platform: 'TEMU卖家', isPrimary: true, title: '天然彩棉 亲肤百搭', description: '达人联名系列,持续热卖无库存压力', salesGrowth: '210%', monthlySales: '38W美元', image: '/case-temu-cotton.png' },
|
||||||
{ id: 5, platform: 'TikTok卖家', isPrimary: false, title: '冠军荣耀 同款套装', description: '夺冠纪念球衣,球迷珍藏爆款', salesGrowth: '300%', monthlySales: '68W美元', image: 'https://picsum.photos/seed/case2/400/300' },
|
{ id: 5, platform: 'TikTok卖家', isPrimary: false, title: '日系简约 清凉无袖', description: '音乐节应援款,透气轻便人气高', salesGrowth: '240%', monthlySales: '32W美元', image: '/case-tiktok-sleeveless.png' },
|
||||||
{ id: 6, platform: 'SHEIN卖家', isPrimary: false, title: '潮流印花 全棉舒适', description: '自制设计印花图案,无尾货压力轻松售', salesGrowth: '180%', monthlySales: '22W美元', image: 'https://picsum.photos/seed/case3/400/300' },
|
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="py-10 lg:py-16 bg-gray-50 overflow-hidden">
|
<section class="cases-section py-10 lg:py-16 bg-gray-50 overflow-hidden">
|
||||||
<div class="max-w-7xl mx-auto px-4 lg:px-6">
|
<div class="max-w-7xl mx-auto px-4 lg:px-6">
|
||||||
<h2 class="text-[clamp(1.5rem,3vw,2.5rem)] font-bold text-gray-900 text-center mb-8 lg:mb-12">用户案例</h2>
|
<h2 class="text-[clamp(1.5rem,3vw,2.5rem)] font-bold text-gray-900 text-center mb-8 lg:mb-12">用户案例</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hidden lg:block overflow-hidden hover:[&>.marquee-track]:[animation-play-state:paused]">
|
<div class="hidden lg:block overflow-hidden" aria-label="用户案例自动轮播">
|
||||||
<div class="marquee-track flex gap-8 w-max animate-marquee">
|
<div class="case-track flex gap-8 w-max">
|
||||||
<div
|
<div
|
||||||
v-for="(testCase, idx) in [...customerCases, ...customerCases]"
|
v-for="(testCase, idx) in [...customerCases, ...customerCases]"
|
||||||
:key="'a-' + idx"
|
:key="'a-' + idx"
|
||||||
class="flex-shrink-0 w-[350px] h-[420px] bg-white rounded-xl overflow-hidden"
|
class="case-card relative flex-shrink-0 w-[350px] h-[420px] bg-white rounded-xl overflow-hidden"
|
||||||
>
|
>
|
||||||
<div class="relative h-48">
|
<div class="case-media relative h-48">
|
||||||
<span
|
<span
|
||||||
:class="['absolute top-4 left-4', testCase.isPrimary ? 'bg-primary' : 'bg-black', 'text-white px-3 py-1 rounded-full text-sm font-medium']"
|
:class="['absolute top-4 left-4', testCase.isPrimary ? 'bg-primary' : 'bg-black', 'text-white px-3 py-1 rounded-full text-sm font-medium']"
|
||||||
>
|
>
|
||||||
@@ -30,28 +29,30 @@ const customerCases = ref([
|
|||||||
</span>
|
</span>
|
||||||
<img :src="testCase.image" :alt="testCase.title" class="w-full h-full object-cover" />
|
<img :src="testCase.image" :alt="testCase.title" class="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="case-details p-6">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="case-metrics flex justify-between items-center mb-4">
|
||||||
<div>
|
<div class="growth-metric">
|
||||||
<p class="text-gray-500 text-sm">销量增长</p>
|
<p class="text-gray-500 text-sm">销量增长</p>
|
||||||
<p class="text-2xl lg:text-3xl font-bold text-primary">{{ testCase.salesGrowth }}</p>
|
<p class="text-2xl lg:text-3xl font-bold text-primary">{{ testCase.salesGrowth }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="sales-metric text-right">
|
||||||
<p class="text-gray-500 text-sm">月销售额</p>
|
<p class="text-gray-500 text-sm">月销售额</p>
|
||||||
<p class="text-2xl lg:text-3xl font-bold text-primary">{{ testCase.monthlySales }}</p>
|
<p class="text-2xl lg:text-3xl font-bold text-primary">{{ testCase.monthlySales }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg lg:text-xl font-bold text-gray-900 mb-2">{{ testCase.title }}</h3>
|
<div class="case-copy">
|
||||||
<p class="text-gray-600 text-sm">{{ testCase.description }}</p>
|
<h3 class="text-lg lg:text-xl font-bold text-gray-900 mb-2">{{ testCase.title }}</h3>
|
||||||
|
<p class="text-gray-600 text-sm">{{ testCase.description }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lg:hidden overflow-x-auto pb-4 px-4">
|
<div class="lg:hidden overflow-hidden pb-4 px-4" aria-label="用户案例自动轮播">
|
||||||
<div class="flex gap-4 w-max">
|
<div class="case-track-mobile flex gap-4 w-max">
|
||||||
<div
|
<div
|
||||||
v-for="(testCase, idx) in customerCases"
|
v-for="(testCase, idx) in [...customerCases, ...customerCases]"
|
||||||
:key="'m-' + idx"
|
:key="'m-' + idx"
|
||||||
class="flex-shrink-0 w-[280px] bg-white rounded-xl overflow-hidden"
|
class="flex-shrink-0 w-[280px] bg-white rounded-xl overflow-hidden"
|
||||||
>
|
>
|
||||||
@@ -82,3 +83,43 @@ const customerCases = ref([
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.case-track { animation: case-marquee 30s linear infinite; }
|
||||||
|
.case-track-mobile { animation: case-marquee-mobile 30s linear infinite; }
|
||||||
|
.case-track:hover,
|
||||||
|
.case-track:focus-within,
|
||||||
|
.case-track-mobile:hover,
|
||||||
|
.case-track-mobile:focus-within { animation-play-state: paused; }
|
||||||
|
|
||||||
|
@keyframes case-marquee {
|
||||||
|
to { transform: translateX(calc(-50% - 16px)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes case-marquee-mobile {
|
||||||
|
to { transform: translateX(calc(-50% - 8px)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.case-track,
|
||||||
|
.case-track-mobile { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.cases-section { height: 928px; padding-top: 54px; }
|
||||||
|
.case-track { padding-left: 122px; }
|
||||||
|
.case-card { width: 464px; height: 500px; }
|
||||||
|
.case-media { height: 360px; background: #f8f9fb; }
|
||||||
|
.case-media img { object-fit: contain; }
|
||||||
|
.case-details { position: absolute; inset: 0; padding: 0; pointer-events: none; }
|
||||||
|
.case-metrics { display: block; margin: 0; }
|
||||||
|
.case-metrics > div { position: absolute; right: 16px; width: 240px; text-align: right; }
|
||||||
|
.growth-metric { top: 148px; }
|
||||||
|
.sales-metric { top: 253px; }
|
||||||
|
.case-metrics p:first-child { margin-bottom: 2px; font-size: 16px; }
|
||||||
|
.case-metrics p:last-child { font-size: 48px; line-height: 1.1; white-space: nowrap; }
|
||||||
|
.case-copy { position: absolute; top: 392px; left: 0; width: 100%; padding: 0 24px; text-align: center; }
|
||||||
|
.case-copy h3 { margin-bottom: 12px; font-size: 24px; }
|
||||||
|
.case-copy p { font-size: 16px; white-space: nowrap; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
const activeFeature = ref(0);
|
const activeFeature = ref(0);
|
||||||
|
const FEATURE_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{ title: '设计工具', desc: '轻松设计,快速定制', image: '/设计工具.png' },
|
{ title: '设计工具', desc: '轻松设计,快速定制', image: '/设计工具.png' },
|
||||||
@@ -12,52 +15,34 @@ const selectFeature = (idx: number) => {
|
|||||||
activeFeature.value = idx;
|
activeFeature.value = idx;
|
||||||
};
|
};
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let featureTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const startAutoPlay = () => {
|
onMounted(() => {
|
||||||
stopAutoPlay();
|
featureTimer = setInterval(() => {
|
||||||
timer = setInterval(() => {
|
|
||||||
activeFeature.value = (activeFeature.value + 1) % features.length;
|
activeFeature.value = (activeFeature.value + 1) % features.length;
|
||||||
}, 3000);
|
}, FEATURE_INTERVAL_MS);
|
||||||
};
|
});
|
||||||
|
|
||||||
const stopAutoPlay = () => {
|
onBeforeUnmount(() => {
|
||||||
if (timer) {
|
if (featureTimer) clearInterval(featureTimer);
|
||||||
clearInterval(timer);
|
});
|
||||||
timer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pauseAutoPlay = () => {
|
|
||||||
stopAutoPlay();
|
|
||||||
};
|
|
||||||
|
|
||||||
const resumeAutoPlay = () => {
|
|
||||||
startAutoPlay();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(startAutoPlay);
|
|
||||||
onUnmounted(stopAutoPlay);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="w-full flex flex-col gap-8 lg:gap-10 items-center p-6 lg:p-10">
|
<section class="feature-section w-full flex flex-col gap-8 lg:gap-10 items-center p-6 lg:p-10">
|
||||||
<div class="text-2xl md:text-4xl lg:text-5xl text-center">用强大功能,开启盈利快车道</div>
|
<div class="text-[48px] font-extrabold md:text-4xl lg:text-5xl text-center">用强大功能,开启盈利快车道</div>
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row gap-5 w-full max-w-7xl items-stretch">
|
<div class="flex flex-col lg:flex-row gap-5 w-full max-w-7xl items-stretch">
|
||||||
<div class="flex flex-row lg:flex-col justify-start lg:justify-between gap-3 lg:gap-0 overflow-x-auto lg:overflow-visible"
|
<div class="feature-list flex flex-row gap-3 overflow-x-auto lg:overflow-visible">
|
||||||
@mouseenter="pauseAutoPlay"
|
|
||||||
@mouseleave="resumeAutoPlay"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-for="(feat, idx) in features"
|
v-for="(feat, idx) in features"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
@click="selectFeature(idx)"
|
@click="selectFeature(idx)"
|
||||||
@mouseenter="activeFeature = idx"
|
@mouseenter="activeFeature = idx"
|
||||||
class="flex-shrink-0 flex flex-col justify-center gap-2 lg:gap-5 pl-4 lg:pl-5 w-40 lg:w-100 h-20 lg:h-38 rounded-lg cursor-pointer transition-colors duration-300"
|
class="flex-shrink-0 flex flex-col justify-center gap-2 lg:gap-4 pl-4 lg:pl-5 w-40 lg:w-100 h-20 lg:h-auto rounded-lg cursor-pointer transition-colors duration-300"
|
||||||
:class="activeFeature === idx ? 'bg-primary text-white' : 'bg-inkreach-homepage-2'"
|
:class="activeFeature === idx ? 'bg-primary text-white' : 'bg-inkreach-homepage-2'"
|
||||||
>
|
>
|
||||||
<p class="text-xl lg:text-4xl">{{ feat.title }}</p>
|
<p class="feature-item-title text-xl lg:text-3xl">{{ feat.title }}</p>
|
||||||
<p class="text-sm lg:text-base indent-1">{{ feat.desc }}</p>
|
<p class="text-sm lg:text-base indent-1">{{ feat.desc }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,6 +53,7 @@ onUnmounted(stopAutoPlay);
|
|||||||
:key="'feat-img-' + idx"
|
:key="'feat-img-' + idx"
|
||||||
:src="feat.image"
|
:src="feat.image"
|
||||||
:alt="feat.title"
|
:alt="feat.title"
|
||||||
|
:data-testid="`feature-image-${idx}`"
|
||||||
class="w-full h-full object-contain transition-opacity duration-500 absolute inset-0"
|
class="w-full h-full object-contain transition-opacity duration-500 absolute inset-0"
|
||||||
:class="activeFeature === idx ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
:class="activeFeature === idx ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||||
/>
|
/>
|
||||||
@@ -75,3 +61,17 @@ onUnmounted(stopAutoPlay);
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.feature-section { height: 754px; padding-top: 54px; }
|
||||||
|
.feature-section > div:nth-child(2) { height: 550px; }
|
||||||
|
.feature-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(4, minmax(0, 1fr));
|
||||||
|
width: 400px;
|
||||||
|
height: 100%;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
function goToLogin(): void {
|
||||||
|
window.location.href = 'https://inkpod.vip/user/login';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="w-full bg-inkreach-homepage-1 flex flex-col items-center gap-6 lg:gap-10 p-6 lg:p-10 relative overflow-hidden">
|
<section class="hero w-full bg-inkreach-homepage-1 flex flex-col items-center gap-6 lg:gap-10 p-6 lg:p-10 relative overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src="/水洗T恤.png"
|
src="/水洗T恤.png"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -11,21 +17,25 @@
|
|||||||
class="hidden lg:block absolute right-[10%] top-1/4 -translate-y-2/3"
|
class="hidden lg:block absolute right-[10%] top-1/4 -translate-y-2/3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row gap-2 lg:gap-4 text-3xl md:text-5xl lg:text-7xl font-extrabold text-center mt-4 lg:mt-0">
|
<div class="hero-copy flex flex-col items-center lg:items-start gap-3 mt-4 lg:mt-0">
|
||||||
<div>一站定制</div>
|
<div class="hero-title text-3xl md:text-5xl font-extrabold text-center lg:text-left">
|
||||||
<div>货通全球</div>
|
一站定制 链<span class="hero-gradient-text">通全球</span>
|
||||||
|
</div>
|
||||||
|
<p class="hero-subtitle font-thin text-xl md:text-2xl text-center lg:text-left">从定制设计至生产发货如此简单</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xl md:text-2xl lg:text-4xl text-center">从定制设计至生产发货如此简单</p>
|
<div class="selling-points flex items-center gap-3 text-base lg:text-xl">
|
||||||
|
<span>0库存</span>
|
||||||
<div class="flex gap-3 text-base lg:text-xl">
|
<span class="selling-point-separator" aria-hidden="true">|</span>
|
||||||
<div>0库存</div>
|
<span>0关税</span>
|
||||||
<div>0关税</div>
|
<span class="selling-point-separator" aria-hidden="true">|</span>
|
||||||
<div>1件起订</div>
|
<span>1件起订</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="goToLogin"
|
||||||
class="text-lg lg:text-xl text-white px-10 lg:px-14 py-3 lg:py-4 rounded-lg hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 pulse-animation bg-primary relative z-10"
|
class="text-lg lg:text-xl text-white px-10 lg:px-14 py-3 lg:py-4 rounded-lg hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 pulse-animation bg-primary relative z-10"
|
||||||
>
|
>
|
||||||
免费试用
|
免费试用
|
||||||
@@ -37,6 +47,33 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img src="/主视觉.png" class="w-full max-w-5xl" />
|
<img
|
||||||
|
src="/主视觉.png"
|
||||||
|
alt="InkReach 全球 POD 定制商品"
|
||||||
|
width="800"
|
||||||
|
height="400"
|
||||||
|
class="hero-visual"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.hero { height: auto; min-height: 0; margin-top: 80px; padding-top: 52px; padding-bottom: 32px; gap: 18px; }
|
||||||
|
.hero-copy { align-items: flex-start; }
|
||||||
|
.hero-title { font-size: 80px; line-height: 1.25; }
|
||||||
|
.hero-subtitle { font-size: 48px; line-height: 1.4; }
|
||||||
|
.hero-visual { flex: 0 0 auto; width: min(100%, 800px); height: auto; object-fit: contain; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual { width: min(100%, 800px); height: auto; object-fit: contain; }
|
||||||
|
.hero-gradient-text {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(90deg, #111827 0%, #111827 28%, #ff6800 100%);
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.selling-point-separator { color: #9ca3af; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,69 +1,78 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const {
|
const {
|
||||||
countryGroups,
|
countryGroups,
|
||||||
activeCountryIdx,
|
|
||||||
loading,
|
loading,
|
||||||
loadingProducts,
|
loadingProducts,
|
||||||
countryPage,
|
|
||||||
visibleCountries,
|
|
||||||
canPrevCountry,
|
|
||||||
canNextCountry,
|
|
||||||
currentProducts,
|
currentProducts,
|
||||||
fetchCategories,
|
fetchCategories,
|
||||||
selectCountry,
|
selectCountry,
|
||||||
} = usePodProducts();
|
} = usePodProducts();
|
||||||
|
const { countries: configuredCountries, categories: configuredCategories, loadFilters } = useProductCenter();
|
||||||
|
const selectedCountryId = shallowRef<string>();
|
||||||
|
|
||||||
onMounted(() => {
|
async function initialize(): Promise<void> {
|
||||||
fetchCategories();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="w-full flex flex-col items-center p-6 lg:p-10 bg-inkreach-homepage-2">
|
<section class="pod-section w-full flex flex-col items-center p-6 lg:p-10 bg-inkreach-homepage-2">
|
||||||
<div class="w-full max-w-7xl flex flex-col gap-8 lg:gap-10">
|
<div class="pod-content w-full max-w-7xl flex flex-col gap-8 lg:gap-10">
|
||||||
<div class="text-2xl md:text-4xl lg:text-5xl text-center">全球POD货盘</div>
|
<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">
|
<div v-if="loading" class="flex justify-center py-8">
|
||||||
<i class="fa-solid fa-spinner fa-spin text-3xl text-primary"></i>
|
<i class="fa-solid fa-spinner fa-spin text-3xl text-primary"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex items-center justify-center gap-4 lg:gap-10 overflow-x-auto pb-2">
|
<div class="country-grid grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 pb-2">
|
||||||
<button
|
<button
|
||||||
v-if="canPrevCountry"
|
v-for="country in configuredCountries"
|
||||||
@click="countryPage--"
|
|
||||||
class="w-8 h-8 flex-shrink-0 flex items-center justify-center rounded-full border border-gray-300 text-gray-400 hover:border-primary hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-chevron-left text-xs"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-for="country in visibleCountries"
|
|
||||||
:key="country.id"
|
:key="country.id"
|
||||||
@click="selectCountry(countryGroups.indexOf(country))"
|
type="button"
|
||||||
class="px-4 lg:px-6 py-2 rounded-lg text-base lg:text-xl transition-colors whitespace-nowrap"
|
class="country-button px-4 py-2 rounded-lg transition-colors whitespace-nowrap"
|
||||||
:class="activeCountryIdx === countryGroups.indexOf(country)
|
:class="selectedCountryId === country.id ? 'bg-primary text-white' : 'bg-white text-gray-600 hover:text-primary'"
|
||||||
? 'bg-primary text-white'
|
@click="switchCountry(country)"
|
||||||
: 'bg-white text-gray-500 hover:text-gray-700'"
|
|
||||||
>
|
>
|
||||||
{{ country.name }}
|
<span class="country-flag">
|
||||||
</button>
|
<img v-if="country.icon" :src="country.icon" :alt="country.name" />
|
||||||
<button
|
</span>
|
||||||
v-if="canNextCountry"
|
<span>{{ country.name }}</span>
|
||||||
@click="countryPage++"
|
|
||||||
class="w-8 h-8 flex-shrink-0 flex items-center justify-center rounded-full border border-gray-300 text-gray-400 hover:border-primary hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-chevron-right text-xs"></i>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center items-center min-h-[200px]">
|
<div class="product-stage flex justify-center items-center min-h-[200px]">
|
||||||
<div v-if="loadingProducts" class="flex justify-center py-8">
|
<div v-if="loadingProducts" class="flex justify-center py-8">
|
||||||
<i class="fa-solid fa-spinner fa-spin text-2xl text-primary"></i>
|
<i class="fa-solid fa-spinner fa-spin text-2xl text-primary"></i>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3 lg:gap-5 w-full">
|
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3 lg:gap-5 w-full">
|
||||||
<div
|
<NuxtLink
|
||||||
v-for="product in currentProducts"
|
v-for="product in currentProducts"
|
||||||
:key="product.id"
|
:key="product.id"
|
||||||
|
:to="categoryLink(product.name)"
|
||||||
class="bg-white rounded-lg overflow-hidden"
|
class="bg-white rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
<div class="h-[14.25rem] flex items-center justify-center">
|
<div class="h-[14.25rem] flex items-center justify-center">
|
||||||
@@ -81,20 +90,31 @@ onMounted(() => {
|
|||||||
<div class="w-full text-xs lg:text-sm h-8 lg:h-10 flex justify-center items-center px-1 truncate">
|
<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 class="truncate">{{ product.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full flex justify-center py-6 lg:py-10">
|
<div class="more-products w-full flex justify-center py-6 lg:py-10">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/product-center"
|
to="/product-center"
|
||||||
class="inline-flex items-center gap-2 px-6 lg:px-8 py-2.5 lg:py-3 rounded-lg border-2 border-primary text-primary text-base lg:text-lg font-medium bg-white hover:bg-primary hover:text-white hover:shadow-lg transition-all duration-300 transform hover:-translate-y-0.5"
|
class="more-products-link text-primary text-base lg:text-lg font-medium hover:underline underline-offset-4"
|
||||||
>
|
>
|
||||||
更多产品
|
更多产品
|
||||||
<i class="fa-solid fa-arrow-right text-xs"></i>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
const activeStep = ref(0);
|
const activeStep = ref(0);
|
||||||
|
const STEP_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ num: '1', title: '选择产品', desc: '海量精选产品,满足多元定制需求', image: '/01步骤-选择产品.png' },
|
{ num: '1', title: '选择产品', desc: '海量精选产品,满足多元定制需求', image: '/01步骤-选择产品.png' },
|
||||||
@@ -10,63 +13,50 @@ const steps = [
|
|||||||
|
|
||||||
const selectStep = (idx: number) => {
|
const selectStep = (idx: number) => {
|
||||||
activeStep.value = idx;
|
activeStep.value = idx;
|
||||||
|
startStepTimer();
|
||||||
};
|
};
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let stepTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const startAutoPlay = () => {
|
function goToLogin(): void {
|
||||||
stopAutoPlay();
|
window.location.href = 'https://inkpod.vip/user/login';
|
||||||
timer = setInterval(() => {
|
}
|
||||||
|
|
||||||
|
function startStepTimer(): void {
|
||||||
|
if (stepTimer) clearInterval(stepTimer);
|
||||||
|
stepTimer = setInterval(() => {
|
||||||
activeStep.value = (activeStep.value + 1) % steps.length;
|
activeStep.value = (activeStep.value + 1) % steps.length;
|
||||||
}, 3000);
|
}, STEP_INTERVAL_MS);
|
||||||
};
|
}
|
||||||
|
|
||||||
const stopAutoPlay = () => {
|
onMounted(startStepTimer);
|
||||||
if (timer) {
|
|
||||||
clearInterval(timer);
|
|
||||||
timer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pauseAutoPlay = () => {
|
onBeforeUnmount(() => {
|
||||||
stopAutoPlay();
|
if (stepTimer) clearInterval(stepTimer);
|
||||||
};
|
});
|
||||||
|
|
||||||
const resumeAutoPlay = () => {
|
|
||||||
startAutoPlay();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(startAutoPlay);
|
|
||||||
onUnmounted(stopAutoPlay);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="w-full flex flex-col gap-8 lg:gap-10 items-center p-6 lg:p-10">
|
<section class="step-section w-full flex flex-col gap-8 lg:gap-10 items-center p-6 lg:p-10">
|
||||||
<div class="text-2xl md:text-4xl lg:text-6xl text-center">4 步开始你的按需定制生意</div>
|
<div class="text-[48px] font-extrabold md:text-4xl lg:text-6xl text-center">4步开始你的按需定制生意</div>
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row gap-5 w-full max-w-7xl items-stretch">
|
<div class="flex flex-col lg:flex-row gap-5 w-full max-w-7xl items-stretch">
|
||||||
<div class="flex flex-row lg:flex-col gap-3 lg:gap-10 lg:p-5 lg:w-5/12 overflow-x-auto lg:overflow-visible justify-center"
|
<div class="flex flex-row lg:flex-col gap-3 lg:gap-8 lg:p-5 lg:w-5/12 overflow-x-auto lg:overflow-visible justify-center">
|
||||||
@mouseenter="pauseAutoPlay"
|
|
||||||
@mouseleave="resumeAutoPlay"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-for="(step, idx) in steps"
|
v-for="(step, idx) in steps"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="flex-shrink-0 flex flex-col gap-2 lg:gap-5 cursor-pointer group min-w-[140px] lg:min-w-0"
|
class="flex-shrink-0 flex flex-col gap-2 lg:gap-5 cursor-pointer group min-w-[140px] lg:min-w-0"
|
||||||
@click="selectStep(idx)"
|
@click="selectStep(idx)"
|
||||||
@mouseenter="activeStep = idx"
|
@mouseenter="selectStep(idx)"
|
||||||
>
|
>
|
||||||
<p class="text-xl lg:text-3xl font-bold">{{ step.num }} {{ step.title }}</p>
|
<p class="step-title text-xl lg:text-4xl">{{ step.num }} {{ step.title }}</p>
|
||||||
<p class="hidden lg:block indent-10 text-gray-500 text-xl">{{ step.desc }}</p>
|
<p class="hidden lg:block indent-10 text-gray-500 text-xl">{{ step.desc }}</p>
|
||||||
<div class="h-0.5 w-full rounded-full bg-gray-200 overflow-hidden">
|
<div class="h-0.5 w-full rounded-full bg-gray-200 overflow-hidden">
|
||||||
<div
|
<div v-if="activeStep === idx" class="step-progress h-full rounded-full bg-primary"></div>
|
||||||
class="h-full rounded-full transition-all duration-500"
|
|
||||||
:class="activeStep === idx ? 'w-1/2 bg-primary' : 'w-0 bg-primary'"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden lg:flex items-center pt-6">
|
<div class="hidden lg:flex items-center pt-6">
|
||||||
<button class="text-xl lg:text-2xl text-white bg-primary rounded-lg px-10 lg:px-14 py-3 lg:py-4 hover:opacity-90 transition-opacity">
|
<button type="button" class="text-xl lg:text-2xl text-white bg-primary rounded-lg px-10 lg:px-14 py-3 lg:py-4 hover:opacity-90 transition-opacity" @click="goToLogin">
|
||||||
立即开始
|
立即开始
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,6 +68,7 @@ onUnmounted(stopAutoPlay);
|
|||||||
:key="'img-' + idx"
|
:key="'img-' + idx"
|
||||||
:src="step.image"
|
:src="step.image"
|
||||||
:alt="step.title"
|
:alt="step.title"
|
||||||
|
:data-testid="`step-image-${idx}`"
|
||||||
class="w-full h-full object-contain transition-opacity duration-500 absolute inset-0"
|
class="w-full h-full object-contain transition-opacity duration-500 absolute inset-0"
|
||||||
:class="activeStep === idx ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
:class="activeStep === idx ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||||
/>
|
/>
|
||||||
@@ -85,9 +76,23 @@ onUnmounted(stopAutoPlay);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex lg:hidden">
|
<div class="flex lg:hidden">
|
||||||
<button class="text-xl text-white bg-primary rounded-lg px-10 py-3 hover:opacity-90 transition-opacity">
|
<button type="button" class="text-xl text-white bg-primary rounded-lg px-10 py-3 hover:opacity-90 transition-opacity" @click="goToLogin">
|
||||||
立即开始
|
立即开始
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.step-progress { width: 0; animation: fill-step-progress 2s linear forwards; }
|
||||||
|
|
||||||
|
@keyframes fill-step-progress {
|
||||||
|
to { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.step-section { height: 935px; padding-top: 54px; }
|
||||||
|
.step-section > div:first-child { font-size: 48px; }
|
||||||
|
.step-section > div:nth-child(2) { height: 708px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="w-full bg-inkreach-homepage-2 flex flex-col justify-center items-center gap-6 lg:gap-10 p-6 lg:p-10 py-12 lg:py-0 lg:h-138">
|
<section class="trust-section w-full bg-inkreach-homepage-2 flex flex-col justify-center items-center gap-6 lg:gap-10 p-6 lg:p-10 py-12 lg:py-0 lg:h-138">
|
||||||
<div class="flex gap-2 text-2xl md:text-4xl lg:text-5xl text-center">全球5000+卖家信赖我们的平台</div>
|
<div class="flex gap-2 text-[48px] font-extrabold md:text-4xl lg:text-5xl text-center">全球5000+卖家信赖我们的平台</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 lg:gap-14 w-full max-w-5xl">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 lg:gap-14 w-full max-w-5xl">
|
||||||
<div
|
<div
|
||||||
@@ -10,11 +10,11 @@
|
|||||||
>
|
>
|
||||||
<div class="bg-inkreach-homepage-3">
|
<div class="bg-inkreach-homepage-3">
|
||||||
<div class="text-lg lg:text-2xl flex flex-col gap-1 lg:gap-2 p-3 lg:p-5">
|
<div class="text-lg lg:text-2xl flex flex-col gap-1 lg:gap-2 p-3 lg:p-5">
|
||||||
<div>{{ item.value }}</div>
|
<div class="trust-stat-value text-primary font-black">{{ item.value }}</div>
|
||||||
<div>{{ item.label }}</div>
|
<div>{{ item.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative -top-6 lg:-top-10">
|
<div class="trust-illustration relative -top-6 lg:-top-10">
|
||||||
<img :src="item.icon" :alt="item.label" />
|
<img :src="item.icon" :alt="item.label" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,3 +30,10 @@ const stats = [
|
|||||||
{ value: '24H', label: '急速发货', icon: '/极速发货.png' },
|
{ value: '24H', label: '急速发货', icon: '/极速发货.png' },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.trust-section { height: 600px; }
|
||||||
|
.trust-illustration { margin-bottom: -40px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="py-10 lg:py-16 px-4 lg:px-6 bg-inkreach-homepage-2">
|
<section class="why-section py-10 lg:py-16 px-4 lg:px-6 bg-inkreach-homepage-2">
|
||||||
<div class="max-w-7xl mx-auto">
|
<div class="max-w-7xl mx-auto">
|
||||||
<h2 class="text-2xl md:text-4xl lg:text-5xl font-bold text-center mb-8 lg:mb-10">
|
<h2 class="text-2xl md:text-4xl lg:text-5xl font-bold text-center mb-8 lg:mb-10">
|
||||||
为什么选择<span
|
为什么选择<span
|
||||||
@@ -30,3 +30,7 @@ const reasons = [
|
|||||||
{ title: '全球配送', icon: '/全球配送图标.png' },
|
{ title: '全球配送', icon: '/全球配送图标.png' },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@media (min-width: 1024px) { .why-section { height: 430px; } }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,146 +3,44 @@ import { computed } from 'vue';
|
|||||||
import type { Product } from '~/composables/useProductCenter';
|
import type { Product } from '~/composables/useProductCenter';
|
||||||
import { sortTagsByGroup } from '~/composables/useProductCenter';
|
import { sortTagsByGroup } from '~/composables/useProductCenter';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{ product: Product; showCountry?: boolean }>();
|
||||||
product: Product;
|
const visibleTags = computed(() => sortTagsByGroup(props.product.tags).slice(0, 3));
|
||||||
showCountry?: boolean;
|
const price = computed(() => {
|
||||||
}>();
|
const value = Number(props.product.price);
|
||||||
|
return Number.isFinite(value) ? value.toFixed(2) : null;
|
||||||
const sortedTags = computed(() => sortTagsByGroup(props.product.tags).slice(0, 4));
|
});
|
||||||
|
|
||||||
function formatPrice(price: string | null): string {
|
|
||||||
if (!price) return '—';
|
|
||||||
const num = Number(price);
|
|
||||||
if (!Number.isFinite(num)) return '—';
|
|
||||||
return `¥${num.toFixed(2)}起`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="product-card">
|
<a class="product-card" :href="`https://inkpod.vip/portal/detail/${product.id}`">
|
||||||
<div class="product-media">
|
<div class="product-media">
|
||||||
<img
|
<img v-if="product.image" :src="product.image" :alt="product.name" loading="lazy" />
|
||||||
v-if="product.image"
|
<i v-else class="fa-regular fa-image" aria-hidden="true"></i>
|
||||||
:src="product.image"
|
|
||||||
:alt="product.name"
|
|
||||||
class="product-img"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div v-else class="placeholder">
|
|
||||||
<i class="fa-solid fa-image"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="product-info">
|
<div class="product-info">
|
||||||
<h3 class="product-title" :title="product.name">{{ product.name }}</h3>
|
<h3 :title="product.name">{{ product.name }}</h3>
|
||||||
<div v-if="sortedTags.length > 0 || showCountry" class="chips">
|
<div class="chips">
|
||||||
<span
|
<span v-for="tag in visibleTags" :key="tag.id" class="chip" :style="{ color: tag.fontColor || tag.color || '#21834b', backgroundColor: `${tag.color || '#eaf8ef'}30` }">{{ tag.name }}</span>
|
||||||
v-for="tag in sortedTags"
|
<span v-if="showCountry" class="chip country">{{ product.country.name }}</span>
|
||||||
:key="tag.id"
|
|
||||||
class="chip"
|
|
||||||
:style="{ color: tag.fontColor || tag.color || '#ff6a00', background: (tag.color || '#ff6a00') + '4d', borderColor: (tag.color || '#ff6a00') + '99' }"
|
|
||||||
>
|
|
||||||
{{ tag.name }}
|
|
||||||
</span>
|
|
||||||
<span v-if="showCountry" class="chip country-chip">
|
|
||||||
<img
|
|
||||||
v-if="product.country.icon"
|
|
||||||
:src="product.country.icon"
|
|
||||||
:alt="product.country.name"
|
|
||||||
class="country-flag"
|
|
||||||
/>
|
|
||||||
{{ product.country.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="price">{{ formatPrice(product.price) }}</div>
|
<div class="price"><template v-if="price"><small>¥</small>{{ price }}<small>起</small></template><template v-else>暂无报价</template></div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.product-card {
|
.product-card { display: block; width: 100%; height: 382px; overflow: hidden; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; color: inherit; text-decoration: none; transition: box-shadow .2s, transform .2s; }.product-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.08); }
|
||||||
overflow: hidden;
|
.product-media { display: grid; place-items: center; width: 100%; aspect-ratio: 1; overflow: hidden; border-radius: 8px; background: #f2f2f2; color: #bbb; font-size: 32px; }
|
||||||
border-radius: 12px;
|
.product-media img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
background: #fff;
|
.product-info { padding: 7px 12px 9px; }
|
||||||
border: 1px solid #eee;
|
.product-info h3 { margin: 0 0 5px; overflow: hidden; color: #000; font-size: 18px; font-weight: 500; line-height: 25px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
.chips { display: flex; gap: 6px; height: 21px; margin-bottom: 5px; overflow: hidden; }
|
||||||
}
|
.chip { display: inline-flex; align-items: center; flex: 0 0 auto; height: 21px; padding: 0 6px; border-radius: 4px; font-size: 12px; white-space: nowrap; }
|
||||||
|
.chip.country { color: #535353; background: #ececef; }
|
||||||
.product-card:hover {
|
.price { margin-top: 5px; color: #000; font-size: 20px; font-weight: 700; line-height: 28px; }
|
||||||
transform: translateY(-3px);
|
.price small { font-size: 16px; font-weight: 500; }
|
||||||
box-shadow: 0 8px 28px rgba(17, 17, 17, 0.06);
|
@media (max-width: 760px) {
|
||||||
}
|
.product-card { height: auto; min-height: 0; max-width: none; overflow: visible; }
|
||||||
|
.product-info h3 { font-size: 15px; }
|
||||||
.product-media {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
background: linear-gradient(180deg, #f8f8f8, #f0f0f0);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
color: #ccc;
|
|
||||||
font-size: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-info {
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-title {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 19px;
|
|
||||||
line-height: 1.4;
|
|
||||||
font-weight: 500;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: #111;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chips {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: #ff6a00;
|
|
||||||
background: #fff2e8;
|
|
||||||
font-size: 14px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.country-chip {
|
|
||||||
color: #555;
|
|
||||||
background: #f3f4f6;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.country-flag {
|
|
||||||
width: 16px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 2px;
|
|
||||||
object-fit: cover;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.price {
|
|
||||||
color: #222;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<article class="skeleton-card">
|
<article class="skeleton-card">
|
||||||
<div class="skeleton-media"></div>
|
<div class="skeleton-media"><i class="fa-solid fa-image" aria-hidden="true"></i></div>
|
||||||
<div class="skeleton-info">
|
<div class="skeleton-info">
|
||||||
<div class="skeleton-line title"></div>
|
<div class="skeleton-line title"></div>
|
||||||
<div class="skeleton-chips">
|
<div class="skeleton-chips">
|
||||||
@@ -31,10 +31,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-media {
|
.skeleton-media {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skeleton-media i { position: relative; z-index: 1; color: #c2c2c2; font-size: 44px; }
|
||||||
|
|
||||||
.skeleton-media::after,
|
.skeleton-media::after,
|
||||||
.skeleton-line::after,
|
.skeleton-line::after,
|
||||||
.skeleton-chip::after {
|
.skeleton-chip::after {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const portalUrl = 'https://inkpod.vip/portal/search';
|
||||||
|
const loginUrl = 'https://inkpod.vip/user/login';
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<header class="header">
|
||||||
|
<NuxtLink to="/" class="logo"><img src="/logo-unified.svg" alt="InkReach" /></NuxtLink>
|
||||||
|
<nav><NuxtLink to="/product-center" class="active">产品中心</NuxtLink><a :href="portalUrl" target="_blank">在线设计</a><a href="#">选品推荐</a><a href="#">解决方案</a></nav>
|
||||||
|
<div class="actions"><a :href="loginUrl" target="_blank"><i class="fa-solid fa-desktop"></i>工作台</a><a :href="loginUrl" target="_blank"><i class="fa-solid fa-cart-shopping"></i>购物车</a><a class="phone" href="tel:18588980344"><i class="fa-solid fa-circle-user"></i>18588980344<i class="fa-solid fa-chevron-down"></i></a></div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.header { position: fixed; z-index: 60; top: 0; left: 0; right: 0; display: flex; align-items: center; height: 80px; padding: 0 70px; border-bottom: 1px solid #e5e7eb; background: #fff; }
|
||||||
|
.logo { width: 97px; height: 56px; display: grid; place-items: center; }.logo img { width: 97px; height: 56px; object-fit: contain; }
|
||||||
|
nav { display: flex; gap: 32px; margin-left: 44px; } nav a { color: rgba(0,0,0,.5); font-size: 18px; text-decoration: none; white-space: nowrap; } nav .active { color: #000; }
|
||||||
|
.actions { display: flex; align-items: center; gap: 24px; margin-left: auto; }.actions a { display: flex; align-items: center; gap: 8px; color: rgba(0,0,0,.62); font-size: 16px; text-decoration: none; white-space: nowrap; }.actions i { font-size: 18px; }.actions .phone { color: #222; font-size: 18px; }.phone .fa-chevron-down { margin-left: 4px; font-size: 10px; }
|
||||||
|
@media (max-width: 1000px) { .header { padding: 0 20px; }.header nav { display: none; }.actions a:not(.phone) { display: none; } }
|
||||||
|
@media (max-width: 520px) { .actions .phone { font-size: 0; }.actions .phone i { font-size: 20px; } }
|
||||||
|
</style>
|
||||||
@@ -1,89 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
import type { Country } from '~/composables/useProductCenter';
|
import type { Country } from '~/composables/useProductCenter';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{ countries: Country[]; activeCountryId: string | null }>();
|
||||||
countries: Country[];
|
const emit = defineEmits<{ 'update:activeCountryId': [id: string | null] }>();
|
||||||
activeCountryId: string | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const configuredCountries = computed(() => props.countries);
|
||||||
'update:activeCountryId': [id: string | null];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
function selectCountry(id: string | null): void {
|
|
||||||
emit('update:activeCountryId', id);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="country-bar">
|
<div class="country-bar" aria-label="国家筛选">
|
||||||
<button
|
<button type="button" class="filter-pill" :class="{ active: activeCountryId === null }" @click="emit('update:activeCountryId', null)">全部</button>
|
||||||
type="button"
|
<template v-for="country in configuredCountries" :key="country.id">
|
||||||
class="filter-pill"
|
<button type="button" class="filter-pill" :class="{ active: activeCountryId === country.id }" @click="emit('update:activeCountryId', country.id)">
|
||||||
:class="{ active: activeCountryId === null }"
|
<img v-if="country.icon" :src="country.icon" :alt="country.name" class="flag" />
|
||||||
@click="selectCountry(null)"
|
{{ country.name }}
|
||||||
>
|
</button>
|
||||||
全部
|
</template>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-for="country in countries"
|
|
||||||
:key="country.id"
|
|
||||||
type="button"
|
|
||||||
class="filter-pill"
|
|
||||||
:class="{ active: activeCountryId === country.id }"
|
|
||||||
@click="selectCountry(country.id)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="country.icon"
|
|
||||||
:src="country.icon"
|
|
||||||
:alt="country.name"
|
|
||||||
class="flag"
|
|
||||||
/>
|
|
||||||
{{ country.name }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.country-bar {
|
.country-bar { display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; max-width: 100%; flex-wrap: wrap; }
|
||||||
display: flex;
|
.filter-pill { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 38px; padding: 0 16px; border: 0; border-radius: 60px; background: #f2f2f2; color: #000; font-size: 16px; white-space: nowrap; cursor: pointer; }
|
||||||
align-items: center;
|
.filter-pill.active { color: #ff6800; background: rgba(255, 104, 0, .06); }
|
||||||
gap: 10px;
|
.flag { width: 22px; height: 16px; border-radius: 2px; object-fit: cover; }
|
||||||
overflow-x: auto;
|
@media (max-width: 760px) { .country-bar { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; } }
|
||||||
}
|
|
||||||
|
|
||||||
.filter-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
background: #fff;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #555;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-pill:hover {
|
|
||||||
border-color: #ddd;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-pill.active {
|
|
||||||
color: #ff6a00;
|
|
||||||
border-color: #ff6a00;
|
|
||||||
background: #fff2e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag {
|
|
||||||
width: 18px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 2px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,108 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { shallowRef, watch } from 'vue';
|
||||||
|
|
||||||
const keyword = defineModel<string>('keyword', { default: '' });
|
const keyword = defineModel<string>('keyword', { default: '' });
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{ search: [] }>();
|
||||||
search: [];
|
const localKeyword = shallowRef(keyword.value);
|
||||||
}>();
|
watch(keyword, (value) => { localKeyword.value = value; });
|
||||||
|
function submit(): void { keyword.value = localKeyword.value.trim(); emit('search'); }
|
||||||
const localKeyword = ref(keyword.value);
|
function clear(): void { localKeyword.value = ''; keyword.value = ''; emit('search'); }
|
||||||
|
|
||||||
watch(keyword, (v) => {
|
|
||||||
localKeyword.value = v;
|
|
||||||
});
|
|
||||||
|
|
||||||
function onSearch(): void {
|
|
||||||
keyword.value = localKeyword.value.trim();
|
|
||||||
emit('search');
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeydown(event: KeyboardEvent): void {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
onSearch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearKeyword(): void {
|
|
||||||
localKeyword.value = '';
|
|
||||||
keyword.value = '';
|
|
||||||
emit('search');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="search-box">
|
<form class="search-box" role="search" @submit.prevent="submit">
|
||||||
<input
|
<i class="fa-solid fa-magnifying-glass search-icon" aria-hidden="true"></i>
|
||||||
v-model="localKeyword"
|
<input v-model="localKeyword" aria-label="搜索商品" placeholder="搜索商品" />
|
||||||
type="text"
|
<button v-if="localKeyword" class="clear-search" type="button" aria-label="清除搜索" @click="clear"><i class="fa-solid fa-xmark"></i></button>
|
||||||
placeholder="搜索商品"
|
<button class="submit-search" type="submit">搜索</button>
|
||||||
@keydown="onKeydown"
|
</form>
|
||||||
/>
|
|
||||||
<button v-if="localKeyword" type="button" class="clear-btn" @click="clearKeyword">
|
|
||||||
<i class="fa-solid fa-times"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="search-btn" @click="onSearch">
|
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.search-box {
|
.search-box { position: relative; display: flex; align-items: center; width: 220px; height: 38px; border: 1px solid #c7c7c7; border-radius: 60px; background: #fff; overflow: hidden; }
|
||||||
display: flex;
|
.search-icon { position: absolute; left: 12px; color: #666; font-size: 14px; }
|
||||||
align-items: stretch;
|
.search-box input { min-width: 0; flex: 1; height: 100%; padding: 0 2px 0 34px; border: 0; outline: 0; background: transparent; font-size: 16px; }
|
||||||
height: 38px;
|
.search-box button { flex: 0 0 auto; height: 34px; padding: 0; border: 0; background: transparent; cursor: pointer; }
|
||||||
border: 1px solid #eee;
|
.search-box .clear-search { width: 28px; color: #777; font-size: 14px; }.search-box .submit-search { width: 50px; color: #ff6800; font-size: 16px; }
|
||||||
border-radius: 8px;
|
@media (max-width: 760px) { .search-box { width: 100%; } }
|
||||||
overflow: hidden;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box:focus-within {
|
|
||||||
border-color: #ff6a00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box input {
|
|
||||||
width: 200px;
|
|
||||||
padding: 0 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
border: 0;
|
|
||||||
outline: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box input::placeholder {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: #999;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn:hover {
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
border: 0;
|
|
||||||
background: #ff6a00;
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,127 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Product } from '~/composables/useProductCenter';
|
import type { Product } from '~/composables/useProductCenter';
|
||||||
|
defineProps<{ products: Product[]; loading: boolean; showCountry?: boolean }>();
|
||||||
const props = defineProps<{
|
const emit = defineEmits<{ 'clear-filters': []; 'view-all': [] }>();
|
||||||
products: Product[];
|
|
||||||
loading: boolean;
|
|
||||||
showCountry?: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const skeletonCount = 12;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div v-if="loading" class="product-grid"><ProductCardSkeleton v-for="index in 8" :key="index" /></div>
|
||||||
<div v-if="loading" class="product-grid">
|
<div v-else-if="!products.length" class="empty">
|
||||||
<ProductCardSkeleton
|
<img src="/product-empty.svg" alt="" />
|
||||||
v-for="n in skeletonCount"
|
<h2>未找到相关商品</h2>
|
||||||
:key="`skeleton-${n}`"
|
<p>当前筛选条件下暂无匹配产品,可尝试调整关键词或减少筛选条件。</p>
|
||||||
/>
|
<div class="empty-actions"><button type="button" class="view-all" @click="emit('view-all')">查看全部商品</button><button type="button" class="clear" @click="emit('clear-filters')">清空筛选</button></div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="products.length === 0" class="empty-state">
|
|
||||||
<div class="empty-art">
|
|
||||||
<div class="empty-box"></div>
|
|
||||||
<div class="empty-lid"></div>
|
|
||||||
<div class="empty-spark spark-1"></div>
|
|
||||||
<div class="empty-spark spark-2"></div>
|
|
||||||
</div>
|
|
||||||
<h2>暂无匹配商品</h2>
|
|
||||||
<p>试试清空筛选条件,或切换其他分类看看。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="product-grid">
|
|
||||||
<ProductCard
|
|
||||||
v-for="product in products"
|
|
||||||
:key="product.id"
|
|
||||||
:product="product"
|
|
||||||
:show-country="showCountry"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="product-grid"><ProductCard v-for="product in products" :key="product.id" :product="product" :show-country="showCountry" /></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.product-grid {
|
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px 24px; }
|
||||||
display: grid;
|
.empty { display: flex; flex-direction: column; align-items: center; min-height: 600px; padding-top: 86px; color: rgba(0,0,0,.6); text-align: center; }.empty img { width: 260px; height: 260px; object-fit: contain; }.empty h2 { margin: 12px 0 10px; color: #000; font-size: 22px; font-weight: 600; }.empty p { margin: 0; font-size: 14px; }.empty-actions { display: flex; gap: 24px; margin-top: 34px; }.empty-actions button { height: 38px; padding: 0 16px; border: 0; border-radius: 60px; font-size: 16px; cursor: pointer; }.empty-actions .view-all { color: #ff6800; background: rgba(255,104,0,.06); }.empty-actions .clear { min-width: 128px; color: #fff; background: #ff6800; }
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
@media (max-width: 1180px) { .product-grid { gap: 16px; } }
|
||||||
gap: 28px;
|
@media (max-width: 760px) { .product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
}
|
@media (max-width: 430px) { .product-grid { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
min-height: 480px;
|
|
||||||
padding: 80px 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-art {
|
|
||||||
position: relative;
|
|
||||||
width: 220px;
|
|
||||||
height: 176px;
|
|
||||||
margin: 0 auto 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-box {
|
|
||||||
position: absolute;
|
|
||||||
left: 35px;
|
|
||||||
top: 42px;
|
|
||||||
width: 150px;
|
|
||||||
height: 94px;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: #f2f2f2;
|
|
||||||
transform: perspective(400px) rotateX(58deg) rotateZ(-8deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-lid {
|
|
||||||
position: absolute;
|
|
||||||
left: 48px;
|
|
||||||
top: 20px;
|
|
||||||
width: 124px;
|
|
||||||
height: 64px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: #e8e8e8;
|
|
||||||
transform: rotate(-8deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-spark {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #ff6a00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spark-1 {
|
|
||||||
left: 21px;
|
|
||||||
top: 52px;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spark-2 {
|
|
||||||
right: 26px;
|
|
||||||
top: 18px;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state h2 {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state p {
|
|
||||||
margin: 0;
|
|
||||||
color: #777;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.product-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,249 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{
|
const props = defineProps<{ total: number; page: number; pageSize: number }>();
|
||||||
total: number;
|
const emit = defineEmits<{ 'update:page': [page: number]; 'update:pageSize': [size: number] }>();
|
||||||
page: number;
|
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)));
|
||||||
pageSize: number;
|
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); }
|
||||||
const emit = defineEmits<{
|
function jump(): void { go(Math.floor(Number(goto.value))); goto.value = ''; }
|
||||||
'update:page': [page: number];
|
|
||||||
'update:pageSize': [size: number];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const totalPages = computed(() => {
|
|
||||||
if (props.total <= 0) return 1;
|
|
||||||
return Math.max(1, Math.ceil(props.total / props.pageSize));
|
|
||||||
});
|
|
||||||
|
|
||||||
const pageNumbers = computed<(number | string)[]>(() => {
|
|
||||||
const total = totalPages.value;
|
|
||||||
const current = props.page;
|
|
||||||
if (total <= 7) {
|
|
||||||
return Array.from({ length: total }, (_, i) => i + 1);
|
|
||||||
}
|
|
||||||
const pages: (number | string)[] = [1];
|
|
||||||
const start = Math.max(2, current - 1);
|
|
||||||
const end = Math.min(total - 1, current + 1);
|
|
||||||
if (start > 2) pages.push('...');
|
|
||||||
for (let i = start; i <= end; i++) pages.push(i);
|
|
||||||
if (end < total - 1) pages.push('...');
|
|
||||||
pages.push(total);
|
|
||||||
return pages;
|
|
||||||
});
|
|
||||||
|
|
||||||
const canPrev = computed(() => props.page > 1);
|
|
||||||
const canNext = computed(() => props.page < totalPages.value);
|
|
||||||
|
|
||||||
const gotoInput = ref('');
|
|
||||||
|
|
||||||
function goPrev(): void {
|
|
||||||
if (canPrev.value) emit('update:page', props.page - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function goNext(): void {
|
|
||||||
if (canNext.value) emit('update:page', props.page + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToPage(page: number): void {
|
|
||||||
if (page < 1 || page > totalPages.value || page === props.page) return;
|
|
||||||
emit('update:page', page);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPageSizeChange(event: Event): void {
|
|
||||||
const target = event.target as HTMLSelectElement;
|
|
||||||
const size = Number(target.value);
|
|
||||||
if (!Number.isFinite(size) || size <= 0) return;
|
|
||||||
emit('update:pageSize', size);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onGoto(): void {
|
|
||||||
const value = Number(gotoInput.value);
|
|
||||||
if (!Number.isFinite(value)) return;
|
|
||||||
const clamped = Math.min(totalPages.value, Math.max(1, Math.floor(value)));
|
|
||||||
gotoInput.value = '';
|
|
||||||
goToPage(clamped);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onGotoKeydown(event: KeyboardEvent): void {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
onGoto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<div class="total-text">
|
<strong>总计{{ total }}个产品</strong>
|
||||||
总计 <span>{{ total }}</span> 个产品
|
<button type="button" :disabled="page <= 1" aria-label="上一页" @click="go(page - 1)"><i class="fa-solid fa-chevron-left"></i></button>
|
||||||
</div>
|
<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>
|
||||||
<div class="page-buttons">
|
<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>
|
||||||
<button
|
<label>导航至<input v-model="goto" type="number" min="1" :max="totalPages" @keydown.enter.prevent="jump" /></label>
|
||||||
type="button"
|
|
||||||
class="nav-btn"
|
|
||||||
:disabled="!canPrev"
|
|
||||||
@click="goPrev"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-chevron-left"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-for="(item, idx) in pageNumbers"
|
|
||||||
:key="`page-${idx}-${item}`"
|
|
||||||
type="button"
|
|
||||||
class="page-btn"
|
|
||||||
:class="{
|
|
||||||
current: item === page,
|
|
||||||
dots: item === '...',
|
|
||||||
}"
|
|
||||||
:disabled="item === '...'"
|
|
||||||
@click="typeof item === 'number' && goToPage(item)"
|
|
||||||
>
|
|
||||||
{{ item }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="nav-btn"
|
|
||||||
:disabled="!canNext"
|
|
||||||
@click="goNext"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="right-controls">
|
|
||||||
<select :value="pageSize" class="size-select" @change="onPageSizeChange">
|
|
||||||
<option :value="12">12 / 页</option>
|
|
||||||
<option :value="20">20 / 页</option>
|
|
||||||
<option :value="40">40 / 页</option>
|
|
||||||
</select>
|
|
||||||
<span class="goto-label">导航至</span>
|
|
||||||
<input
|
|
||||||
v-model="gotoInput"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
:max="totalPages"
|
|
||||||
class="goto-input"
|
|
||||||
@keydown="onGotoKeydown"
|
|
||||||
/>
|
|
||||||
<button type="button" class="goto-btn" @click="onGoto">Go</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pagination {
|
.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; }
|
||||||
display: flex;
|
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; }
|
||||||
align-items: center;
|
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; }
|
||||||
justify-content: space-between;
|
@media (max-width: 760px) { .pagination { flex-wrap: wrap; }.pagination strong { width: 100%; text-align: center; } }
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 24px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total-text span {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-buttons {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn,
|
|
||||||
.page-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
background: #fff;
|
|
||||||
color: #555;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:hover:not(:disabled),
|
|
||||||
.page-btn:hover:not(.dots):not(.current) {
|
|
||||||
border-color: #ff6a00;
|
|
||||||
color: #ff6a00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn.current {
|
|
||||||
background: #ff6a00;
|
|
||||||
border-color: #ff6a00;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn.dots {
|
|
||||||
border-color: transparent;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.size-select {
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fff;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #555;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goto-label {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goto-input {
|
|
||||||
width: 48px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fff;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #222;
|
|
||||||
outline: none;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goto-input:focus {
|
|
||||||
border-color: #ff6a00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goto-btn {
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid #ff6a00;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: #ff6a00;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goto-btn:hover {
|
|
||||||
background: #ff6a00;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,257 +1,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from 'vue';
|
||||||
|
|
||||||
import type { Category } from '~/composables/useProductCenter';
|
import type { Category } from '~/composables/useProductCenter';
|
||||||
|
const props = defineProps<{ categories: Category[]; activeCategoryId: string | null }>();
|
||||||
const props = defineProps<{
|
const emit = defineEmits<{ 'update:activeCategoryId': [id: string | null] }>();
|
||||||
categories: Category[];
|
const expanded = reactive<Record<string, boolean>>({});
|
||||||
activeCategoryId: string | null;
|
function selectRootCategory(category: Category): void {
|
||||||
}>();
|
emit('update:activeCategoryId', category.id);
|
||||||
|
if (category.children.length) expanded[category.id] = !expanded[category.id];
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:activeCategoryId': [id: string | null];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const expanded = ref<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
function toggleExpand(id: string): void {
|
|
||||||
expanded.value[id] = !expanded.value[id];
|
|
||||||
}
|
}
|
||||||
|
watch(() => [props.categories, props.activeCategoryId] as const, () => {
|
||||||
function isExpanded(id: string): boolean {
|
for (const category of props.categories) {
|
||||||
return Boolean(expanded.value[id]);
|
if (category.id === props.activeCategoryId || category.children.some((item) => item.id === props.activeCategoryId)) expanded[category.id] = true;
|
||||||
}
|
}
|
||||||
|
}, { immediate: true });
|
||||||
function selectCategory(id: string | null): void {
|
|
||||||
emit('update:activeCategoryId', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldAutoExpand(id: string): boolean {
|
|
||||||
if (props.activeCategoryId === null) return false;
|
|
||||||
if (props.activeCategoryId === id) return true;
|
|
||||||
const cat = props.categories.find((c) => c.id === id);
|
|
||||||
if (!cat) return false;
|
|
||||||
return cat.children.some((c) => c.id === props.activeCategoryId);
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.categories,
|
|
||||||
(cats) => {
|
|
||||||
for (const c of cats) {
|
|
||||||
if (shouldAutoExpand(c.id)) {
|
|
||||||
expanded.value[c.id] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="product-sidebar">
|
<nav class="sidebar" aria-label="商品分类">
|
||||||
<div class="category-list">
|
<button class="root-row" :class="{ active: activeCategoryId === null }" type="button" @click="emit('update:activeCategoryId', null)"><i class="fa-solid fa-border-all"></i><span>全部商品</span></button>
|
||||||
<button
|
<template v-for="category in categories" :key="category.id">
|
||||||
type="button"
|
<button class="root-row" :class="{ active: activeCategoryId === category.id }" type="button" @click="selectRootCategory(category)">
|
||||||
class="cat-row"
|
<img v-if="category.icon" :src="category.icon" alt="" /><i v-else class="fa-regular fa-folder"></i><span>{{ category.name }}</span><i v-if="category.children.length" class="fa-solid fa-chevron-down arrow" :class="{ open: expanded[category.id] }"></i>
|
||||||
:class="{ active: activeCategoryId === null }"
|
|
||||||
@click="selectCategory(null)"
|
|
||||||
>
|
|
||||||
<span class="cat-icon"></span>
|
|
||||||
<span class="cat-name">全部商品</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<div v-if="category.children.length && expanded[category.id]" class="children">
|
||||||
<template v-for="cat in categories" :key="cat.id">
|
<button v-for="child in category.children" :key="child.id" type="button" :class="{ active: activeCategoryId === child.id }" @click="emit('update:activeCategoryId', child.id)">{{ child.name }}</button>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
</template>
|
||||||
class="cat-row"
|
<p v-if="!categories.length" class="empty">暂无分类</p>
|
||||||
:class="{ active: activeCategoryId === cat.id }"
|
</nav>
|
||||||
@click="cat.children.length > 0 ? toggleExpand(cat.id) : selectCategory(cat.id)"
|
|
||||||
>
|
|
||||||
<span class="cat-icon"></span>
|
|
||||||
<span class="cat-name">{{ cat.name }}</span>
|
|
||||||
<span v-if="cat.children.length > 0" class="chevron" :class="{ expanded: isExpanded(cat.id) }"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Transition name="subtree">
|
|
||||||
<div v-if="cat.children.length > 0 && isExpanded(cat.id)" class="subtree">
|
|
||||||
<button
|
|
||||||
v-for="child in cat.children"
|
|
||||||
:key="child.id"
|
|
||||||
type="button"
|
|
||||||
class="sub-row"
|
|
||||||
:class="{ active: activeCategoryId === child.id }"
|
|
||||||
@click="selectCategory(child.id)"
|
|
||||||
>
|
|
||||||
{{ child.name }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
<div v-if="categories.length === 0" class="empty-cat">暂无分类</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.product-sidebar {
|
.sidebar { width: 216px; padding-top: 12px; }
|
||||||
position: relative;
|
.root-row { display: flex; align-items: center; width: 216px; height: 45px; margin-bottom: 10px; padding: 10px; border: 0; border-radius: 6px; background: #fff; color: #000; font-size: 18px; cursor: pointer; }
|
||||||
padding-top: 26px;
|
.root-row:hover, .root-row.active, .children button.active { color: #ff6800; background: #fff3eb; }.root-row img { filter: brightness(0); }.root-row.active img { filter: invert(48%) sepia(99%) saturate(3770%) hue-rotate(3deg) brightness(104%) contrast(104%); }
|
||||||
background: #ffffff;
|
.root-row img, .root-row > i:first-child { width: 24px; height: 24px; margin-right: 8px; object-fit: contain; text-align: center; line-height: 24px; }
|
||||||
min-height: 100%;
|
.root-row span { flex: 1; text-align: left; }
|
||||||
}
|
.arrow { font-size: 12px; transition: transform .2s; }.arrow.open { transform: rotate(180deg); }
|
||||||
|
.children { position: relative; margin-left: 10px; padding-left: 20px; border-left: 1px solid #e5e7eb; }
|
||||||
.category-list {
|
.children button { position: relative; display: block; width: 174px; height: 45px; margin-bottom: 10px; padding: 10px; border: 0; border-radius: 6px; background: #fff; color: #000; text-align: left; font-size: 18px; cursor: pointer; }
|
||||||
width: 216px;
|
.children button::before { content: ''; position: absolute; top: 22px; left: -14px; width: 6px; border-top: 1px solid #e5e7eb; }
|
||||||
margin: 0 auto;
|
.empty { color: #777; text-align: center; }
|
||||||
}
|
|
||||||
|
|
||||||
.cat-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 45px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #222;
|
|
||||||
transition: background 0.2s ease, color 0.2s ease;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cat-row:hover {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cat-row.active {
|
|
||||||
color: #ff6a00;
|
|
||||||
background: #fff2e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cat-icon {
|
|
||||||
position: relative;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin-right: 8px;
|
|
||||||
color: currentColor;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cat-icon::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 4px 5px;
|
|
||||||
border: 2px solid currentColor;
|
|
||||||
border-radius: 7px 7px 4px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cat-icon::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 8px;
|
|
||||||
top: 2px;
|
|
||||||
width: 8px;
|
|
||||||
height: 7px;
|
|
||||||
border: 2px solid currentColor;
|
|
||||||
border-bottom: 0;
|
|
||||||
border-radius: 7px 7px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cat-name {
|
|
||||||
flex: 1;
|
|
||||||
text-align: left;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-right: 1.8px solid currentColor;
|
|
||||||
border-bottom: 1.8px solid currentColor;
|
|
||||||
transform: rotate(45deg) translateY(-2px);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron.expanded {
|
|
||||||
transform: rotate(225deg) translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtree {
|
|
||||||
position: relative;
|
|
||||||
margin: -2px 0 12px;
|
|
||||||
padding: 0 0 0 42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtree::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 22px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 1px;
|
|
||||||
background: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-row {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 45px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
padding-left: 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #555;
|
|
||||||
font-size: 15px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s ease, color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-row::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -16px;
|
|
||||||
top: 50%;
|
|
||||||
width: 6px;
|
|
||||||
height: 1px;
|
|
||||||
background: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-row:hover {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-row.active {
|
|
||||||
color: #ff6a00;
|
|
||||||
background: #fff2e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-cat {
|
|
||||||
padding: 40px 0;
|
|
||||||
text-align: center;
|
|
||||||
color: #999;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtree-enter-active,
|
|
||||||
.subtree-leave-active {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtree-enter-from,
|
|
||||||
.subtree-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
max-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtree-enter-to,
|
|
||||||
.subtree-leave-from {
|
|
||||||
max-height: 600px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,191 +1,75 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Tag } from '~/composables/useProductCenter';
|
import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue';
|
||||||
|
import type { Tag, TagGroup } from '~/composables/useProductCenter';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{ tags: Tag[]; tagGroups: TagGroup[]; selectedTagIds: string[] }>();
|
||||||
tags: Tag[];
|
const emit = defineEmits<{ 'toggle-tag': [id: string] }>();
|
||||||
selectedTagIds: string[];
|
const openGroup = shallowRef<string | null>(null);
|
||||||
}>();
|
const rootElement = shallowRef<HTMLElement | null>(null);
|
||||||
|
const groupedTags = computed(() => {
|
||||||
|
return props.tagGroups
|
||||||
|
.filter((group) => group.name.includes('物流') || group.name.includes('工艺'))
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
label: group.name.includes('物流') ? '物流' : '工艺',
|
||||||
|
tags: props.tags.filter((tag) => tag.group?.id === group.id),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.tags.length > 0);
|
||||||
|
});
|
||||||
|
const selectedTagByGroup = computed(() => {
|
||||||
|
const selectedIds = new Set(props.selectedTagIds);
|
||||||
|
return new Map(
|
||||||
|
props.tags
|
||||||
|
.filter((tag) => selectedIds.has(tag.id) && tag.group)
|
||||||
|
.map((tag) => [tag.group!.id, tag]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const selectedGroups = computed(() => groupedTags.value
|
||||||
|
.map((group) => ({ ...group, selectedTag: selectedTagByGroup.value.get(group.id) }))
|
||||||
|
.filter((group) => group.selectedTag));
|
||||||
|
|
||||||
const emit = defineEmits<{
|
function selectTag(id: string): void {
|
||||||
'toggle-tag': [id: string];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const dropdownOpen = ref(false);
|
|
||||||
const dropdownRef = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
function toggleDropdown(): void {
|
|
||||||
dropdownOpen.value = !dropdownOpen.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTag(id: string): void {
|
|
||||||
emit('toggle-tag', id);
|
emit('toggle-tag', id);
|
||||||
|
openGroup.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelected(id: string): boolean {
|
function closeOnOutsideClick(event: MouseEvent): void {
|
||||||
return props.selectedTagIds.includes(id);
|
if (!rootElement.value?.contains(event.target as Node)) openGroup.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClickOutside(event: MouseEvent): void {
|
onMounted(() => document.addEventListener('click', closeOnOutsideClick));
|
||||||
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
|
onBeforeUnmount(() => document.removeEventListener('click', closeOnOutsideClick));
|
||||||
dropdownOpen.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', onClickOutside);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener('click', onClickOutside);
|
|
||||||
});
|
|
||||||
|
|
||||||
const label = computed(() => {
|
|
||||||
if (props.selectedTagIds.length === 0) return '标签';
|
|
||||||
if (props.selectedTagIds.length === 1) {
|
|
||||||
return props.tags.find((t) => t.id === props.selectedTagIds[0])?.name ?? '标签';
|
|
||||||
}
|
|
||||||
return `标签 (${props.selectedTagIds.length})`;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="dropdownRef" class="filter-pill-wrapper">
|
<div ref="rootElement" class="tag-filters">
|
||||||
<button
|
<div class="filter-controls">
|
||||||
type="button"
|
<div v-for="group in groupedTags" :key="group.id" class="filter-group">
|
||||||
class="filter-pill"
|
<div class="dropdown">
|
||||||
:class="{ active: selectedTagIds.length > 0 }"
|
<button type="button" class="filter-button" :class="{ open: openGroup === group.id }" @click="openGroup = openGroup === group.id ? null : group.id">{{ group.label }}<i class="fa-solid fa-chevron-down"></i></button>
|
||||||
@click="toggleDropdown"
|
<div v-if="openGroup === group.id" class="menu">
|
||||||
>
|
<button v-for="tag in group.tags" :key="tag.id" type="button" :class="{ selected: selectedTagIds.includes(tag.id) }" @click="selectTag(tag.id)"><span>{{ tag.name }}</span><span class="checkbox"><i v-if="selectedTagIds.includes(tag.id)" class="fa-solid fa-check"></i></span></button>
|
||||||
{{ label }}
|
</div>
|
||||||
<span class="chevron" :class="{ expanded: dropdownOpen }"></span>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
</div>
|
||||||
<Transition name="dropdown">
|
<div v-if="selectedGroups.length" class="selected-tags" :class="{ 'craft-only': selectedGroups.length === 1 && selectedGroups[0]?.label === '工艺' }">
|
||||||
<div v-if="dropdownOpen" class="dropdown-menu">
|
<div v-for="group in selectedGroups" :key="group.id" class="selected-tag">
|
||||||
<button
|
<span>{{ group.selectedTag?.name }}</span>
|
||||||
v-for="tag in tags"
|
<button type="button" class="selected-tag-remove" :aria-label="`取消${group.selectedTag?.name}筛选`" @click="emit('toggle-tag', group.selectedTag!.id)">
|
||||||
:key="tag.id"
|
<i class="fa-solid fa-xmark"></i>
|
||||||
type="button"
|
|
||||||
class="dropdown-item"
|
|
||||||
@click="toggleTag(tag.id)"
|
|
||||||
>
|
|
||||||
<span class="tag-label">{{ tag.name }}</span>
|
|
||||||
<span class="check-box" :class="{ checked: isSelected(tag.id) }">
|
|
||||||
<i v-if="isSelected(tag.id)" class="fa-solid fa-check text-[10px]"></i>
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filter-pill-wrapper {
|
.tag-filters { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; }.filter-controls, .selected-tags { display: flex; align-items: flex-start; gap: 12px; }.filter-group { display: flex; align-items: flex-start; }.dropdown { position: relative; }
|
||||||
position: relative;
|
.filter-button { display: flex; align-items: center; gap: 6px; height: 38px; padding: 0 16px; border: 1px solid #c7c7c7; border-radius: 60px; background: #fff; color: #000; font-size: 16px; cursor: pointer; }.filter-button i { color: #777; font-size: 11px; transition: transform .2s; }.filter-button.open i { transform: rotate(180deg); }
|
||||||
}
|
.menu { position: absolute; z-index: 40; top: 50px; left: -2px; width: 141px; padding: 6px; border-radius: 6px; background: #fff; box-shadow: 0 1px 5px rgba(0,0,0,.25); }
|
||||||
|
.menu button { display: flex; align-items: center; justify-content: space-between; width: 129px; height: 36px; padding: 6px; border: 0; background: #fff; color: #000; text-align: left; font-size: 16px; cursor: pointer; }.menu button:hover { background: #fafafa; }
|
||||||
.filter-pill {
|
.checkbox { display: grid; place-items: center; width: 18px; height: 18px; border: 1px solid #c7c7c7; border-radius: 4px; color: #fff; font-size: 11px; }.menu button.selected .checkbox { border-color: #ff6800; background: #ff6800; }
|
||||||
display: inline-flex;
|
.selected-tag { display: flex; align-items: center; justify-content: center; gap: 6px; height: 30px; padding: 0 16px; border-radius: 60px; background: #f2f2f2; color: #000; font-size: 16px; line-height: normal; white-space: nowrap; }.selected-tag-remove { display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; background: transparent; color: #000; font-size: 16px; cursor: pointer; }
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
height: 38px;
|
|
||||||
padding: 0 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
background: #fff;
|
|
||||||
font-size: 15px;
|
|
||||||
color: #222;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-pill:hover {
|
|
||||||
border-color: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-pill.active {
|
|
||||||
color: #ff6a00;
|
|
||||||
border-color: #ff6a00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-right: 1.8px solid currentColor;
|
|
||||||
border-bottom: 1.8px solid currentColor;
|
|
||||||
transform: rotate(45deg) translateY(-2px);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron.expanded {
|
|
||||||
transform: rotate(225deg) translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
left: 0;
|
|
||||||
min-width: 180px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 8px 28px rgba(17, 17, 17, 0.08);
|
|
||||||
padding: 6px 0;
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #555;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background: #f7f7f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-label {
|
|
||||||
flex: 1;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-box {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 1.5px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #fff;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-box.checked {
|
|
||||||
background: #ff6a00;
|
|
||||||
border-color: #ff6a00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-enter-active,
|
|
||||||
.dropdown-leave-active {
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-enter-from,
|
|
||||||
.dropdown-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ const COUNTRY_NAME_MAP: Record<string, string> = {
|
|||||||
'墨西哥工厂本地直发': '墨西哥',
|
'墨西哥工厂本地直发': '墨西哥',
|
||||||
'巴西本地工厂直发': '巴西',
|
'巴西本地工厂直发': '巴西',
|
||||||
'中东本地工厂直发': '中东',
|
'中东本地工厂直发': '中东',
|
||||||
'欧洲波兰工厂直发': '欧洲波兰',
|
'欧洲波兰工厂直发': '波兰',
|
||||||
'欧洲西班牙工厂直发': '欧洲西班牙',
|
'欧洲西班牙工厂直发': '西班牙',
|
||||||
'欧洲德国工厂本地直发': '欧洲德国',
|
'欧洲德国工厂本地直发': '德国',
|
||||||
'欧洲意大利工厂直发': '欧洲意大利',
|
'欧洲意大利工厂直发': '意大利',
|
||||||
'英国本地直发': '英国',
|
'英国本地直发': '英国',
|
||||||
'加拿大本地工厂直发': '加拿大',
|
'加拿大本地工厂直发': '加拿大',
|
||||||
'澳大利亚本地工厂直发': '澳大利亚',
|
'澳大利亚本地工厂直发': '澳大利亚',
|
||||||
|
|||||||
@@ -56,6 +56,64 @@ export interface ProductQuery {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveBackendAssetUrl(value: string | null, backendUrl: string): string | null {
|
||||||
|
if (!value || /^(?:https?:|data:|blob:)/i.test(value)) return value;
|
||||||
|
return new URL(value, `${backendUrl.replace(/\/$/, '')}/`).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCountryIdFromQuery(
|
||||||
|
value: string | string[] | null | undefined,
|
||||||
|
countries: Country[],
|
||||||
|
): string | undefined {
|
||||||
|
const countryId = Array.isArray(value) ? value[0] : value;
|
||||||
|
return countryId && countries.some((country) => country.id === countryId) ? countryId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCategoryIdFromQuery(
|
||||||
|
value: string | string[] | null | undefined,
|
||||||
|
categories: Category[],
|
||||||
|
): string | undefined {
|
||||||
|
const categoryId = Array.isArray(value) ? value[0] : value;
|
||||||
|
const containsId = (nodes: Category[]): boolean => nodes.some(
|
||||||
|
(category) => category.id === categoryId || containsId(category.children),
|
||||||
|
);
|
||||||
|
return categoryId && containsId(categories) ? categoryId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCategoryIdByName(name: string, categories: Category[]): string | undefined {
|
||||||
|
for (const category of categories) {
|
||||||
|
if (category.name === name) return category.id;
|
||||||
|
const childId = findCategoryIdByName(name, category.children);
|
||||||
|
if (childId) return childId;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCategoryIdForPodName(name: string, categories: Category[]): string | undefined {
|
||||||
|
const rootName = name.includes('男士')
|
||||||
|
? '男士服装'
|
||||||
|
: name.includes('女士')
|
||||||
|
? '女士服装'
|
||||||
|
: /童装|儿童/.test(name)
|
||||||
|
? '儿童服装'
|
||||||
|
: undefined;
|
||||||
|
const root = rootName ? categories.find((category) => category.name === rootName) : undefined;
|
||||||
|
if (!root) return findCategoryIdByName(name, categories);
|
||||||
|
|
||||||
|
const childName = name.includes('T恤')
|
||||||
|
? 'T恤'
|
||||||
|
: name.includes('裤')
|
||||||
|
? '裤装'
|
||||||
|
: name.includes('卫衣')
|
||||||
|
? '卫衣'
|
||||||
|
: name.includes('裙')
|
||||||
|
? '裙装'
|
||||||
|
: name.includes('内衣')
|
||||||
|
? '内衣'
|
||||||
|
: undefined;
|
||||||
|
return root.children.find((category) => category.name === childName)?.id ?? root.id;
|
||||||
|
}
|
||||||
|
|
||||||
interface BackendCategoryNode {
|
interface BackendCategoryNode {
|
||||||
id: string;
|
id: string;
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
@@ -111,21 +169,21 @@ interface BackendEnvelope<T> {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapCategory(node: BackendCategoryNode): Category {
|
function mapCategory(node: BackendCategoryNode, backendUrl: string): Category {
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
name: node.categoryName,
|
name: node.categoryName,
|
||||||
icon: node.categoryIcon,
|
icon: resolveBackendAssetUrl(node.categoryIcon, backendUrl),
|
||||||
parentId: node.parentCategoryId,
|
parentId: node.parentCategoryId,
|
||||||
children: node.children.map(mapCategory),
|
children: node.children.map((child) => mapCategory(child, backendUrl)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapCountry(c: BackendCountry): Country {
|
function mapCountry(c: BackendCountry, backendUrl: string): Country {
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.countryName,
|
name: c.countryName,
|
||||||
icon: c.countryIcon,
|
icon: resolveBackendAssetUrl(c.countryIcon, backendUrl),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,18 +218,41 @@ export function sortTagsByGroup(tags: Tag[]): Tag[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapProduct(p: BackendProduct): Product {
|
export function selectExclusiveTagIds(selectedIds: string[], tags: Tag[], tagId: string): string[] {
|
||||||
|
const selectedTag = tags.find((tag) => tag.id === tagId);
|
||||||
|
if (!selectedTag) return selectedIds;
|
||||||
|
if (selectedIds.includes(tagId)) return selectedIds.filter((id) => id !== tagId);
|
||||||
|
|
||||||
|
const selectedGroupId = selectedTag.group?.id;
|
||||||
|
const otherGroups = selectedIds.filter((id) => {
|
||||||
|
const tag = tags.find((item) => item.id === id);
|
||||||
|
return !selectedGroupId || tag?.group?.id !== selectedGroupId;
|
||||||
|
});
|
||||||
|
return [...otherGroups, tagId];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultProductFilters(
|
||||||
|
_categories: Category[],
|
||||||
|
_tags: Tag[],
|
||||||
|
): Pick<ProductQuery, 'categoryId' | 'tagIds'> {
|
||||||
|
return {
|
||||||
|
categoryId: undefined,
|
||||||
|
tagIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProduct(p: BackendProduct, backendUrl: string): Product {
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.goodName,
|
name: p.goodName,
|
||||||
priority: p.goodPriority,
|
priority: p.goodPriority,
|
||||||
image: p.image,
|
image: resolveBackendAssetUrl(p.image, backendUrl),
|
||||||
price: p.price,
|
price: p.price,
|
||||||
country: mapCountry(p.country),
|
country: mapCountry(p.country, backendUrl),
|
||||||
category: {
|
category: {
|
||||||
id: p.category.id,
|
id: p.category.id,
|
||||||
name: p.category.categoryName,
|
name: p.category.categoryName,
|
||||||
icon: p.category.categoryIcon,
|
icon: resolveBackendAssetUrl(p.category.categoryIcon, backendUrl),
|
||||||
parentId: null,
|
parentId: null,
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
@@ -181,6 +262,7 @@ function mapProduct(p: BackendProduct): Product {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useProductCenter() {
|
export function useProductCenter() {
|
||||||
|
const backendUrl = useRuntimeConfig().public.backendUrl;
|
||||||
const categories = useState<Category[]>('pc-categories', () => []);
|
const categories = useState<Category[]>('pc-categories', () => []);
|
||||||
const countries = useState<Country[]>('pc-countries', () => []);
|
const countries = useState<Country[]>('pc-countries', () => []);
|
||||||
const tags = useState<Tag[]>('pc-tags', () => []);
|
const tags = useState<Tag[]>('pc-tags', () => []);
|
||||||
@@ -216,15 +298,15 @@ export function useProductCenter() {
|
|||||||
$fetch<BackendEnvelope<BackendCategoryNode[]>>('/api/backend/categories'),
|
$fetch<BackendEnvelope<BackendCategoryNode[]>>('/api/backend/categories'),
|
||||||
$fetch<BackendEnvelope<BackendCountry[]>>('/api/backend/countries'),
|
$fetch<BackendEnvelope<BackendCountry[]>>('/api/backend/countries'),
|
||||||
]);
|
]);
|
||||||
categories.value = cats.data.map(mapCategory);
|
categories.value = cats.data.map((category) => mapCategory(category, backendUrl));
|
||||||
countries.value = cos.data.map(mapCountry);
|
countries.value = cos.data.map((country) => mapCountry(country, backendUrl));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load filters:', error);
|
console.error('Failed to load filters:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProducts(): Promise<void> {
|
async function fetchProducts(options: { ignoreTags?: boolean } = {}): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string | number> = {
|
const params: Record<string, string | number> = {
|
||||||
@@ -233,14 +315,16 @@ export function useProductCenter() {
|
|||||||
};
|
};
|
||||||
if (query.countryId) params.countryId = query.countryId;
|
if (query.countryId) params.countryId = query.countryId;
|
||||||
if (query.categoryId) params.categoryId = query.categoryId;
|
if (query.categoryId) params.categoryId = query.categoryId;
|
||||||
if (query.tagIds.length > 0) params.tagIds = query.tagIds.join(',');
|
if (!options.ignoreTags && query.tagIds.length > 0) {
|
||||||
|
params.tagIds = query.tagIds.join(',');
|
||||||
|
}
|
||||||
if (query.keyword?.trim()) params.keyword = query.keyword.trim();
|
if (query.keyword?.trim()) params.keyword = query.keyword.trim();
|
||||||
|
|
||||||
const response = await $fetch<BackendEnvelope<BackendPaginatedProducts>>(
|
const response = await $fetch<BackendEnvelope<BackendPaginatedProducts>>(
|
||||||
'/api/backend/goods',
|
'/api/backend/goods',
|
||||||
{ params },
|
{ params },
|
||||||
);
|
);
|
||||||
products.value = response.data.items.map(mapProduct);
|
products.value = response.data.items.map((product) => mapProduct(product, backendUrl));
|
||||||
total.value = response.data.total;
|
total.value = response.data.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch products:', error);
|
console.error('Failed to fetch products:', error);
|
||||||
@@ -262,12 +346,7 @@ export function useProductCenter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setTag(id: string): void {
|
function setTag(id: string): void {
|
||||||
const idx = query.tagIds.indexOf(id);
|
query.tagIds = selectExclusiveTagIds(query.tagIds, tags.value, id);
|
||||||
if (idx >= 0) {
|
|
||||||
query.tagIds.splice(idx, 1);
|
|
||||||
} else {
|
|
||||||
query.tagIds.push(id);
|
|
||||||
}
|
|
||||||
query.page = 1;
|
query.page = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,387 +1,68 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Tag, Country } from '~/composables/useProductCenter';
|
import { resolveCategoryIdFromQuery, resolveCountryIdFromQuery, resolveDefaultProductFilters } from '~/composables/useProductCenter';
|
||||||
import { sortTagsByGroup } from '~/composables/useProductCenter';
|
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({ title: '产品中心 | InkReach', description: 'InkReach 产品中心,支持全球 POD 商品分类、筛选、搜索与分页浏览。' });
|
||||||
title: '产品中心 | InkReach',
|
|
||||||
description:
|
|
||||||
'InkReach 产品中心:按品类和国家筛选全球 POD 定制商品,支持关键词搜索、分页浏览与多维筛选。',
|
|
||||||
});
|
|
||||||
|
|
||||||
const stickyTopRef = ref<HTMLElement | null>(null);
|
const { categories, countries, tags, tagGroups, products, total, loading, query, loadFilters, fetchProducts, setCategory, setCountry, setTag, clearTags, setKeyword, setPage, setPageSize } = useProductCenter();
|
||||||
const stickySpacerHeight = ref(152);
|
const keyword = shallowRef('');
|
||||||
|
const mobileSidebarOpen = shallowRef(false);
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
const route = useRoute();
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!stickyTopRef.value) return;
|
|
||||||
const updateHeight = () => {
|
|
||||||
if (stickyTopRef.value) {
|
|
||||||
stickySpacerHeight.value = stickyTopRef.value.offsetHeight;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
updateHeight();
|
|
||||||
resizeObserver = new ResizeObserver(updateHeight);
|
|
||||||
resizeObserver.observe(stickyTopRef.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
resizeObserver?.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
categories,
|
|
||||||
countries,
|
|
||||||
tags,
|
|
||||||
products,
|
|
||||||
total,
|
|
||||||
loading,
|
|
||||||
query,
|
|
||||||
loadFilters,
|
|
||||||
fetchProducts,
|
|
||||||
setCategory,
|
|
||||||
setCountry,
|
|
||||||
setTag,
|
|
||||||
clearTags,
|
|
||||||
setKeyword,
|
|
||||||
setPage,
|
|
||||||
setPageSize,
|
|
||||||
} = useProductCenter();
|
|
||||||
|
|
||||||
const searchKeyword = ref('');
|
|
||||||
|
|
||||||
await loadFilters();
|
await loadFilters();
|
||||||
|
const defaults = resolveDefaultProductFilters(categories.value, tags.value);
|
||||||
|
query.categoryId = resolveCategoryIdFromQuery(route.query.categoryId, categories.value) ?? defaults.categoryId;
|
||||||
|
query.tagIds = defaults.tagIds;
|
||||||
|
query.countryId = resolveCountryIdFromQuery(route.query.countryId, countries.value);
|
||||||
|
query.pageSize = 20;
|
||||||
await fetchProducts();
|
await fetchProducts();
|
||||||
|
|
||||||
watch(
|
watch(() => [query.countryId, query.categoryId, [...query.tagIds], query.keyword, query.page, query.pageSize], () => fetchProducts());
|
||||||
() => [
|
|
||||||
query.countryId,
|
|
||||||
query.categoryId,
|
|
||||||
[...query.tagIds],
|
|
||||||
query.keyword,
|
|
||||||
query.page,
|
|
||||||
query.pageSize,
|
|
||||||
],
|
|
||||||
() => {
|
|
||||||
fetchProducts();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function onSidebarSelect(id: string | null): void {
|
function chooseCategory(id: string | null): void { setCategory(id); mobileSidebarOpen.value = false; }
|
||||||
setCategory(id);
|
function search(): void { setKeyword(keyword.value); }
|
||||||
}
|
function clearAll(): void { setCategory(null); setCountry(null); clearTags(); keyword.value = ''; setKeyword(''); }
|
||||||
|
|
||||||
function onCountrySelect(id: string | null): void {
|
|
||||||
setCountry(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onToggleTag(id: string): void {
|
|
||||||
setTag(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSearch(): void {
|
|
||||||
setKeyword(searchKeyword.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPageChange(page: number): void {
|
|
||||||
setPage(page);
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPageSizeChange(size: number): void {
|
|
||||||
setPageSize(size);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAllFilters(): void {
|
|
||||||
setCategory(null);
|
|
||||||
setCountry(null);
|
|
||||||
clearTags();
|
|
||||||
searchKeyword.value = '';
|
|
||||||
setKeyword('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeTags = computed(() => {
|
|
||||||
const list = query.tagIds
|
|
||||||
.map((id) => tags.value.find((t: Tag) => t.id === id))
|
|
||||||
.filter(Boolean) as Tag[];
|
|
||||||
return sortTagsByGroup(list);
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeCountryName = computed(() => {
|
|
||||||
if (!query.countryId) return null;
|
|
||||||
return countries.value.find((c: Country) => c.id === query.countryId)?.name ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const showCountry = computed(() => !query.countryId);
|
|
||||||
|
|
||||||
const hasActiveFilters = computed(() => {
|
|
||||||
return query.tagIds.length > 0 || Boolean(query.countryId || query.keyword);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="product-page">
|
<div class="page">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
<button class="mobile-filter-trigger" type="button" @click="mobileSidebarOpen = true"><i class="fa-solid fa-sliders"></i>商品分类</button>
|
||||||
|
<aside class="sidebar-shell" :class="{ open: mobileSidebarOpen }">
|
||||||
|
<button class="drawer-close" type="button" aria-label="关闭分类" @click="mobileSidebarOpen = false"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
<ProductSidebar :categories="categories" :active-category-id="query.categoryId ?? null" @update:active-category-id="chooseCategory" />
|
||||||
|
<!-- <img class="promo" src="/product-promo-banner.png" alt="印美达全球 POD 供应链" /> -->
|
||||||
|
</aside>
|
||||||
|
<button v-if="mobileSidebarOpen" class="backdrop" type="button" aria-label="关闭分类" @click="mobileSidebarOpen = false"></button>
|
||||||
|
|
||||||
<main class="content">
|
<main class="main">
|
||||||
<aside class="sidebar-area">
|
<section class="filters">
|
||||||
<ProductSidebar
|
<div class="countries"><ProductCountryFilter :countries="countries" :active-country-id="query.countryId ?? null" @update:active-country-id="setCountry" /></div>
|
||||||
:categories="categories"
|
<div class="filter-tools">
|
||||||
:active-category-id="query.categoryId ?? null"
|
<ProductTagFilter :tags="tags" :tag-groups="tagGroups" :selected-tag-ids="query.tagIds" @toggle-tag="setTag" />
|
||||||
@update:active-category-id="onSidebarSelect"
|
<ProductFilterBar v-model:keyword="keyword" @search="search" />
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="main-area">
|
|
||||||
<div ref="stickyTopRef" class="sticky-top">
|
|
||||||
<div class="country-section">
|
|
||||||
<ProductCountryFilter
|
|
||||||
:countries="countries"
|
|
||||||
:active-country-id="query.countryId ?? null"
|
|
||||||
@update:active-country-id="onCountrySelect"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-section">
|
|
||||||
<div class="filter-left">
|
|
||||||
<ProductTagFilter
|
|
||||||
:tags="tags"
|
|
||||||
:selected-tag-ids="query.tagIds"
|
|
||||||
@toggle-tag="onToggleTag"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="selected-tags">
|
|
||||||
<span
|
|
||||||
v-for="tag in activeTags"
|
|
||||||
:key="'tag-' + tag.id"
|
|
||||||
class="sel-tag"
|
|
||||||
:style="{ color: tag.fontColor || tag.color || '#ff6a00', background: (tag.color || '#ff6a00') + '4d', borderColor: (tag.color || '#ff6a00') + '99' }"
|
|
||||||
>
|
|
||||||
{{ tag.name }}
|
|
||||||
<button type="button" class="tag-close" @click="onToggleTag(tag.id)"></button>
|
|
||||||
</span>
|
|
||||||
<span v-if="query.keyword" class="sel-tag">
|
|
||||||
"{{ query.keyword }}"
|
|
||||||
<button type="button" class="tag-close" @click="searchKeyword = ''; onSearch()"></button>
|
|
||||||
</span>
|
|
||||||
<span v-if="activeCountryName" class="sel-tag">
|
|
||||||
{{ activeCountryName }}
|
|
||||||
<button type="button" class="tag-close" @click="onCountrySelect(null)"></button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-right">
|
|
||||||
<ProductFilterBar
|
|
||||||
v-model:keyword="searchKeyword"
|
|
||||||
@search="onSearch"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sticky-spacer" :style="{ height: stickySpacerHeight + 'px' }"></div>
|
|
||||||
|
|
||||||
<ProductGrid :products="products" :loading="loading" :show-country="showCountry" />
|
|
||||||
|
|
||||||
<div v-if="!loading && products.length === 0 && hasActiveFilters" class="clear-all-area">
|
|
||||||
<button type="button" class="clear-all-btn" @click="clearAllFilters">
|
|
||||||
清空筛选
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProductPagination
|
|
||||||
v-if="!loading && total > 0"
|
|
||||||
:total="total"
|
|
||||||
:page="query.page"
|
|
||||||
:page-size="query.pageSize"
|
|
||||||
@update:page="onPageChange"
|
|
||||||
@update:page-size="onPageSizeChange"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
<section class="catalog"><ProductGrid :products="products" :loading="loading" :show-country="!query.countryId" @clear-filters="clearAll" @view-all="clearAll" /></section>
|
||||||
|
<ProductPagination v-if="!loading && total" :total="total" :page="query.page" :page-size="query.pageSize" @update:page="setPage" @update:page-size="setPageSize" />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.product-page {
|
.page { --page-gutter: clamp(24px, 3vw, 56px); min-height: 100vh; padding-top: 80px; background: #fff; color: #000; }
|
||||||
width: 100%;
|
.sidebar-shell { position: fixed; z-index: 30; top: 80px; bottom: 0; left: var(--page-gutter); width: 240px; padding: 0 12px; border-right: 1px solid #e5e7eb; background: #fff; overflow-y: auto; scrollbar-width: none; }
|
||||||
min-height: 100vh;
|
.sidebar-shell::-webkit-scrollbar { display: none; }
|
||||||
background: #fff;
|
/* Banner is temporarily hidden because expanded category trees can overlap it. */
|
||||||
padding-top: 80px;
|
.promo { position: absolute; top: 752px; left: 12px; width: 216px; height: 60px; border-radius: 28px; object-fit: cover; }
|
||||||
}
|
.main { min-width: 0; margin-right: var(--page-gutter); margin-left: calc(240px + var(--page-gutter)); }.filters { position: sticky; z-index: 20; top: 80px; min-width: 0; background: #fff; }.countries { position: relative; min-width: 0; max-width: 100%; padding: 12px; overflow: visible; }.filter-tools { position: relative; display: flex; justify-content: space-between; min-height: 60px; padding: 12px; }
|
||||||
|
.countries::after, .filter-tools::after { position: absolute; right: calc(-1 * var(--page-gutter)); bottom: 0; left: 0; height: 1px; background: #e5e7eb; content: ''; pointer-events: none; }
|
||||||
.content {
|
.catalog { padding: 24px; }.drawer-close,.mobile-filter-trigger,.backdrop { display: none; }
|
||||||
display: flex;
|
@media (min-width: 1440px) { .catalog { padding-left: 24px; }.countries { padding-right: 12px; } }
|
||||||
}
|
@media (max-width: 1000px) {
|
||||||
|
.mobile-filter-trigger { position: fixed; z-index: 25; top: 92px; left: 16px; display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border: 1px solid #c7c7c7; border-radius: 60px; background: #fff; }
|
||||||
.sidebar-area {
|
.sidebar-shell { z-index: 80; top: 0; left: 0; width: 240px; transform: translateX(-100%); transition: transform .2s; }.sidebar-shell.open { transform: translateX(0); }.drawer-close { display: grid; place-items: center; width: 36px; height: 36px; margin: 10px 0 0 auto; border: 0; background: transparent; font-size: 20px; }.backdrop { position: fixed; z-index: 70; inset: 0; display: block; border: 0; background: rgba(0,0,0,.36); }
|
||||||
position: fixed;
|
.main { margin-right: 0; margin-left: 0; }.filters { position: relative; top: 0; }.countries { padding-top: 62px; }.filter-tools { gap: 12px; flex-wrap: wrap; }.countries::after, .filter-tools::after { right: 0; }
|
||||||
left: 0;
|
|
||||||
top: 80px;
|
|
||||||
bottom: 0;
|
|
||||||
width: 240px;
|
|
||||||
border-right: 1px solid #eee;
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-area::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-area {
|
|
||||||
min-width: 0;
|
|
||||||
background: #fff;
|
|
||||||
width: 100%;
|
|
||||||
margin-left: 240px;
|
|
||||||
padding: 0 32px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sticky-spacer {
|
|
||||||
height: 152px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sticky-top {
|
|
||||||
position: fixed;
|
|
||||||
left: 240px;
|
|
||||||
right: 0;
|
|
||||||
top: 80px;
|
|
||||||
z-index: 10;
|
|
||||||
background: #fff;
|
|
||||||
padding: 20px 32px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.country-section {
|
|
||||||
padding: 0 0 14px;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-left {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-tags {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid;
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-close {
|
|
||||||
position: relative;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-close:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-close::before,
|
|
||||||
.tag-close::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 6px;
|
|
||||||
top: 1px;
|
|
||||||
width: 1.5px;
|
|
||||||
height: 12px;
|
|
||||||
background: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-close::before {
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-close::after {
|
|
||||||
transform: rotate(-45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-right {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-all-area {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-all-btn {
|
|
||||||
height: 44px;
|
|
||||||
padding: 0 26px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #ff6a00;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-all-btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.sidebar-area {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
top: 0;
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-area {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sticky-top {
|
|
||||||
position: relative;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sticky-spacer {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-section {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-right {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@media (max-width: 760px) { .countries { min-height: auto; }.catalog { padding: 16px; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "nuxt dev --host",
|
"dev": "nuxt dev --host",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
|
"test": "vitest",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -18,5 +19,11 @@
|
|||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
"vue-router": "^5.0.7"
|
"vue-router": "^5.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
|
"happy-dom": "^20.10.6",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 830 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 880 KiB |
|
After Width: | Height: | Size: 664 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 140 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="logo-æ©™">
|
||||||
|
<g id="Rectangle 1">
|
||||||
|
</g>
|
||||||
|
<path id="Vector" d="M27.84 27.4H30.36C30.8241 27.4 31.2 27.7655 31.2 28.2168V33.7579C31.2 34.8852 30.2592 35.8 29.1 35.8C27.9408 35.8 27 34.8852 27 33.7579V28.2168C27 27.7655 27.3759 27.4 27.84 27.4Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_2" d="M44.36 19.8H41.84C41.3761 19.8 41 20.1581 41 20.5998V40.0002C41 40.4419 41.3761 40.8 41.84 40.8H44.36C44.8239 40.8 45.2 40.4419 45.2 40.0002V20.5998C45.2 20.1581 44.8239 19.8 44.36 19.8Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_3" d="M85.6307 36.981C76.5464 45.1314 66.2036 48.3663 62.528 44.2067C61.0848 42.5736 60.8852 40.0431 61.7237 37.078C59.1392 41.8692 58.5846 46.1915 60.6997 48.5856C64.3753 52.7452 74.7182 49.5103 83.8024 41.3599C89.3193 36.41 93.1023 30.7851 94.4 26.2C92.4945 29.733 89.4858 33.5217 85.6307 36.981Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_4" d="M24.5645 22.4H22.2355C21.7741 22.4 21.4 22.7554 21.4 23.1937V25.4063C21.4 25.8446 21.7741 26.2 22.2355 26.2H24.5645C25.0259 26.2 25.4 25.8446 25.4 25.4063V23.1937C25.4 22.7554 25.0259 22.4 24.5645 22.4Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_5" d="M24.5645 27.4H22.2355C21.7741 27.4 21.4 27.7598 21.4 28.2037V39.7963C21.4 40.2402 21.7741 40.6 22.2355 40.6H24.5645C25.0259 40.6 25.4 40.2402 25.4 39.7963V28.2037C25.4 27.7598 25.0259 27.4 24.5645 27.4Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_6" d="M38.5726 40.8H36.0906C35.6335 40.8 35.2632 40.4367 35.2632 39.9881V33.5336C35.2632 32.4375 34.3966 31.5028 33.2796 31.4612C32.1058 31.4176 31.1368 32.3421 31.1368 33.4838V39.9587C31.1368 40.4072 30.7665 40.7706 30.3094 40.7706H27.8274C27.3702 40.7706 27 40.4072 27 39.9587V33.5762C27 30.2373 29.7013 27.4515 33.1038 27.4007C36.5663 27.349 39.4 30.0982 39.4 33.4838V39.9881C39.4 40.4367 39.0298 40.8 38.5726 40.8Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_7" d="M107.16 19.8H109.44C109.86 19.8 110.2 20.1548 110.2 20.5929V32.6177C110.2 33.7119 109.349 34.6 108.3 34.6C107.251 34.6 106.4 33.7119 106.4 32.6177V20.5929C106.4 20.1548 106.74 19.8 107.16 19.8Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_8" d="M117.599 40.8H115.197C114.755 40.8 114.397 40.4367 114.397 39.9881V33.5336C114.397 32.4375 113.558 31.5028 112.477 31.4612C111.341 31.4176 110.403 32.3421 110.403 33.4838V39.9587C110.403 40.4072 110.045 40.7706 109.603 40.7706H107.201C106.758 40.7706 106.4 40.4072 106.4 39.9587V33.5762C106.4 30.2373 109.014 27.4515 112.307 27.4007C115.658 27.349 118.4 30.0982 118.4 33.4838V39.9881C118.4 40.4367 118.042 40.8 117.599 40.8Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_9" d="M55.88 27.4H58.8368C59.147 27.4 59.4 27.6322 59.4 27.9192V39.9887C59.4 40.4369 59.0062 40.8 58.52 40.8H55.88C55.3938 40.8 55 40.4369 55 39.9887V28.2123C55 27.7641 55.3938 27.401 55.88 27.401V27.4Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_10" d="M41.2145 32.2104C41.0773 32.0585 41 31.8604 41 31.6541V28.2211C41 27.7674 41.364 27.4 41.8133 27.4H45.0667V29.6908C45.0667 29.9124 45.1551 30.1259 45.3127 30.2809C47.0522 31.9795 49.1333 30.752 49.1333 29.0421V28.2211C49.1333 27.7674 49.4973 27.4 49.9467 27.4H52.3867C52.836 27.4 53.2 27.7674 53.2 28.2211V29.0421C53.2 34.3441 46.5276 38.1025 41.2145 32.2104Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_11" d="M52.3867 40.8H49.9467C49.4973 40.8 49.1333 40.442 49.1333 40V37.2489C49.1333 36.1659 48.2783 35.2418 47.1783 35.2008C46.0213 35.1578 45.0667 36.0709 45.0667 37.1999V40C45.0667 40.442 44.7027 40.8 44.2533 40.8H41.8133C41.364 40.8 41 40.442 41 40V37.2909C41 33.9978 43.6586 31.2507 47.0054 31.2007C50.4123 31.1497 53.2 33.8608 53.2 37.1999V40C53.2 40.442 52.836 40.8 52.3867 40.8Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_12" d="M59.0789 35.2457V33.4974C59.0789 32.3532 60.0341 31.4266 61.1912 31.4703C61.1912 31.4703 61.1912 31.4703 61.1922 31.4703C61.2717 31.4734 61.3471 31.4398 61.392 31.3747C62.1026 30.3505 62.8896 29.3274 63.7459 28.3133C63.8428 28.1984 63.8051 28.0235 63.6685 27.9604C62.859 27.5871 61.9537 27.3837 61.0005 27.401C57.6537 27.461 55 30.2488 55 33.5879V39.9863C55 40.4359 55.365 40.8 55.8156 40.8H57.2387C57.4803 39.0862 58.0951 37.2199 59.0778 35.2447L59.0789 35.2457Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_13" d="M68.1372 36.5053L72.7809 34.6331L76.1462 33.2752C76.3562 33.1901 76.4555 32.956 76.3685 32.7518C76.015 31.9253 75.5643 30.7925 75.0838 30.1811C73.832 28.456 71.7862 27.3973 69.5673 27.4003H69.5694C65.4348 27.3642 62.1864 31.1257 62.8984 35.0974C63.0797 36.1791 63.5478 37.2118 64.2455 38.0704H64.2444C66.7369 41.1735 71.6705 41.4386 74.8738 38.7719C75.0449 38.6298 75.0633 38.3766 74.9179 38.2095L73.0237 36.035C72.8824 35.8729 72.6375 35.8499 72.4634 35.977C71.0589 37.0007 69.6104 37.2378 68.1383 36.5064L68.1372 36.5053ZM66.7123 33.5544C66.8772 31.3959 69.678 30.2392 71.3508 31.6861C70.1605 32.1664 67.9006 33.0781 66.7123 33.5574V33.5544Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<g id="Group">
|
||||||
|
<path id="Vector_14" d="M100.211 27.5073C97.183 27.5073 94.5329 29.5412 93.7485 32.4659C92.9652 35.3906 94.2431 38.4774 96.8658 39.9915C99.2149 41.3474 102.117 41.1386 104.236 39.5415C104.633 39.2426 104.679 38.6659 104.329 38.3153L102.853 36.8398C102.585 36.5722 102.166 36.5216 101.849 36.7273C101.028 37.2583 99.9861 37.3657 99.0578 36.9816C97.9319 36.5155 97.1982 35.4169 97.1982 34.1978C97.1982 32.9787 97.9319 31.8801 99.0578 31.414C99.985 31.0299 101.028 31.1373 101.849 31.6683C102.166 31.8741 102.585 31.8234 102.853 31.5558L104.33 30.0793C104.679 29.7297 104.635 29.1531 104.24 28.8551C103.088 27.9826 101.673 27.5042 100.211 27.5083" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
</g>
|
||||||
|
<g id="Group_2">
|
||||||
|
<path id="Vector_15" d="M90.7529 29.4166V27.9268C90.7529 27.7029 90.5715 27.5215 90.3476 27.5215H87.3073C87.0834 27.5215 86.902 27.7029 86.902 27.9268V28.1184C85.9869 27.6887 84.9603 27.4597 83.8759 27.4891C80.3432 27.5863 77.4651 30.4715 77.3759 34.0043C77.3131 36.5195 78.6396 38.7298 80.6411 39.9236C81.6687 39.1412 82.6862 38.3001 83.6824 37.4053C83.8273 37.2745 83.9712 37.1438 84.1141 37.0131C84.0968 37.0131 84.0796 37.0141 84.0624 37.0141C82.3903 37.0141 81.0516 35.5608 81.2421 33.8512C81.387 32.549 82.4359 31.5011 83.7381 31.3552C85.4477 31.1647 86.9009 32.5034 86.9009 34.1755C86.9009 34.1968 86.8999 34.2181 86.8989 34.2384C88.3603 32.6584 89.6564 31.035 90.7519 29.4145L90.7529 29.4166Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_16" d="M86.902 40.1303V40.7677H90.3475C90.5715 40.7677 90.7529 40.5863 90.7529 40.3624V35.9115C89.4264 37.4265 88.4758 38.7176 86.902 40.1303Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
</g>
|
||||||
|
<path id="Vector_17" d="M70.4667 22.021C79.2819 13.4468 89.9062 10.072 94.1967 14.4849C96.0664 16.4085 96.4238 19.4945 95.5021 23.0742C97.6625 18.2454 97.8502 13.9487 95.5505 11.5837C91.4458 7.36174 80.9729 10.891 72.1567 19.4652C69.2664 22.2765 66.9132 25.2817 65.2 28.2C66.6557 26.1026 68.4224 24.0093 70.4667 22.021Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 97 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Frame" clip-path="url(#clip0_0_30)">
|
||||||
|
<g id="Group">
|
||||||
|
<path id="Vector" d="M16.318 51.2332V53.8323C17.2443 53.6294 18.0909 53.4188 18.8525 53.1979L19.0969 54.0481C17.9416 54.3665 16.7117 54.6542 15.4072 54.9085L15.0341 54.1354C15.2683 54.0044 15.3866 53.8169 15.3866 53.5678V47.6478C16.632 47.4526 17.7486 47.1957 18.7341 46.8747L19.3105 47.617C18.4614 47.8918 17.463 48.1383 16.3155 48.3592V50.342H19.0171V51.2306H16.318V51.2332ZM21.4666 54.6131L21.2119 53.7142L22.0842 53.7347C22.3389 53.7347 22.465 53.5935 22.465 53.3135V48.2513H20.5378V55.9307H19.5986V47.373H23.4067V53.5087C23.4067 54.2458 23.0671 54.6131 22.3878 54.6131H21.4666Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
<path id="Vector_2" d="M34.4941 52.1013C34.4606 52.2965 34.4221 52.4788 34.3757 52.6483H38.0474V53.5267H34.8183C35.4847 54.2895 36.7275 54.8057 38.5466 55.0805L38.0577 55.9589C35.9633 55.548 34.5893 54.8032 33.9358 53.7219C33.7325 54.0737 33.4855 54.3794 33.1922 54.6414C32.5463 55.209 31.4142 55.6584 29.7958 55.9897L29.415 55.0805C30.8173 54.834 31.8233 54.477 32.4305 54.0147C32.6209 53.858 32.783 53.6962 32.9194 53.5267H29.8755V52.6483H33.398C33.4572 52.4583 33.5086 52.2451 33.5549 52.0037L34.4941 52.1013ZM29.9733 47.8224H32.078C31.9468 47.5219 31.8079 47.2548 31.6561 47.0211L32.6338 46.8644C32.7522 47.111 32.8808 47.432 33.0249 47.8224H34.9135C35.0627 47.4962 35.1785 47.1675 35.2557 46.8362L36.254 46.962C36.1691 47.2548 36.0508 47.5425 35.9015 47.8224H37.9368V48.6623H34.4272V49.4045H37.4222V50.2058H34.4272V50.9584H38.2636V51.8188H29.6491V50.9584H33.4855V50.2058H30.5188V49.4045H33.4855V48.6623H29.9707V47.8224H29.9733Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
<path id="Vector_3" d="M50.8993 55.756C50.5597 55.756 49.9525 55.7483 49.0776 55.7355C48.419 55.7278 47.876 55.6738 47.4541 55.5685C47.0372 55.4504 46.677 55.2244 46.3786 54.8853C46.2422 54.7235 46.111 54.6414 45.9875 54.6414C45.779 54.6414 45.4188 55.0934 44.9119 56L44.2275 55.3656C44.7241 54.5592 45.1692 54.0506 45.5681 53.8426V51.097H44.2378V50.1981H46.4609V53.9094C46.5123 53.9479 46.5612 53.9967 46.6076 54.0558C46.8623 54.3152 47.1093 54.5078 47.3512 54.6311C47.6522 54.7672 48.1076 54.8468 48.7226 54.8648C49.4919 54.8776 50.1763 54.8853 50.7784 54.8853L52.1395 54.8751C52.5898 54.8545 52.9449 54.8365 53.2073 54.816L52.9835 55.7534H50.8993V55.756ZM45.0174 47.0006C45.6838 47.5091 46.2473 48.0125 46.7105 48.5159L46.0261 49.199C45.635 48.7111 45.0869 48.182 44.3819 47.617L45.0174 47.0006ZM47.2096 49.0039H49.2938C49.3452 48.5338 49.3761 48.0561 49.3813 47.5682V46.9132H50.3101V47.3627C50.3101 47.9354 50.2818 48.4825 50.2226 49.0039H52.5718V49.9028H50.3899C50.7167 51.1073 51.5426 52.3221 52.8651 53.5472L52.2193 54.2304C51.0511 53.0644 50.2689 51.9189 49.8701 50.7914C49.4199 52.3221 48.6274 53.4676 47.4927 54.2304L46.924 53.4676C48.0664 52.7382 48.8101 51.5491 49.1548 49.9028H47.2071V49.0039H47.2096Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
<path id="Vector_4" d="M63.6535 46.8953C64.8808 48.1589 66.2908 49.1528 67.8809 49.8745L67.4203 50.7041C66.9186 50.4627 66.4606 50.2135 66.0489 49.9516V50.8299H63.7873V52.2965H66.5378V53.1954H63.7873V54.739H67.4281V55.6481H59.215V54.739H62.8455V53.1954H60.0847V52.2965H62.8455V50.8299H60.5839V49.9516C60.167 50.2058 59.6962 50.4627 59.1738 50.7246L58.7236 49.913C60.4269 49.0989 61.8446 48.0921 62.9819 46.8953H63.6535ZM66.0309 49.9413C65.012 49.3172 64.1089 48.5801 63.319 47.7325C62.5882 48.5339 61.6851 49.271 60.607 49.9413H66.0309Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
<path id="Vector_5" d="M73.6859 50.4319H74.7229V48.3618H73.5779V47.4911H76.7401V48.3618H75.6646V50.4319H76.5368V51.3102H75.6646V53.031C76.0505 52.8615 76.4082 52.6946 76.7401 52.5328V53.4317C75.8061 53.894 74.7769 54.2972 73.6473 54.6439L73.4132 53.7245C73.9355 53.6063 74.3729 53.4933 74.7254 53.3829V51.3128H73.6885V50.4319H73.6859ZM76.8276 48.5159H78.9812V46.9235H79.8715V48.5159H81.2017C80.9341 48.2102 80.5893 47.8815 80.1648 47.5296L80.6922 47.0211C81.2017 47.4064 81.616 47.7762 81.935 48.1358L81.5645 48.5159H82.1126V49.4045H79.8715V50.2752C80.0027 50.6861 80.1519 51.0688 80.3217 51.4284C80.7077 50.9789 81.0628 50.4832 81.3895 49.9439L82.0842 50.4627C81.634 51.1664 81.1811 51.7494 80.7231 52.2117C81.2069 53.0464 81.7986 53.7758 82.5036 54.3999L81.8861 55.1319C81.037 54.2972 80.3655 53.3212 79.8689 52.2014V54.9085C79.8689 55.5274 79.5498 55.8356 78.9092 55.8356H77.8851L77.6895 54.9573C78.0549 55.0035 78.3585 55.0266 78.6004 55.0266C78.8551 55.0266 78.9812 54.9136 78.9812 54.685V52.6714C78.3817 53.5113 77.687 54.2381 76.8971 54.8494L76.4082 54.0481C77.3679 53.4163 78.2247 52.543 78.9812 51.4309V49.4097H76.8276V48.5159ZM77.512 50.0209C77.9546 50.6733 78.2942 51.228 78.5309 51.6929L77.8851 52.1424C77.6098 51.6338 77.2496 51.0765 76.7993 50.4729L77.512 50.0209Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
</g>
|
||||||
|
<path id="Vector_6" d="M11.1801 50.889H0.165039V51.4386H11.1801V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
<path id="Vector_7" d="M97 50.889H85.7508V51.5671H97V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
|
<g id="Group_2">
|
||||||
|
<path id="Vector_8" d="M64.2864 27.2064C55.1033 35.3686 44.6466 38.6072 40.9337 34.4414C39.4748 32.8054 39.2741 30.273 40.1207 27.304C37.5091 32.1016 36.9481 36.4293 39.0863 38.8281C42.8017 42.994 53.2585 39.7527 62.439 31.5931C68.0173 26.6362 71.8408 21.0039 73.1504 16.4117C71.2232 19.9508 68.1845 23.7443 64.2864 27.2064Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_9" d="M3.07218 12.536H0.810499C0.362795 12.536 0 12.8982 0 13.3451V15.6026C0 16.0495 0.362795 16.4117 0.810499 16.4117H3.07218C3.51988 16.4117 3.88268 16.0495 3.88268 15.6026V13.3451C3.88268 12.8982 3.51988 12.536 3.07218 12.536Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_10" d="M3.07218 17.6804H0.810499C0.362795 17.6804 0 18.0451 0 18.492V30.1702C0 30.6171 0.362795 30.9793 0.810499 30.9793H3.07218C3.51988 30.9793 3.88268 30.6171 3.88268 30.1702V18.492C3.88268 18.0451 3.51988 17.6804 3.07218 17.6804Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_11" d="M11.6889 17.6907C10.9711 17.701 10.2841 17.8397 9.64853 18.0811C9.50701 17.8422 9.24714 17.6804 8.94867 17.6804H6.5146C6.0669 17.6804 5.7041 18.0426 5.7041 18.4895V23.847V23.9806V30.2113C5.7041 30.6582 6.0669 31.0204 6.5146 31.0204H8.94867C9.39637 31.0204 9.75917 30.6582 9.75917 30.2113V23.9831V23.7571C9.75917 22.6193 10.7086 21.6973 11.8587 21.741C12.9523 21.7821 13.8039 22.7144 13.8039 23.8085V30.2447C13.8039 30.6916 14.1667 31.0538 14.6144 31.0538H17.0485C17.4962 31.0538 17.859 30.6916 17.859 30.2447V23.7571C17.8616 20.3797 15.0853 17.6393 11.6889 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_12" d="M90.8296 17.6907C90.1606 17.701 89.5199 17.8217 88.9204 18.0349V10.6303C88.9204 10.1835 88.5576 9.82132 88.1099 9.82132H85.6758C85.2281 9.82132 84.8653 10.1835 84.8653 10.6303V22.907C84.8653 22.9969 84.873 23.0842 84.8833 23.169C84.8576 23.3924 84.8447 23.6184 84.8447 23.847V30.2113C84.8447 30.6582 85.2075 31.0204 85.6552 31.0204H88.0893C88.537 31.0204 88.8998 30.6582 88.8998 30.2113V23.7571C88.8998 22.6193 89.8492 21.6973 90.9994 21.741C92.0929 21.7821 92.9446 22.7144 92.9446 23.8085V30.2447C92.9446 30.6916 93.3074 31.0538 93.7551 31.0538H96.1891C96.6368 31.0538 96.9996 30.6916 96.9996 30.2447V23.7571C96.9996 20.3797 94.2234 17.6393 90.8296 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_13" d="M31.0412 17.7909H28.6072C28.1595 17.7909 27.7967 18.153 27.7967 18.5999V19.4089C27.7967 20.4748 26.9656 21.3506 25.9184 21.4276C25.8386 21.4251 25.7563 21.4251 25.6765 21.4251C25.6559 21.4251 25.6379 21.4276 25.6173 21.4276C25.0898 21.3891 24.5238 21.1477 23.9886 20.6314C23.8316 20.4799 23.7416 20.2693 23.7416 20.051V10.6303C23.7416 10.1835 23.3788 9.82132 22.9311 9.82132H20.497C20.0493 9.82132 19.6865 10.1835 19.6865 10.6303V18.5999V21.985V27.5865V30.3269C19.6865 30.7738 20.0493 31.1359 20.497 31.1359H22.9311C23.3788 31.1359 23.7416 30.7738 23.7416 30.3269V30.2653V27.494C23.7416 26.4307 24.5701 25.5549 25.6147 25.4779C25.7125 25.4805 25.8103 25.4805 25.9081 25.4779C26.9759 25.5524 27.7967 26.4693 27.7967 27.5454V30.3295C27.7967 30.7764 28.1595 31.1385 28.6072 31.1385H31.0412C31.4889 31.1385 31.8517 30.7764 31.8517 30.3295V27.4966C31.8517 25.9453 31.2651 24.5302 30.3028 23.454C31.2651 22.3805 31.8517 20.9628 31.8517 19.4089V18.5999C31.8517 18.153 31.4889 17.7909 31.0412 17.7909Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_14" d="M39.8324 21.7744C39.8761 21.7769 39.9173 21.7821 39.9585 21.7846C40.7355 20.6546 41.6052 19.5271 42.5598 18.4124C41.6927 17.9604 40.7046 17.7087 39.66 17.7267C38.9807 17.7369 38.3323 17.8628 37.7277 18.0785C37.6633 17.8628 37.4678 17.7061 37.2311 17.7061H34.5062C34.0585 17.7061 33.6957 18.0682 33.6957 18.5151V23.4181C33.6855 23.5722 33.6777 23.7263 33.6777 23.883V30.2473C33.6777 30.4759 33.7729 30.6839 33.9273 30.8303C34.074 30.9818 34.2798 31.0769 34.5088 31.0769H36.9429C37.3906 31.0769 37.7534 30.7147 37.7534 30.2678V23.5285C37.8846 22.514 38.7723 21.7358 39.8324 21.7744Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_15" d="M41.5516 25.4805C41.7291 26.5746 42.1948 27.6199 42.8844 28.4854H42.8818C45.3493 31.6239 50.2304 31.891 53.4003 29.1943C53.5701 29.0504 53.5881 28.7936 53.444 28.6241L51.5709 26.4256C51.432 26.2612 51.1875 26.2381 51.0151 26.3665C49.6257 27.4016 48.1925 27.643 46.7362 26.9008L51.3316 25.0079L54.6611 23.6338C54.8695 23.5491 54.9673 23.3102 54.8798 23.1048C54.5299 22.2675 54.0847 21.1246 53.6087 20.5056C52.3685 18.7617 50.3461 17.6907 48.1488 17.6933H48.1514C44.0628 17.6624 40.8466 21.4662 41.5516 25.4805ZM45.3262 23.9215C45.4909 21.7384 48.262 20.5698 49.9164 22.0312C48.738 22.5166 46.5021 23.4386 45.3262 23.9241V23.9215Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_16" d="M77.6686 21.6125C78.5975 21.2299 79.6396 21.3352 80.4604 21.8668C80.7768 22.0723 81.1988 22.0209 81.4664 21.7538L82.9433 20.2796C83.2932 19.9303 83.2495 19.355 82.8533 19.0571C81.7006 18.1864 80.2854 17.7087 78.8213 17.7112C75.7929 17.7112 73.1401 19.7428 72.3579 22.663C71.5732 25.5832 72.852 28.6652 75.4764 30.1779C77.8256 31.5315 80.7305 31.3234 82.8507 29.7285C83.2469 29.4306 83.2932 28.8553 82.9433 28.5034L81.4664 27.0292C81.1988 26.7621 80.7794 26.7107 80.4604 26.9162C79.6396 27.4478 78.5975 27.5531 77.6686 27.1704C76.5417 26.7056 75.8084 25.6089 75.8084 24.3915C75.8084 23.1767 76.5417 22.0774 77.6686 21.6125Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_17" d="M69.361 19.617V18.1299C69.361 17.9064 69.1783 17.7241 68.9545 17.7241H65.9132C65.6893 17.7241 65.5067 17.9064 65.5067 18.1299V18.3225C64.5907 17.8936 63.564 17.665 62.4808 17.6933C58.9481 17.7909 56.0663 20.67 55.9788 24.1989C55.9145 26.7107 57.2421 28.9169 59.2465 30.1086C60.2757 29.3278 61.2921 28.488 62.2904 27.5942C62.4345 27.4632 62.5786 27.3322 62.7227 27.2012C62.7047 27.2012 62.6892 27.2038 62.6712 27.2038C60.9987 27.2038 59.6582 25.7527 59.8512 24.0448C59.9953 22.7452 61.0451 21.6973 62.347 21.5535C64.0581 21.3634 65.5118 22.699 65.5118 24.3684C65.5118 24.3889 65.5118 24.4095 65.5092 24.4326C66.9681 22.8556 68.2649 21.235 69.361 19.617Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_18" d="M65.5089 30.3166V30.9536H68.9567C69.1806 30.9536 69.3633 30.7712 69.3633 30.5478V26.102C68.0356 27.6173 67.0836 28.9066 65.5089 30.3166Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_19" d="M48.9692 12.2458C57.8229 3.65217 68.4906 0.272244 72.7979 4.69492C74.6762 6.62374 75.0338 9.71602 74.1101 13.304C76.2791 8.46524 76.467 4.15814 74.159 1.78756C70.037 -2.44249 59.5185 1.09411 50.6674 9.6852C47.765 12.5027 45.4004 15.5153 43.6816 18.4381C45.1431 16.3372 46.9185 14.2389 48.9692 12.2458Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_0_30">
|
||||||
|
<rect width="97" height="56" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 260 260" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="空状态">
|
||||||
|
<path id="Vector" d="M63.2429 46.2863C66.6064 46.0297 72.4699 46.2271 76.0334 46.229L100.679 46.2312L179.31 46.1991C181.817 46.1973 183.448 46.1838 185.784 47.1709C190.411 49.1269 193.222 53.3572 193.263 58.3786C193.286 61.0849 193.291 63.8983 193.287 66.66L193.275 84.8904L193.272 151.471C194.464 152.047 195.627 152.679 196.756 153.367C204.403 158.078 209.871 165.63 211.962 174.366C214.268 183.999 212.345 192.628 207.287 200.961C210.206 203.976 211.385 202.484 212.055 203.141C216.671 207.656 226.381 215.924 229.346 221.07C230.887 223.745 224.655 233.138 218.501 228.727C213.169 223.519 207.679 217.986 202.47 212.633C202.048 212.2 202.301 210.899 202.406 210.361L200.552 208.472C196.181 211.91 191.993 214.121 186.529 215.388C183.078 216.202 179.518 216.441 175.99 216.097C174.378 215.925 172.149 215.514 170.586 215.504C163.979 215.454 157.359 215.474 150.752 215.474L92.6955 215.467L74.3909 215.476C69.3064 215.485 62.2801 216.256 57.9473 213.511C55.2466 211.775 53.346 209.037 52.6642 205.9C52.3085 204.204 52.3902 200.545 52.3926 198.674L52.4063 188.395L52.4051 149.978L52.4108 87.7877L52.3985 68.4416C52.3949 64.9306 52.2549 61.2389 52.4661 57.7453C52.8458 51.4634 57.3365 47.379 63.2429 46.2863Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
<path id="Vector_2" d="M64.5305 48.4078C64.8713 48.3956 65.2121 48.3881 65.5532 48.3852L146.291 48.3909L170.31 48.3911C174.273 48.3847 178.998 48.1609 182.783 48.5504C188.007 49.0879 191.688 54.3209 191.425 59.3651C191.299 61.8093 191.45 64.5657 191.446 67.0171L191.422 85.4932L191.45 150.657C183.885 147.769 175.545 147.638 167.892 150.286C159.376 153.252 152.395 159.492 148.501 167.62C144.544 175.624 143.98 184.883 146.941 193.306C150.539 203.019 156.28 208.936 165.559 213.409C157.099 213.288 148.455 213.38 139.983 213.38H93.5428L73.7357 213.39C70.3212 213.392 66.5217 213.525 63.1807 213.229C58.9313 212.753 55.0321 208.865 54.6873 204.599C54.4426 201.571 54.5356 198.449 54.5399 195.44L54.5622 180.744L54.5506 134.099L54.5328 83.1416L54.5346 67.1703C54.5356 64.2388 54.4587 61.2967 54.6135 58.3698C54.9168 52.6375 58.9566 48.9309 64.5305 48.4078Z" fill="var(--fill-0, #FDFDFD)"/>
|
||||||
|
<path id="Vector_3" d="M108.323 66.088C109.2 66.1253 112.237 68.6559 113.395 69.2556C118.817 72.0631 127.634 72.4062 132.96 69.1682C134.008 68.5317 136.41 66.3098 137.445 66.1875C138.226 66.0953 154.944 72.0837 156.729 72.9098C158.048 73.5363 159.282 74.3287 160.4 75.268C161.117 75.8686 162.005 76.7251 162.581 77.4663C167.102 83.2899 171.639 89.1149 176.067 95.0066C176.449 95.5136 176.658 96.0473 176.716 96.6721C176.015 97.8918 160.854 112.526 159.309 113.418C157.83 113.35 155.165 109.939 153.959 108.699L153.969 137.873C153.969 139.875 154.23 156.182 153.563 156.997C152.026 157.757 145.456 157.455 143.306 157.441L111.317 157.453C108.471 157.457 93.4802 157.789 92.2281 156.954C91.6966 154.954 91.8954 144.37 91.8949 141.668L91.8893 108.707C90.7898 109.919 88.1342 113.242 86.6755 113.439C86.146 113.164 85.4861 112.732 85.0318 112.278C79.8859 107.133 74.2377 102.296 69.3625 96.9225C68.6522 96.1395 71.5067 92.7719 72.0739 92.0767C76.4274 86.7415 80.3461 80.7282 85.007 75.6891C87.4031 73.0985 92.4277 71.6931 95.6614 70.5499L108.323 66.088Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
<path id="Vector_4" d="M103.686 70.028C105.455 71.0903 106.199 81.5705 118.223 84.7356C123.21 86.0417 128.515 85.2924 132.942 82.6569C137.655 79.8712 140.847 75.2394 142.194 69.988C146.898 71.7448 152.06 73.2293 156.596 75.275C159.724 76.6866 163.257 81.8113 165.357 84.5115L174.55 96.4075C169.363 101.281 164.229 106.209 159.15 111.192L154.017 105.677C154.021 103.665 154.49 99.2772 153.317 97.9335C151.373 97.7715 151.941 102.724 151.942 103.889L151.944 114.167L151.949 149.525L151.947 155.355L94.0001 155.348L94.0017 113.551L93.9882 104.19C94.0018 102.581 94.5554 99.0958 93.2053 98.1509C92.5371 98.164 92.8475 98.0818 92.2514 98.4482C91.5906 99.3537 91.8704 104.238 91.8796 105.732C90.1911 107.518 88.5202 109.322 86.8672 111.141C81.8995 106.705 76.3593 101.085 71.4164 96.407C74.6542 92.38 77.6961 88.1589 80.9067 84.1102C83.2681 81.1323 85.6515 77.2422 89.0593 75.4033C90.9333 74.392 93.5202 73.6256 95.5548 72.9173L103.686 70.028Z" fill="var(--fill-0, #FDFDFD)"/>
|
||||||
|
<path id="Vector_5" d="M138.063 68.688L140.316 69.561C140.078 70.5564 139.739 71.4129 139.382 72.3704C139.173 72.8481 138.945 73.3176 138.699 73.7779C136.404 78.1041 132.872 81.2699 128.104 82.7033C123.781 83.9973 119.116 83.5295 115.143 81.4036C110.33 78.8124 107.617 74.5953 106.083 69.5107L108.076 68.7486C108.288 68.9403 108.293 68.8966 108.385 69.1466C110.273 74.236 113.178 78.3408 118.627 80.09C122.374 81.3083 126.457 80.9811 129.958 79.1824C134.481 76.8666 136.597 73.3024 138.063 68.688Z" fill="var(--fill-0, #FDFDFD)"/>
|
||||||
|
<path id="Vector_6" d="M111.283 70.5909C112.865 71.2139 113.914 71.9903 115.866 72.5357C123.012 74.5316 128.717 73.8549 135.116 70.4213C134.418 71.7459 134.042 72.3751 133.182 73.5956C131.995 75.0701 130.84 76.1541 129.185 77.0734C126.228 78.7484 122.726 79.1104 119.5 78.0748C115.525 76.8214 113.167 74.2609 111.283 70.5909Z" fill="var(--fill-0, #FDFDFD)"/>
|
||||||
|
<path id="Vector_7" d="M69.4044 175.416C82.7754 175.147 96.8527 175.386 110.282 175.382L121.036 175.366C122.609 175.361 125.782 175.167 127.184 175.535C129.172 176.055 129.03 178.402 127.134 179.478C114.278 179.787 100.401 179.5 87.4608 179.503L75.5429 179.514C73.7291 179.521 69.2091 180.083 68.2271 178.426C67.9432 177.947 67.8733 177.364 68.0355 176.828C68.2662 176.062 68.7797 175.777 69.4044 175.416Z" fill="var(--fill-0, #A7A6A6)"/>
|
||||||
|
<path id="Vector_8" d="M69.9164 190.173C73.1296 190.101 94.8028 189.781 96.3471 190.37C96.7895 190.539 97.1371 190.955 97.2934 191.36C97.5027 191.895 97.4427 192.487 97.1298 192.978C96.7369 193.588 96.2231 193.695 95.533 193.824C92.2294 193.842 70.4603 194.125 69.1663 193.634C68.6536 193.438 68.2136 193.028 68.0472 192.536C67.8632 191.991 67.9373 191.406 68.2898 190.924C68.6885 190.38 69.2619 190.268 69.9164 190.173Z" fill="var(--fill-0, #CDCDCC)"/>
|
||||||
|
<path id="Vector_9" d="M116.679 190.097C119.774 190.038 122.926 189.974 126.017 190.073C128.647 190.158 129.716 192.494 127.146 193.782C125.127 193.896 123.049 193.873 120.98 193.884C119.331 193.892 114.826 194.357 115.206 191.753C115.338 190.846 115.89 190.51 116.679 190.097Z" fill="var(--fill-0, #CDCDCC)"/>
|
||||||
|
<path id="Vector_10" d="M105.377 190.068C106.045 190.034 107.048 189.932 107.645 190.207C109.758 191.184 109.563 193.314 107.268 193.887C106.685 193.909 105.516 193.998 105 193.757C102.842 192.756 103.003 190.671 105.377 190.068Z" fill="var(--fill-0, #CDCDCC)"/>
|
||||||
|
<path id="Vector_11" d="M177.721 150.185C195.307 149.423 210.177 163.071 210.921 180.658C211.664 198.245 198.002 213.101 180.413 213.827C162.851 214.552 148.022 200.913 147.279 183.351C146.535 165.79 160.16 150.946 177.721 150.185Z" fill="var(--fill-0, #FDFDFD)"/>
|
||||||
|
<path id="Vector_12" d="M175.656 155.116C190.633 153.332 204.216 164.032 205.991 179.009C207.764 193.988 197.057 207.565 182.079 209.331C167.113 211.095 153.548 200.397 151.775 185.432C150.004 170.467 160.693 156.895 175.656 155.116Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
<path id="Vector_13" d="M178.473 156.663C192.471 156.317 204.098 167.385 204.442 181.383C204.786 195.381 193.714 207.007 179.716 207.347C165.722 207.689 154.099 196.621 153.757 182.626C153.414 168.632 164.478 157.008 178.473 156.663Z" fill="var(--fill-0, #FDFDFD)"/>
|
||||||
|
<path id="Vector_14" d="M210.712 204.755C211.917 205.272 223.837 217.464 225.997 219.42C226.887 220.227 227.497 221.307 227.408 222.602C227.288 224.367 225.554 225.677 224.321 226.817C223.332 227.733 222.377 227.801 221.097 227.671C218.26 226.587 210.594 217.338 207.683 215.121C206.92 214.541 204.725 212.226 204.016 211.453C205.997 209.328 208.62 206.817 210.712 204.755Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
<path id="Vector_15" d="M206.194 202.588C206.664 202.922 207.863 203.999 208.35 204.416C207.004 205.782 205.298 207.317 203.878 208.655C203.419 208.39 202.296 207.342 201.85 206.947C203.495 205.552 204.78 204.158 206.194 202.588Z" fill="var(--fill-0, #FDFDFD)"/>
|
||||||
|
<path id="Vector_16" d="M229.865 109.855L230.759 109.915C231.667 110.412 232.805 114.541 233.561 116.107C235.599 117.353 237.194 117.668 239.285 118.652C239.374 118.787 239.463 118.923 239.55 119.058L239.454 119.475C238.45 120.656 235.431 121.414 233.522 122.554C232.371 124.598 231.827 127.785 231.031 128.921L230.147 128.828C229.302 128.322 228.126 124.078 227.441 122.566C227.386 122.535 227.333 122.503 227.278 122.472L227.006 122.321C225.023 121.203 223.021 120.794 221.566 119.949C221.288 119.499 221.327 119.732 221.414 119.232C222.279 118.085 225.514 117.173 227.439 115.996C228.653 113.846 228.919 111.575 229.865 109.855Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
<path id="Vector_17" d="M230.427 113.755C230.613 114.021 231.924 116.873 232.254 117.511C233.616 118.379 234.637 118.859 236.083 119.548C235.554 119.743 235.032 119.954 234.519 120.182C231.761 121.387 231.491 122.797 230.444 125.455C229.855 123.919 229.362 122.74 228.538 121.326C227.418 120.679 225.992 120.044 224.816 119.5C228.832 117.705 228.76 117.68 230.427 113.755Z" fill="var(--fill-0, #FDFDFD)"/>
|
||||||
|
<path id="Vector_18" d="M20.6652 165.343C23.5077 165.088 26.0328 167.077 26.3225 169.799C26.612 172.52 24.5576 174.958 21.7202 175.257C18.8489 175.562 16.2699 173.565 15.9769 170.811C15.6839 168.057 17.7894 165.601 20.6652 165.343Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_19" d="M20.6174 167.094C22.4647 166.825 24.207 167.977 24.5552 169.699C24.9034 171.422 23.7319 173.092 21.9075 173.476C20.6723 173.735 19.3844 173.343 18.5478 172.452C17.7114 171.562 17.4592 170.314 17.8901 169.198C18.321 168.082 19.3665 167.276 20.6174 167.094Z" fill="var(--fill-0, #FDFDFD)"/>
|
||||||
|
<path id="Vector_20" d="M201.304 24.0879C203.918 23.7729 206.281 25.7314 206.584 28.4636C206.887 31.1958 205.016 33.6681 202.404 33.9872C199.788 34.3067 197.42 32.3475 197.115 29.6124C196.812 26.8774 198.689 24.4032 201.304 24.0879Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_21" d="M201.403 25.8238C203.036 25.5636 204.563 26.7496 204.839 28.493C205.117 30.2362 204.042 31.8967 202.421 32.2297C201.343 32.4515 200.236 32.0307 199.529 31.1297C198.82 30.2286 198.623 28.9883 199.014 27.8874C199.403 26.7864 200.316 25.9969 201.403 25.8238Z" fill="var(--fill-0, #FDFDFD)"/>
|
||||||
|
<path id="Vector_22" d="M98.0306 226.519C99.3123 226.472 103.947 226.43 104.924 226.495C108.398 226.727 129.764 226.058 131.137 226.732C131.274 227.263 131.236 227.093 131 227.665C125.384 227.824 119.182 227.583 113.506 227.687C108.748 227.773 103.724 227.64 98.941 227.68C98.5193 227.684 98.2831 227.626 97.9692 227.411C97.7848 226.895 97.8204 227.087 98.0306 226.519Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
<path id="Vector_23" d="M128.792 233.909C131.259 233.784 151.55 233.609 152.38 234.243C152.529 234.762 152.417 234.933 152.251 235.445C151.388 235.461 148.861 235.505 148.166 235.447C145.409 235.219 129.783 235.923 128.758 235.141C128.531 234.492 128.603 234.691 128.792 233.909Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
<path id="Vector_24" d="M30.465 32.7214C32.4441 32.7595 37.1616 36.7524 39.1174 37.7854C39.9501 38.2253 40.9096 38.9279 41.083 39.8707C40.9491 40.4143 40.7181 40.5581 40.3093 40.9547C38.5618 40.9622 32.9576 36.4666 31.1908 35.5331C29.7722 34.7837 29.3191 34.0644 30.465 32.7214Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
<path id="Vector_25" d="M241.059 176.708C242.189 176.392 243.402 176.71 244.23 177.539C245.06 178.369 245.378 179.581 245.064 180.71C244.749 181.841 243.85 182.714 242.71 182.993C240.986 183.418 239.242 182.38 238.791 180.664C238.339 178.947 239.351 177.185 241.059 176.708Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
|
<path id="Vector_26" d="M218.987 71.4006C220.697 70.9402 222.46 71.9324 222.953 73.6322C223.447 75.3321 222.49 77.1143 220.8 77.6409C219.677 77.9906 218.452 77.7065 217.598 76.898C216.745 76.0893 216.394 74.8824 216.682 73.7422C216.97 72.602 217.852 71.7064 218.987 71.4006Z" fill="var(--fill-0, #CDCDCC)"/>
|
||||||
|
<path id="Vector_27" d="M17.1511 98.7579C18.7443 98.1847 20.4689 99.1093 20.9959 100.819C21.523 102.529 20.6513 104.372 19.0528 104.928C17.4655 105.48 15.76 104.555 15.2368 102.857C14.7135 101.159 15.569 99.3271 17.1511 98.7579Z" fill="var(--fill-0, #CDCDCC)"/>
|
||||||
|
<path id="Vector_28" d="M44.5972 23.6214C44.8409 23.6813 45.5729 23.8691 45.665 24.0086C46.5563 25.3585 48.7479 32.0605 48.883 33.3871C48.7668 33.9451 48.4731 34.085 48.0107 34.4547C47.7398 34.4113 47.0273 34.297 46.9205 34.1274C45.9953 32.6597 43.8683 26.1212 43.683 24.7795C43.8179 24.1771 44.1088 24.0304 44.5972 23.6214Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
<path id="Vector_29" d="M27.8099 47.1507C29.9031 47.1135 35.9869 46.6632 37.3838 47.6964C37.8665 48.6459 37.5752 49.0571 36.72 49.5386C34.5615 49.5373 28.4815 49.9566 27.0212 48.92C26.7101 48.3604 26.7686 48.652 26.8389 48.0143C27.3284 47.3103 27.0699 47.4971 27.8099 47.1507Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
<path id="Vector_30" d="M72.0079 226.529C73.4588 226.536 77.7491 226.1 78.6744 226.933C78.8427 227.603 78.8206 227.253 78.5715 228.024C77.0957 228.062 73.0695 228.46 71.9958 227.648C71.7705 227.049 71.8307 227.258 72.0079 226.529Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
<path id="Vector_31" d="M85.4352 226.54C86.1422 226.534 86.849 226.513 87.5552 226.48C88.5936 226.44 90.5096 226.237 91.2597 226.926C91.4087 227.579 91.3735 227.356 91.1482 228.07C89.699 228.096 86.4819 228.394 85.4766 227.692C85.1958 227.065 85.2552 227.319 85.4352 226.54Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
<path id="Vector_32" d="M159.123 233.956C160.06 233.902 160.999 233.851 161.937 233.809C162.81 233.773 163.831 233.701 164.491 234.434C164.643 235.241 164.609 234.96 164.372 235.844C163.062 235.867 159.982 236.277 159.077 235.358C158.873 234.597 158.933 234.846 159.123 233.956Z" fill="var(--fill-0, #3E3D41)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 371 KiB |
@@ -1,5 +1,8 @@
|
|||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const query = getQuery(event);
|
const requestUrl = event.node.req.url ?? '/';
|
||||||
|
const query = Object.fromEntries(
|
||||||
|
new URL(requestUrl, 'http://localhost').searchParams.entries(),
|
||||||
|
);
|
||||||
return $fetch(`${config.public.backendUrl}/public/goods`, { query });
|
return $fetch(`${config.public.backendUrl}/public/goods`, { query });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('清除搜索');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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恤');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([
|
||||||
|
'烫画',
|
||||||
|
'直喷',
|
||||||
|
'不打印',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { config } from '@vue/test-utils';
|
||||||
|
|
||||||
|
config.global.stubs = {
|
||||||
|
NuxtLink: { template: '<a><slot /></a>' },
|
||||||
|
};
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~': fileURLToPath(new URL('./app', import.meta.url)),
|
||||||
|
'@': fileURLToPath(new URL('./app', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./test/setup.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
# Teable 权限矩阵 UI 交互说明
|
||||||
|
|
||||||
|
> 页面:`https://teable.inkreach.cc/base/bseVtXyL4aojvUDwFhn/authority-matrix`
|
||||||
|
> 梳理日期:2026-07-29
|
||||||
|
> 梳理方式:使用管理员账号在真实页面进行黑盒交互验证。未创建或保存测试角色,页面最终恢复为初始的“权限矩阵关闭”状态。
|
||||||
|
|
||||||
|
## 1. 页面定位与整体结构
|
||||||
|
|
||||||
|
权限矩阵用于为当前数据库配置基于角色的访问控制。页面主内容从上到下分为:
|
||||||
|
|
||||||
|
1. 标题区:`权限矩阵`、总开关、顶部 `添加角色`。
|
||||||
|
2. 管理员区:管理员规则说明、`查看`、`添加用户`、管理员用户列表。
|
||||||
|
3. 自定义角色区:说明文案、`默认角色` 下拉框、角色列表或空状态。
|
||||||
|
4. 角色编辑器:添加或编辑角色时替换权限矩阵主内容区,不以模态框呈现。
|
||||||
|
|
||||||
|
页面仍保留数据库左侧导航和右侧 AI 助手,因此权限矩阵主内容区不是全屏页面。
|
||||||
|
|
||||||
|
### 1.1 页面外壳布局
|
||||||
|
|
||||||
|
本次桌面端实测视口为 `929 × 869 px`,页面采用横向三段式应用外壳:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────┬──────────────────────────────────────────────┬────┐
|
||||||
|
│ 数据库左侧导航 │ 权限矩阵主工作区 │ AI │
|
||||||
|
│ 约 288 px │ 自适应填充剩余宽度 │栏 │
|
||||||
|
│ │ │约 │
|
||||||
|
│ 空间名称、搜索 │ 权限矩阵主页 / 角色编辑器 │40px│
|
||||||
|
│ 权限矩阵、邀请、更多 │ │ │
|
||||||
|
│ 创建、表格树 │ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 当前用户、通知 │ │ │
|
||||||
|
└──────────────────────┴──────────────────────────────────────────────┴────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 左侧数据库导航为固定宽度栏,与权限矩阵业务内容之间使用竖向分隔线。
|
||||||
|
- 主工作区占据中间剩余空间,是权限矩阵主页和角色编辑器共用的内容容器。
|
||||||
|
- 右侧 AI 助手在当前宽度下折叠为窄图标栏;此前更宽状态下可展开成完整对话面板。
|
||||||
|
- 页面外壳占满视口高度。左侧用户信息和通知入口固定在导航底部,不随中间内容滚动。
|
||||||
|
- 权限矩阵的浮层、下拉框和禁用确认框覆盖在主工作区上方,不改变三段式外壳宽度。
|
||||||
|
|
||||||
|
### 1.2 权限矩阵主页布局
|
||||||
|
|
||||||
|
主页采用单列内容流,主内容在工作区内保留约 `32 px` 左右内边距:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 权限矩阵 [总开关] [+ 添加角色] │
|
||||||
|
│ │
|
||||||
|
│ 管理员 │
|
||||||
|
│ 说明文字 查看 │
|
||||||
|
│ [添加用户] [管理员标签……] │
|
||||||
|
│ │
|
||||||
|
│ 自定义角色 默认角色 [无权限 ▾] │
|
||||||
|
│ 说明文字 │
|
||||||
|
│ │
|
||||||
|
│ 空状态插图 │
|
||||||
|
│ 尚未设置角色 │
|
||||||
|
│ 辅助说明 │
|
||||||
|
│ [+ 添加角色] │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 顶部标题和总开关位于同一行左侧,主操作 `添加角色` 右对齐。
|
||||||
|
- `管理员` 和 `自定义角色` 是纵向排列的两个业务区块,不使用独立卡片包裹,主要依靠标题、留白和对齐形成层级。
|
||||||
|
- 管理员说明、`查看`、添加用户按钮和用户标签组成紧凑信息组。
|
||||||
|
- 自定义角色区标题与说明靠左,`默认角色` 标签和下拉框在同一行靠右,形成左右对照布局。
|
||||||
|
- 无角色时,空状态在剩余内容区域水平居中;插图、标题、说明和按钮纵向居中排列。
|
||||||
|
- 空状态会占用大量垂直留白,使页面重点落在“创建第一个角色”操作上。
|
||||||
|
|
||||||
|
### 1.3 角色编辑器整体布局
|
||||||
|
|
||||||
|
点击 `添加角色` 后,左侧数据库导航和右侧 AI 栏保持不变,中间主页替换为角色编辑器:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ [返回] [角色名称输入框] │
|
||||||
|
│ 角色规则说明备注 │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 左侧表格导航 │ 右侧权限详情 │ │
|
||||||
|
│ │ 搜索表格 │ 表格权限 │ │
|
||||||
|
│ │ │ 视图权限 │ │
|
||||||
|
│ │ 表格 / 分组树 │ 记录权限 │ │
|
||||||
|
│ │ 权限摘要 │ 字段权限矩阵 │ │
|
||||||
|
│ │ │ 导入 / 导出权限 │ │
|
||||||
|
│ ├──────────────────────────┴───────────────────────────────┤ │
|
||||||
|
│ │ [取消] [保存] [保存并返回] │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 编辑器顶部是角色元信息区:返回图标和角色名称输入框横向排列,规则说明位于下一行。
|
||||||
|
- 权限编辑主体使用带边框和圆角的双栏容器。
|
||||||
|
- 在本次视口下,双栏容器宽约 `536 px`:左栏约 `228 px`,右栏约 `308 px`。尺寸为截图估算值,不代表实现中的固定像素约束。
|
||||||
|
- 左栏为表格导航,顶部固定搜索框,下方是可展开的表格树和当前权限摘要。
|
||||||
|
- 右栏为所选表格的权限详情。选择 `无权限` 时内容较短;选择 `可编辑` 后纵向展开多个权限分区。
|
||||||
|
- 左右栏之间使用竖向分隔线;各权限分区之间使用横向分隔线,形成清晰的二维结构。
|
||||||
|
- 底部操作栏横跨双栏,按钮整体右对齐,`保存并返回` 使用深色主按钮,`取消` 和 `保存` 使用次级按钮。
|
||||||
|
|
||||||
|
### 1.4 编辑器滚动与固定区域
|
||||||
|
|
||||||
|
- 编辑器外层高度受当前视口约束,不会随着字段数量无限向下撑开页面。
|
||||||
|
- 左侧表格树与右侧权限详情位于操作栏上方的可用高度内。
|
||||||
|
- 右侧权限详情为主要滚动区域。字段权限矩阵和导入/导出权限位于下方,需要在详情区内纵向滚动查看。
|
||||||
|
- 底部 `取消 / 保存 / 保存并返回` 操作栏保持可见,不随右侧权限内容滚走。
|
||||||
|
- 左侧表格导航在滚动右侧详情时保持位置,便于随时切换表格。
|
||||||
|
- 字段权限本身是四列矩阵。在较窄工作区中,字段名列与三个操作列必须保持清晰对应;若字段名过长,需要截断或提示策略,当前数据未触发明显横向溢出。
|
||||||
|
|
||||||
|
### 1.5 视觉层级与密度
|
||||||
|
|
||||||
|
- 页面以白色为主背景,左侧当前导航项、选中表格行使用浅灰底色强调。
|
||||||
|
- 深色填充只用于高优先级动作,例如顶部 `添加角色`、空状态 `添加角色` 和 `保存并返回`。
|
||||||
|
- 标题层级依次为页面标题、区块标题、权限分区标题和辅助标签;字号与字重的差异比颜色差异更明显。
|
||||||
|
- 主页密度较低,使用大面积留白突出空状态;角色编辑器密度明显提高,以分栏、分隔线、复选框和矩阵承载配置任务。
|
||||||
|
- 权限详情中的相关控件紧密分组,不同权限域之间通过更大的上下间距和分隔线区分。
|
||||||
|
|
||||||
|
### 1.6 状态变化对布局的影响
|
||||||
|
|
||||||
|
| 状态变化 | 布局变化 |
|
||||||
|
| --- | --- |
|
||||||
|
| 权限矩阵关闭 → 开启 | 管理员标签直接进入管理员区,`添加用户` 启用;页面整体结构不变 |
|
||||||
|
| 无角色 → 有角色 | 预计空状态区域替换为角色列表;本次未创建角色,因此列表布局待验证 |
|
||||||
|
| 主页 → 添加角色 | 中间工作区整体切换为角色编辑器,左右应用外壳不变 |
|
||||||
|
| 表格无权限 → 可编辑 | 右栏从单个权限区扩展为长内容区,并启用内部滚动 |
|
||||||
|
| 所有视图 → 特定视图 | 在当前视图单选项下方原位插入视图复选列表,后续区块整体下移 |
|
||||||
|
| 分组收起 → 展开 | 左栏在分组节点下原位插入二级子表,右栏当前选择不变 |
|
||||||
|
| 搜索表格 | 左栏树被过滤,右栏继续显示当前所选表格的权限 |
|
||||||
|
| 禁用权限矩阵 | 中央确认框覆盖主工作区,背景内容留在原位 |
|
||||||
|
|
||||||
|
### 1.7 响应式范围说明
|
||||||
|
|
||||||
|
- 以上布局基于 `929 × 869 px` 桌面视口实测。
|
||||||
|
- 当前宽度下右侧 AI 助手已经折叠,但左侧数据库导航仍完整保留,说明系统优先压缩辅助面板。
|
||||||
|
- 本次未切换到平板或手机宽度,因此尚不能确认左侧导航是否抽屉化、角色编辑双栏是否改为上下堆叠、字段矩阵是否启用横向滚动。
|
||||||
|
- 若复刻该页面,建议至少验证 `1440 px`、`1024 px`、`768 px` 和 `375 px` 四档宽度,重点检查双栏编辑器和字段权限矩阵。
|
||||||
|
|
||||||
|
## 2. 权限矩阵总开关
|
||||||
|
|
||||||
|
### 2.1 关闭状态
|
||||||
|
|
||||||
|
- 开关为关闭状态。
|
||||||
|
- `添加角色` 仍可点击,允许提前定义角色规则。
|
||||||
|
- `添加用户` 为禁用状态。
|
||||||
|
- 管理员列表不在主区域直接展示;`查看` 仍可展开管理员信息。
|
||||||
|
- 默认角色控件和已有角色配置仍可见。
|
||||||
|
- 当前无自定义角色时展示空状态:
|
||||||
|
- 插图替代文本:`No roles available`。
|
||||||
|
- 标题:`尚未设置角色`。
|
||||||
|
- 说明:`创建你的第一个角色,开启灵活高效的权限管理`。
|
||||||
|
- 空状态内提供第二个 `添加角色` 入口。
|
||||||
|
|
||||||
|
关闭权限矩阵的产品语义由确认弹窗明确说明:协作者将能够查看所有数据。
|
||||||
|
|
||||||
|
### 2.2 开启
|
||||||
|
|
||||||
|
- 点击关闭状态的开关后立即开启,没有二次确认。
|
||||||
|
- 开关开启后,管理员用户直接显示在管理员区。
|
||||||
|
- `添加用户` 从禁用变为可用。
|
||||||
|
- 其余角色配置继续保留。
|
||||||
|
|
||||||
|
### 2.3 关闭
|
||||||
|
|
||||||
|
点击开启状态的开关不会立即关闭,而是显示确认弹窗:
|
||||||
|
|
||||||
|
- 标题:`禁用权限矩阵`。
|
||||||
|
- 风险提示:`协作者将能够查看所有数据,是否继续?`
|
||||||
|
- 操作:`取消`、`确认`、右上角 `Close`。
|
||||||
|
- `取消`、`Close` 应关闭弹窗并维持开启状态。
|
||||||
|
- `确认` 关闭权限矩阵,随后 `添加用户` 重新变为禁用。
|
||||||
|
|
||||||
|
因此,总开关存在有意的不对称交互:开启即时生效,关闭需要风险确认。
|
||||||
|
|
||||||
|
## 3. 管理员区
|
||||||
|
|
||||||
|
说明文案为:`空间可管理角色的用户可以管理权限矩阵,且不受其限制。`
|
||||||
|
|
||||||
|
这表示管理员是权限矩阵的豁免主体;角色规则不限制拥有空间角色管理能力的用户。
|
||||||
|
|
||||||
|
### 3.1 查看管理员
|
||||||
|
|
||||||
|
- `查看` 是可展开按钮。
|
||||||
|
- 点击后在浮层中展示管理员头像和名称。
|
||||||
|
- 再次点击按钮或点击浮层外区域可收起。
|
||||||
|
- 本次环境只显示用户 `2197651308`。
|
||||||
|
|
||||||
|
### 3.2 添加用户
|
||||||
|
|
||||||
|
- 权限矩阵关闭时按钮禁用。
|
||||||
|
- 权限矩阵开启后按钮可用。
|
||||||
|
- 点击后打开用户选择浮层,并自动展开组合框。
|
||||||
|
- 选择源只包含可被加入管理员集合的现有用户。
|
||||||
|
- 当前没有候选用户时显示:`没有用户可被选择`。
|
||||||
|
- `Esc` 可关闭浮层。
|
||||||
|
|
||||||
|
当前环境没有候选用户,因此未验证选择用户后的确认、去重和移除流程。
|
||||||
|
|
||||||
|
## 4. 默认角色
|
||||||
|
|
||||||
|
- `默认角色` 使用单选下拉框。
|
||||||
|
- 当前没有自定义角色时,下拉框仅提供并选中 `无权限`。
|
||||||
|
- 合理推断:创建角色后,角色会成为默认角色的可选项;新加入且未被显式分配角色的协作者应使用该默认角色。此推断需要结合产品规则或创建角色后的实际行为再次确认。
|
||||||
|
|
||||||
|
## 5. 添加角色入口
|
||||||
|
|
||||||
|
存在两个等价入口:
|
||||||
|
|
||||||
|
- 页面标题右侧 `添加角色`。
|
||||||
|
- 无角色空状态中的 `添加角色`。
|
||||||
|
|
||||||
|
点击后,权限矩阵主页被角色编辑器替换。编辑器初始值为:
|
||||||
|
|
||||||
|
- 角色名:`自定义角色`。
|
||||||
|
- 角色说明:未填写时显示 `角色规则说明备注`。
|
||||||
|
- 所有普通表格:`无权限`。
|
||||||
|
- 表格分组:显示 `自动隐藏`,其子表分别配置权限。
|
||||||
|
|
||||||
|
## 6. 角色基本信息
|
||||||
|
|
||||||
|
### 6.1 返回按钮
|
||||||
|
|
||||||
|
编辑器左上角为仅图标返回按钮。未单独验证它与底部 `取消` 在存在未保存改动时是否完全一致。
|
||||||
|
|
||||||
|
### 6.2 角色名称
|
||||||
|
|
||||||
|
- 角色名称为单行文本框。
|
||||||
|
- 新建角色默认填入 `自定义角色`,可直接编辑。
|
||||||
|
- 本次未提交空名称、重复名称或超长名称,因此校验规则待确认。
|
||||||
|
|
||||||
|
### 6.3 规则说明备注
|
||||||
|
|
||||||
|
- 未聚焦时以可点击文本 `角色规则说明备注` 展示。
|
||||||
|
- 点击后原位切换成文本输入框。
|
||||||
|
- 输入框失焦后回到静态展示状态。
|
||||||
|
|
||||||
|
## 7. 表格导航与搜索
|
||||||
|
|
||||||
|
角色编辑器左栏是可展开的表格树,每项右侧显示权限摘要。
|
||||||
|
|
||||||
|
### 7.1 普通表格
|
||||||
|
|
||||||
|
- 普通表格直接作为一级节点,例如 `自动推单表`。
|
||||||
|
- 点击节点会选中该表格,并在右侧显示详细权限。
|
||||||
|
- 摘要随权限选择即时更新,例如从 `无权限` 变为 `可编辑`。
|
||||||
|
|
||||||
|
### 7.2 表格分组
|
||||||
|
|
||||||
|
- 分组节点例如 `系统配置`,初始摘要为 `自动隐藏`。
|
||||||
|
- 点击分组只展开或收起子表,不会把分组本身设为权限配置对象。
|
||||||
|
- 子表以二级节点出现,并各自显示 `无权限` 或 `可编辑` 摘要。
|
||||||
|
- 本次观察到的子表包括:RIIN 快递公司确定规则、RIIN 发货工厂确定规则、工厂发货地址、物流渠道、发货仓、判定规则、定时更新订单信息、定时推送订单、定时同步订单。
|
||||||
|
|
||||||
|
### 7.3 搜索表格
|
||||||
|
|
||||||
|
- 输入框占位符为 `搜索表格`。
|
||||||
|
- 输入内容后即时过滤表格树,无需回车。
|
||||||
|
- 搜索会保留匹配子表及其父分组。例如输入 `RIIN` 后,只显示 `系统配置` 及两个包含 RIIN 的子表。
|
||||||
|
- 当前选中项匹配时保持选中。
|
||||||
|
- 清空输入应恢复完整树;本次未发现独立清除按钮。
|
||||||
|
|
||||||
|
## 8. 表格权限
|
||||||
|
|
||||||
|
每张表的顶层权限是单选项:
|
||||||
|
|
||||||
|
- `可编辑`
|
||||||
|
- `无权限`
|
||||||
|
|
||||||
|
### 8.1 无权限
|
||||||
|
|
||||||
|
- 右侧只显示表格权限单选项。
|
||||||
|
- 视图、记录、字段、导入和导出等细粒度设置全部隐藏。
|
||||||
|
- 左侧表格摘要显示 `无权限`。
|
||||||
|
|
||||||
|
### 8.2 可编辑
|
||||||
|
|
||||||
|
- 左侧摘要立即变为 `可编辑`。
|
||||||
|
- 右侧展开全部细粒度设置。
|
||||||
|
- 新切换为可编辑时,默认允许绝大多数操作、所有记录、所有视图、全部字段操作以及导入导出。
|
||||||
|
|
||||||
|
由 UI 表现可见,`可编辑` 是详细权限编辑模式,并不等于所有子权限必须保持开启。
|
||||||
|
|
||||||
|
## 9. 视图权限
|
||||||
|
|
||||||
|
### 9.1 视图操作
|
||||||
|
|
||||||
|
使用独立复选框控制:
|
||||||
|
|
||||||
|
- 创建视图
|
||||||
|
- 删除视图
|
||||||
|
- 更新视图
|
||||||
|
|
||||||
|
复选框可单独开关。
|
||||||
|
|
||||||
|
### 9.2 可查看的视图
|
||||||
|
|
||||||
|
单选模式:
|
||||||
|
|
||||||
|
- `所有视图`
|
||||||
|
- `特定视图`
|
||||||
|
|
||||||
|
选择 `特定视图` 后,原位展开当前表的视图清单,每个视图使用独立复选框。`自动推单表` 当前展示:
|
||||||
|
|
||||||
|
- 明细表
|
||||||
|
- 待推送订单
|
||||||
|
- 已推送订单
|
||||||
|
- 手动推送订单
|
||||||
|
- 表格视图
|
||||||
|
|
||||||
|
切换到 `特定视图` 时,现有视图默认全部选中。本次观察中,切换后 `创建视图` 同时由选中变为未选中,体现“仅允许特定现有视图”与“允许创建新视图”之间的联动约束;建议实现或复刻时将该依赖作为明确规则并补充自动化测试。
|
||||||
|
|
||||||
|
## 10. 记录权限
|
||||||
|
|
||||||
|
### 10.1 记录操作
|
||||||
|
|
||||||
|
使用独立复选框控制:
|
||||||
|
|
||||||
|
- 创建记录
|
||||||
|
- 删除记录
|
||||||
|
- 更新记录
|
||||||
|
- 对记录进行评论
|
||||||
|
- 复制记录
|
||||||
|
|
||||||
|
### 10.2 可查看的记录
|
||||||
|
|
||||||
|
单选模式:
|
||||||
|
|
||||||
|
- `所有记录`
|
||||||
|
- `符合特定条件的记录`
|
||||||
|
|
||||||
|
本次浏览器可访问性操作未能触发第二项,因此没有安全确认条件构建器的具体 UI。该选项在界面上可见且未标记为禁用,预期选择后应展开字段、运算符和值组成的条件编辑器;该部分属于待补充验证项,不应将预期当作已验证行为。
|
||||||
|
|
||||||
|
## 11. 字段权限
|
||||||
|
|
||||||
|
字段权限以矩阵表呈现:
|
||||||
|
|
||||||
|
| 列 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| 字段 | 字段名称及字段类型图标 |
|
||||||
|
| 查看记录 | 是否能在读取记录时看到该字段值 |
|
||||||
|
| 更新记录 | 是否能修改已有记录的该字段值 |
|
||||||
|
| 创建记录 | 新建记录时是否能为该字段赋值 |
|
||||||
|
|
||||||
|
交互规则:
|
||||||
|
|
||||||
|
- 每个权限单元格使用复选框独立控制。
|
||||||
|
- 三个权限列的表头均有 `Select all` 复选框,用于整列全选或全不选。
|
||||||
|
- 默认状态为三列全部选中。
|
||||||
|
- 部分特殊字段存在不可取消的权限。例如当前表的主字段 `订单号` 的“查看记录”复选框为选中且禁用,表示主字段至少必须可见。
|
||||||
|
- 字段数量较多时矩阵纵向滚动,底部的导入/导出权限位于字段矩阵之后。
|
||||||
|
|
||||||
|
字段之间、查看/更新/创建三类权限之间是否存在更多级联约束,本次未逐项修改后保存,因此应通过专门测试补充。
|
||||||
|
|
||||||
|
## 12. 导入与导出权限
|
||||||
|
|
||||||
|
位于每张可编辑表格配置的底部,使用两个独立复选框:
|
||||||
|
|
||||||
|
- `导入数据到表格`
|
||||||
|
- `导出表格数据`
|
||||||
|
|
||||||
|
新切换为可编辑时两项默认选中。
|
||||||
|
|
||||||
|
## 13. 保存与退出
|
||||||
|
|
||||||
|
编辑器底部固定提供:
|
||||||
|
|
||||||
|
- `取消`:放弃本次新建或编辑并返回权限矩阵主页。本次验证中,取消后没有创建角色,临时权限修改也没有保留。
|
||||||
|
- `保存`:预期保存当前配置并停留在编辑器中,便于继续编辑;为避免写入真实业务权限,本次未点击验证。
|
||||||
|
- `保存并返回`:预期保存后返回权限矩阵主页;本次未点击验证。
|
||||||
|
|
||||||
|
本次在有未保存修改时点击 `取消` 没有出现二次确认,直接退出。实现同类交互时应注意误操作丢失配置的风险。
|
||||||
|
|
||||||
|
## 14. 关键状态联动
|
||||||
|
|
||||||
|
| 触发 | UI 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 开启权限矩阵 | 立即生效;管理员直接展示;`添加用户` 可用 |
|
||||||
|
| 关闭权限矩阵 | 先弹出风险确认;确认后 `添加用户` 禁用 |
|
||||||
|
| 表格设为无权限 | 隐藏全部细粒度权限 |
|
||||||
|
| 表格设为可编辑 | 展开视图、记录、字段、导入和导出权限 |
|
||||||
|
| 视图设为特定视图 | 展开视图复选清单,并观察到创建视图自动关闭 |
|
||||||
|
| 展开表格分组 | 显示子表,但不改变右侧当前选中表 |
|
||||||
|
| 输入表格搜索词 | 即时过滤表格树并保留匹配项的父分组 |
|
||||||
|
| 点击角色说明占位文本 | 原位切换为文本输入框 |
|
||||||
|
| 取消角色编辑 | 直接放弃未保存修改并返回,无二次确认 |
|
||||||
|
|
||||||
|
## 15. 需要后续补充验证的边界
|
||||||
|
|
||||||
|
以下交互因当前环境没有候选用户、没有现有自定义角色,或会改变真实业务权限而未执行到提交阶段:
|
||||||
|
|
||||||
|
1. 角色名称的必填、重复、长度和特殊字符校验。
|
||||||
|
2. `保存` 与 `保存并返回` 的确切导航、成功提示和失败反馈。
|
||||||
|
3. 已创建角色卡片的编辑、复制、删除、排序和用户分配菜单。
|
||||||
|
4. 默认角色变更的即时保存方式与影响范围。
|
||||||
|
5. 为角色添加或移除用户,以及同一用户的多角色冲突规则。
|
||||||
|
6. `符合特定条件的记录` 的条件构建器和多条件组合逻辑。
|
||||||
|
7. 字段权限之间的级联约束,以及全选在“部分选中”状态下的视觉表现。
|
||||||
|
8. 权限矩阵开启后,对不同角色用户实际访问表格、视图、记录和字段的最终效果。
|
||||||
|
9. 窄屏、触屏、键盘导航及屏幕阅读器体验。
|
||||||
|
|
||||||
|
## 16. 实现或复刻时的验收重点
|
||||||
|
|
||||||
|
- 权限矩阵关闭必须明确提示“所有协作者可查看全部数据”的风险。
|
||||||
|
- 管理员必须始终豁免角色限制,且界面需清楚说明。
|
||||||
|
- 表格权限必须是细粒度权限的总门控:无权限时不应保留可操作的隐藏子权限。
|
||||||
|
- 表格树的分组展开、表格选择和搜索过滤必须是彼此独立的状态。
|
||||||
|
- 视图、记录和字段权限都应支持局部选择,并正确保存半选状态。
|
||||||
|
- 主字段等系统强制权限应呈现禁用态,避免用户误以为可以取消。
|
||||||
|
- 取消未保存修改目前没有确认;若配置成本较高,建议补充脏状态提示。
|
||||||
|
- 所有保存类操作都应具备加载态、防重复提交、成功反馈和失败回滚。
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
# Teable 自动化 UI、交互与运行流程说明
|
||||||
|
|
||||||
|
> 页面范围:`https://teable.inkreach.cc/base/bseVtXyL4aojvUDwFhn/automation/*`
|
||||||
|
> 梳理日期:2026-07-29
|
||||||
|
> 梳理方式:管理员账号黑盒交互验证。创建了临时自动化用于检查编辑流程,未执行任何会改写业务数据或调用外部系统的动作,测试自动化最终已移到回收站。
|
||||||
|
|
||||||
|
## 1. 功能定位
|
||||||
|
|
||||||
|
自动化用于把触发事件、条件判断和执行动作组成可运行的工作流。其基本生命周期为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
创建自动化
|
||||||
|
↓
|
||||||
|
选择并配置触发器
|
||||||
|
↓
|
||||||
|
测试触发器,生成后续步骤可引用的样本数据
|
||||||
|
↓
|
||||||
|
添加一个或多个动作、逻辑节点
|
||||||
|
↓
|
||||||
|
逐步配置并测试节点
|
||||||
|
↓
|
||||||
|
启用自动化
|
||||||
|
↓
|
||||||
|
事件触发或定时运行
|
||||||
|
↓
|
||||||
|
在运行历史中查看状态、耗时、输入、输出和错误
|
||||||
|
```
|
||||||
|
|
||||||
|
页面没有显式的“保存工作流”按钮。节点新增、表格选择等修改会即时持久化,重新打开页面后仍然存在,因此整体采用自动保存模型。
|
||||||
|
|
||||||
|
## 2. 自动化入口与创建
|
||||||
|
|
||||||
|
### 2.1 创建菜单
|
||||||
|
|
||||||
|
数据库左侧导航中有一个横向的 `创建` 按钮。点击后打开菜单:
|
||||||
|
|
||||||
|
- 新表格
|
||||||
|
- 新自动化
|
||||||
|
- 新应用
|
||||||
|
- 新文件夹
|
||||||
|
- CSV 文件
|
||||||
|
- Microsoft Excel
|
||||||
|
|
||||||
|
自动化入口为 `新自动化`,与表格、应用和文件夹创建处于同一级。
|
||||||
|
|
||||||
|
### 2.2 创建行为
|
||||||
|
|
||||||
|
点击 `新自动化` 后:
|
||||||
|
|
||||||
|
1. 系统立即创建一条名为 `新自动化` 的草稿。
|
||||||
|
2. 新自动化作为一级节点插入左侧数据库树并自动选中。
|
||||||
|
3. URL 跳转到 `/base/{baseId}/automation/{workflowId}`。
|
||||||
|
4. 主工作区切换到工作流编辑器。
|
||||||
|
5. 初始启用开关禁用。
|
||||||
|
6. 初始 `运行测试` 按钮禁用。
|
||||||
|
7. 画布中展示触发器选择面板。
|
||||||
|
|
||||||
|
创建过程没有二次确认,也没有独立命名步骤。即使还没有配置触发器,自动化实体已经存在。
|
||||||
|
|
||||||
|
## 3. 页面整体布局
|
||||||
|
|
||||||
|
自动化页面沿用数据库应用外壳,桌面端由四个主要区域组成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────┬─────────────────────────────────────┬───────────────┐
|
||||||
|
│ 数据库左侧导航 │ 自动化主工作区 │ AI 助手 │
|
||||||
|
│ │ │ 可展开/折叠 │
|
||||||
|
│ 创建入口 │ 标题栏、状态开关、帮助、更多 │ │
|
||||||
|
│ 表格/文件夹树 │ 编辑工作流 / 运行历史 │ │
|
||||||
|
│ 自动化节点 │ 画布或历史列表 │ │
|
||||||
|
│ │ 节点属性面板或运行详情 │ │
|
||||||
|
│ 当前用户、通知 │ │ │
|
||||||
|
└──────────────────┴─────────────────────────────────────┴───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 左侧导航固定宽度,自动化与表格、文件夹共享同一棵资源树。
|
||||||
|
- 中间是自动化核心工作区。
|
||||||
|
- 右侧 AI 助手可折叠为窄图标栏,也会在“通过 AI 对话配置”时展开。
|
||||||
|
- AI 助手展开后会压缩自动化工作区宽度,工作流画布和属性面板随之重新排布。
|
||||||
|
|
||||||
|
## 4. 自动化标题栏
|
||||||
|
|
||||||
|
标题栏从左到右包含:
|
||||||
|
|
||||||
|
- 启用/停用开关。
|
||||||
|
- 自动化图标。
|
||||||
|
- 自动化名称。
|
||||||
|
- `添加描述`。
|
||||||
|
- `帮助`。
|
||||||
|
- 更多菜单。
|
||||||
|
|
||||||
|
### 4.1 启用状态
|
||||||
|
|
||||||
|
- 空白自动化的开关禁用。
|
||||||
|
- 已选择触发器但动作未完成时,开关仍禁用。
|
||||||
|
- 完整的现有工作流会显示可操作开关,并在画布中提供 `立即启用` 入口。
|
||||||
|
- 本次已有自动化 `定时同步订单` 处于停用状态,但历史记录仍完整保留。
|
||||||
|
|
||||||
|
为避免影响生产任务,本次未点击启用开关。合理推断:只有所有节点配置有效后才能启用,启用后触发器开始接收事件或按计划运行。
|
||||||
|
|
||||||
|
### 4.2 名称与描述
|
||||||
|
|
||||||
|
- 新建时默认名称为 `新自动化`。
|
||||||
|
- `添加描述` 位于名称下方,作为轻量次级操作。
|
||||||
|
- 更多菜单中的 `重命名` 用于修改名称。
|
||||||
|
- 本次未提交名称或描述修改,未验证长度和重复名称规则。
|
||||||
|
|
||||||
|
### 4.3 更多菜单
|
||||||
|
|
||||||
|
包含:
|
||||||
|
|
||||||
|
- 重命名
|
||||||
|
- 复制
|
||||||
|
- 分享
|
||||||
|
- 节点信息
|
||||||
|
- 删除
|
||||||
|
|
||||||
|
删除会打开确认框:
|
||||||
|
|
||||||
|
- 标题:`删除 自动化`
|
||||||
|
- 提示:`确定要删除 "自动化名称" 吗?`
|
||||||
|
- 操作:`取消`、`移到回收站`
|
||||||
|
|
||||||
|
删除采用可恢复的回收站语义,而不是永久删除。本次创建的测试自动化已通过该流程移到回收站。
|
||||||
|
|
||||||
|
## 5. 顶部页签与操作
|
||||||
|
|
||||||
|
标题栏下方提供:
|
||||||
|
|
||||||
|
- `编辑工作流`
|
||||||
|
- `运行历史`
|
||||||
|
- `密钥管理`
|
||||||
|
- `运行测试`
|
||||||
|
|
||||||
|
### 5.1 编辑工作流
|
||||||
|
|
||||||
|
展示节点画布和节点属性配置,是自动化的默认页签。
|
||||||
|
|
||||||
|
### 5.2 运行历史
|
||||||
|
|
||||||
|
展示自动化的执行记录、汇总指标和单次运行详情。没有历史时显示:
|
||||||
|
|
||||||
|
`当自动化流程运行后,这里会展示运行历史记录。`
|
||||||
|
|
||||||
|
### 5.3 密钥管理
|
||||||
|
|
||||||
|
位于编辑工作流页签右侧,用于管理动作可能引用的密钥。由于可能涉及真实凭据,本次未展开。
|
||||||
|
|
||||||
|
### 5.4 运行测试
|
||||||
|
|
||||||
|
- 没有触发器时禁用。
|
||||||
|
- 触发器配置完成后可用。
|
||||||
|
- 新增未配置动作后会重新禁用。
|
||||||
|
- 表明全局运行测试需要当前工作流内所有必需节点通过配置校验。
|
||||||
|
|
||||||
|
## 6. 初始触发器选择
|
||||||
|
|
||||||
|
空白画布中央展示 `选择一个触发器:`,提供 8 类触发器:
|
||||||
|
|
||||||
|
| 触发器 | UI 说明或用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| 当记录满足条件时 | 记录进入指定条件后运行 |
|
||||||
|
| 当记录创建时 | 新建记录时运行 |
|
||||||
|
| 当记录更新时 | 指定记录发生更新时运行 |
|
||||||
|
| 当表单提交时 | 表单产生提交时运行 |
|
||||||
|
| 定时触发 | 根据时区和计划周期运行 |
|
||||||
|
| 当按钮点击时 | 用户点击表格按钮后运行 |
|
||||||
|
| 当接收 Webhook 时 | 接收外部 HTTP Webhook 后运行 |
|
||||||
|
| 当收到新邮件时 | 邮箱收到新邮件后运行 |
|
||||||
|
|
||||||
|
触发器选择项采用带图标的纵向操作列表,位于空白画布中央。
|
||||||
|
|
||||||
|
## 7. 触发器节点与属性面板
|
||||||
|
|
||||||
|
本次选择了 `当记录创建时`:
|
||||||
|
|
||||||
|
1. 触发器选择列表被替换为节点卡片。
|
||||||
|
2. 节点自动编号为 `1.`。
|
||||||
|
3. 卡片标题为 `1. 当记录创建时`。
|
||||||
|
4. 卡片显示摘要,例如 `当在 自动推单表 表格中新建记录时`。
|
||||||
|
5. 卡片带 `触发器` 类型标签。
|
||||||
|
6. 节点下方出现连接线和 `+` 按钮。
|
||||||
|
|
||||||
|
点击节点后,画布右侧打开约 `350 px` 的属性面板。面板包含:
|
||||||
|
|
||||||
|
- `属性` 标题。
|
||||||
|
- 节点 ID。
|
||||||
|
- 关闭按钮。
|
||||||
|
- `触发器类型` 下拉框。
|
||||||
|
- 触发器用途说明。
|
||||||
|
- 可折叠的 `配置`。
|
||||||
|
- 可折叠的 `测试步骤`。
|
||||||
|
|
||||||
|
### 7.1 配置触发器
|
||||||
|
|
||||||
|
`当记录创建时` 的必填配置是 `表格`:
|
||||||
|
|
||||||
|
- 未选择时显示 `请选择...`。
|
||||||
|
- 点击后打开浮层式表格选择器。
|
||||||
|
- 候选项显示表格图标和名称。
|
||||||
|
- 选择表格后节点摘要即时更新。
|
||||||
|
- 修改会自动持久化。
|
||||||
|
|
||||||
|
### 7.2 测试触发器
|
||||||
|
|
||||||
|
属性面板明确提示:
|
||||||
|
|
||||||
|
`测试此触发器以确认其配置正确。此测试的数据可用于后续步骤。`
|
||||||
|
|
||||||
|
表格未选择时:
|
||||||
|
|
||||||
|
- `选择记录` 禁用。
|
||||||
|
- `使用推荐记录` 禁用。
|
||||||
|
|
||||||
|
表格选择完成后,全局 `运行测试` 可用。
|
||||||
|
|
||||||
|
## 8. 全局运行测试与记录选择器
|
||||||
|
|
||||||
|
点击顶部 `运行测试` 后打开大尺寸模态框:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ 选择记录 [×] │
|
||||||
|
│ [全局 ▾] [搜索记录__________________________________ 🔍] │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ □ | 字段列 | 字段列 | 字段列 | ... │ │
|
||||||
|
│ │ □ | 记录值 | 记录值 | 记录值 | ... │ │
|
||||||
|
│ │ □ | 记录值 | 记录值 | 记录值 | ... │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
│ [取消] [运行自动化] │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
UI 细节:
|
||||||
|
|
||||||
|
- 背景使用半透明遮罩,保持原工作流可辨识。
|
||||||
|
- 顶部包含视图范围选择器、记录搜索和清除/搜索图标。
|
||||||
|
- 主体是可横向容纳多字段的记录表格。
|
||||||
|
- 每行左侧有单选复选框。
|
||||||
|
- 表头显示字段类型图标和字段名。
|
||||||
|
- 底部操作右对齐。
|
||||||
|
- 未选择记录时 `运行自动化` 不应执行。
|
||||||
|
|
||||||
|
测试会使用选中记录作为触发器样本,并把其数据提供给后续节点。由于测试流程可能执行后续写入或外部调用,本次只检查到记录选择界面,没有点击 `运行自动化`。
|
||||||
|
|
||||||
|
## 9. 工作流画布
|
||||||
|
|
||||||
|
编辑区采用节点画布:
|
||||||
|
|
||||||
|
- 浅色点阵背景。
|
||||||
|
- 节点垂直排列。
|
||||||
|
- 节点之间使用带方向的连接线。
|
||||||
|
- 连接处有圆形 `+` 按钮。
|
||||||
|
- 流程末端显示圆形终止标记。
|
||||||
|
- 左下角提供放大、缩小、适配视图。
|
||||||
|
- 达到最大缩放时放大按钮禁用。
|
||||||
|
|
||||||
|
节点卡片视觉结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ [类型图标] 1. 节点标题 [类型] │
|
||||||
|
│ 配置摘要 [状态] │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 当前选中节点使用蓝色边框。
|
||||||
|
- 配置异常节点显示红色警告图标。
|
||||||
|
- 节点标题含执行顺序编号。
|
||||||
|
- 触发器与执行动作使用不同类型标签。
|
||||||
|
- 节点菜单可用于节点级操作,具体菜单项未全部执行。
|
||||||
|
|
||||||
|
## 10. 添加动作与逻辑
|
||||||
|
|
||||||
|
点击节点下方 `+` 后打开悬浮菜单,分为两种构建方式。
|
||||||
|
|
||||||
|
### 10.1 AI 构建
|
||||||
|
|
||||||
|
菜单优先展示:
|
||||||
|
|
||||||
|
- 运行脚本,带 `推荐` 标记和说明 `使用 AI 自动化任何逻辑`
|
||||||
|
- Slack
|
||||||
|
- 发送邮件
|
||||||
|
- AI 生成
|
||||||
|
- 创建/更新/获取记录
|
||||||
|
- 发送 HTTP 请求
|
||||||
|
|
||||||
|
这些入口强调按目标选择能力,而不是先选择底层动作类型。
|
||||||
|
|
||||||
|
### 10.2 手动构建
|
||||||
|
|
||||||
|
可展开区域分为:
|
||||||
|
|
||||||
|
操作:
|
||||||
|
|
||||||
|
- 创建记录
|
||||||
|
- 获取记录
|
||||||
|
- 更新记录
|
||||||
|
- 发送邮件
|
||||||
|
- AI 生成
|
||||||
|
- HTTP 请求
|
||||||
|
|
||||||
|
逻辑:
|
||||||
|
|
||||||
|
- 当条件满足...
|
||||||
|
|
||||||
|
手动构建适合用户明确知道动作类型的场景;AI 构建则通过对话或组合入口降低配置成本。
|
||||||
|
|
||||||
|
## 11. 动作节点与配置面板
|
||||||
|
|
||||||
|
本次选择了推荐的 `运行脚本`:
|
||||||
|
|
||||||
|
- 系统在触发器后自动插入 `2. 运行脚本` 节点。
|
||||||
|
- 自动创建连接线。
|
||||||
|
- 新节点自动选中。
|
||||||
|
- 右侧打开动作属性面板。
|
||||||
|
- 因动作尚未配置,节点显示红色警告,全局 `运行测试` 禁用。
|
||||||
|
|
||||||
|
动作属性面板包含:
|
||||||
|
|
||||||
|
- 节点 ID。
|
||||||
|
- `动作类型` 下拉框。
|
||||||
|
- 动作用途说明。
|
||||||
|
- `标签` 区域和描述输入框。
|
||||||
|
- `配置` 区域。
|
||||||
|
- `测试步骤` 区域。
|
||||||
|
|
||||||
|
### 11.1 运行脚本配置
|
||||||
|
|
||||||
|
配置区提供两条路径:
|
||||||
|
|
||||||
|
1. `通过 AI 对话配置`
|
||||||
|
- Slack
|
||||||
|
- 邮件
|
||||||
|
- 自定义
|
||||||
|
2. `手动编辑`
|
||||||
|
|
||||||
|
点击 AI 配置入口会展开右侧 AI 助手,并把当前节点作为上下文标签加入输入区。工作区会同步收窄。
|
||||||
|
|
||||||
|
单节点 `运行测试` 在节点配置不完整时禁用。动作完成配置后,预期可用触发器测试数据单独验证该步骤。
|
||||||
|
|
||||||
|
### 11.2 数据引用关系
|
||||||
|
|
||||||
|
触发器测试说明明确指出其输出可供后续步骤使用。由此可确定:
|
||||||
|
|
||||||
|
- 节点按编号顺序执行。
|
||||||
|
- 后续节点可以引用触发器或前序动作的测试输出。
|
||||||
|
- 单节点测试依赖上游测试数据。
|
||||||
|
- 修改上游节点后,应重新测试相关下游节点。
|
||||||
|
|
||||||
|
## 12. 启用与正式运行
|
||||||
|
|
||||||
|
完整现有工作流的编辑画布中出现 `立即启用`,标题栏开关也从禁用变为可操作。
|
||||||
|
|
||||||
|
建议的正式运行顺序是:
|
||||||
|
|
||||||
|
1. 确保每个节点没有红色配置警告。
|
||||||
|
2. 测试触发器并确认样本数据。
|
||||||
|
3. 逐个测试动作节点。
|
||||||
|
4. 执行一次全局运行测试。
|
||||||
|
5. 检查动作输出和副作用。
|
||||||
|
6. 点击 `立即启用` 或标题栏开关。
|
||||||
|
7. 观察运行历史。
|
||||||
|
|
||||||
|
本次未启用任何生产自动化,也未点击已有运行的 `重跑`。
|
||||||
|
|
||||||
|
## 13. 运行历史布局
|
||||||
|
|
||||||
|
有历史数据时,中间工作区采用三列结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────┬──────────────────────┬─────────────────────────┐
|
||||||
|
│ 运行记录列表 │ 概览统计 │ 单次运行详情 │
|
||||||
|
│ 状态、耗时、时间 │ 成功、失败、运行中 │ 总状态、诊断、重跑 │
|
||||||
|
│ │ 等待、取消、平均耗时 │ 各步骤输入与输出 │
|
||||||
|
└──────────────────┴──────────────────────┴─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13.1 运行记录列表
|
||||||
|
|
||||||
|
每条记录展示:
|
||||||
|
|
||||||
|
- 状态图标和文字。
|
||||||
|
- 运行耗时,例如 `1.9 s`。
|
||||||
|
- 运行时间,例如 `2026-07-28 20:02:15`。
|
||||||
|
- 右侧进入详情箭头。
|
||||||
|
|
||||||
|
成功使用绿色,失败使用红色,形成明确的语义色。
|
||||||
|
|
||||||
|
### 13.2 概览统计
|
||||||
|
|
||||||
|
现有 `定时同步订单` 自动化的界面显示以下统计卡:
|
||||||
|
|
||||||
|
- 运行成功
|
||||||
|
- 运行失败
|
||||||
|
- 运行中
|
||||||
|
- 等待中
|
||||||
|
- 已取消
|
||||||
|
- 平均运行时间
|
||||||
|
|
||||||
|
本次观察值为成功 84、失败 14、运行中 0、等待中 0、已取消 0、平均运行时间 27 秒。该数值是观察时的实时业务数据,不是固定产品默认值。
|
||||||
|
|
||||||
|
### 13.3 单次运行详情
|
||||||
|
|
||||||
|
详情顶部展示:
|
||||||
|
|
||||||
|
- 总体运行状态。
|
||||||
|
- 运行时间。
|
||||||
|
- 失败时提供 `诊断`。
|
||||||
|
- 失败时提供 `重跑`。
|
||||||
|
|
||||||
|
下方按工作流步骤展示:
|
||||||
|
|
||||||
|
- 节点名称。
|
||||||
|
- 节点运行状态。
|
||||||
|
- 相对运行时间。
|
||||||
|
- 输入。
|
||||||
|
- 输出。
|
||||||
|
- 原始数据入口。
|
||||||
|
- 执行耗时。
|
||||||
|
|
||||||
|
触发器输入示例包含时区、触发时机、开始时间和原始数据;定时触发器输出包含预计触发时间和下次触发时间。
|
||||||
|
|
||||||
|
运行失败时,上游触发器仍可能显示运行成功,说明系统按节点记录状态,便于定位具体失败步骤。
|
||||||
|
|
||||||
|
## 14. 运行历史筛选
|
||||||
|
|
||||||
|
点击 `过滤条件` 后打开轻量浮层,包含:
|
||||||
|
|
||||||
|
- 状态。
|
||||||
|
- 运行耗时。
|
||||||
|
- 时间范围。
|
||||||
|
|
||||||
|
状态选项包括:
|
||||||
|
|
||||||
|
- 全部
|
||||||
|
- 运行成功
|
||||||
|
- 运行失败
|
||||||
|
- 运行中
|
||||||
|
- 等待中
|
||||||
|
- 已取消
|
||||||
|
|
||||||
|
时间范围通过 `选择日期范围` 打开日期选择器。
|
||||||
|
|
||||||
|
选择失败记录或失败统计后,过滤按钮会显示数量,例如 `过滤条件(1)`,运行列表只保留失败记录。
|
||||||
|
|
||||||
|
## 15. 失败处理
|
||||||
|
|
||||||
|
失败运行的详情页提供:
|
||||||
|
|
||||||
|
- 红色失败状态。
|
||||||
|
- `诊断` 按钮。
|
||||||
|
- `重跑` 按钮。
|
||||||
|
- 每个步骤的独立状态。
|
||||||
|
- 输入和输出展开项。
|
||||||
|
|
||||||
|
交互原则:
|
||||||
|
|
||||||
|
- `诊断` 用于分析失败原因,预期不会重新执行流程。
|
||||||
|
- `重跑` 会再次执行自动化,可能产生真实业务副作用,属于高风险操作。
|
||||||
|
- 用户应先检查失败步骤和输入,再决定是否重跑。
|
||||||
|
|
||||||
|
本次只查看了失败详情,没有点击诊断或重跑。
|
||||||
|
|
||||||
|
## 16. 状态联动矩阵
|
||||||
|
|
||||||
|
| 触发 | UI 状态变化 |
|
||||||
|
| --- | --- |
|
||||||
|
| 创建新自动化 | 左侧立即新增节点,进入空白画布 |
|
||||||
|
| 未选择触发器 | 启用开关和运行测试禁用 |
|
||||||
|
| 选择触发器 | 生成第 1 个节点和添加动作按钮 |
|
||||||
|
| 触发器缺少必填配置 | 测试步骤按钮禁用 |
|
||||||
|
| 完成触发器配置 | 节点摘要更新,全局运行测试可用 |
|
||||||
|
| 添加动作 | 自动编号、连线、选中,并打开属性面板 |
|
||||||
|
| 动作配置不完整 | 节点显示红色警告,全局运行测试禁用 |
|
||||||
|
| 点击节点 | 右侧属性面板打开,画布可用宽度缩小 |
|
||||||
|
| 点击画布或关闭属性面板 | 属性面板收起,画布恢复宽度 |
|
||||||
|
| 点击 AI 配置 | AI 助手展开并携带当前节点上下文 |
|
||||||
|
| 工作流配置完整 | 启用开关可用,出现 `立即启用` |
|
||||||
|
| 切换运行历史 | 画布替换为历史三列布局 |
|
||||||
|
| 选择运行记录 | 右侧显示步骤级输入与输出 |
|
||||||
|
| 筛选运行状态 | 列表过滤,按钮显示已应用条件数量 |
|
||||||
|
| 删除自动化 | 二次确认后移到回收站 |
|
||||||
|
|
||||||
|
## 17. UI 设计细节
|
||||||
|
|
||||||
|
### 17.1 视觉语言
|
||||||
|
|
||||||
|
- 白色面板配浅灰分隔线,适合高密度配置任务。
|
||||||
|
- 画布使用浅色点阵,不抢夺节点内容的注意力。
|
||||||
|
- 紫色用于自动化、触发器和脚本能力识别。
|
||||||
|
- 蓝色表示当前选中节点。
|
||||||
|
- 绿色表示成功,红色表示错误,橙色用于执行动作或中间状态。
|
||||||
|
- 主要依靠边框、浅底色和语义色表达状态,没有重阴影。
|
||||||
|
|
||||||
|
### 17.2 信息层级
|
||||||
|
|
||||||
|
- 顶部标题栏管理自动化级状态。
|
||||||
|
- 页签区管理编辑与历史两种工作模式。
|
||||||
|
- 画布表达执行顺序和节点关系。
|
||||||
|
- 属性面板处理单节点配置。
|
||||||
|
- AI 助手是辅助配置工具,不取代节点属性面板。
|
||||||
|
- 历史页从列表、汇总到详情逐层展开,支持快速扫描和深入排错。
|
||||||
|
|
||||||
|
### 17.3 空间与滚动
|
||||||
|
|
||||||
|
- 左侧资源树独立于中间工作区。
|
||||||
|
- 画布支持缩放和适配视图。
|
||||||
|
- 属性面板在右侧覆盖或占据画布宽度,并可纵向滚动。
|
||||||
|
- AI 助手展开时再占用一列宽度。
|
||||||
|
- 运行历史的列表、汇总和详情各有清晰列边界;内容较长时详情区独立滚动。
|
||||||
|
- 模态记录选择器占据大部分可用宽度,优先保证数据表可读性。
|
||||||
|
|
||||||
|
### 17.4 控件状态
|
||||||
|
|
||||||
|
关键控件均有明显禁用状态:
|
||||||
|
|
||||||
|
- 启用开关。
|
||||||
|
- 全局运行测试。
|
||||||
|
- 触发器选择记录。
|
||||||
|
- 单节点运行测试。
|
||||||
|
- 画布放大按钮。
|
||||||
|
|
||||||
|
红色警告图标直接放在未完成节点上,使配置问题在画布层可见。
|
||||||
|
|
||||||
|
## 18. 安全与权限建议
|
||||||
|
|
||||||
|
自动化可能写入表格、发送邮件、调用 Slack、执行脚本或 HTTP 请求,因此应纳入权限控制:
|
||||||
|
|
||||||
|
- 创建、编辑、启用、停用、删除自动化。
|
||||||
|
- 查看运行历史。
|
||||||
|
- 查看节点输入、输出和原始数据。
|
||||||
|
- 管理密钥。
|
||||||
|
- 运行测试。
|
||||||
|
- 诊断和重跑失败任务。
|
||||||
|
- 分享或复制自动化。
|
||||||
|
|
||||||
|
尤其需要限制 `运行脚本`、`HTTP 请求`、`发送邮件`、`Slack`、`密钥管理` 和 `重跑`。
|
||||||
|
|
||||||
|
## 19. 待补充验证
|
||||||
|
|
||||||
|
以下内容因会影响真实业务或当前环境限制而未执行:
|
||||||
|
|
||||||
|
1. 自动化启用和停用时是否有确认提示。
|
||||||
|
2. 节点全部配置完成后的自动校验反馈。
|
||||||
|
3. 全局测试成功后的完整结果页面。
|
||||||
|
4. 创建、更新记录等写操作的字段映射 UI。
|
||||||
|
5. 条件分支的多条件与嵌套逻辑。
|
||||||
|
6. Webhook URL 生成、签名与请求日志。
|
||||||
|
7. 邮件、Slack、HTTP 和 AI 动作的失败重试策略。
|
||||||
|
8. 密钥管理的新增、编辑、权限和脱敏显示。
|
||||||
|
9. 复制、分享、节点信息的完整交互。
|
||||||
|
10. 手机和平板端布局。
|
||||||
|
11. 删除后从回收站恢复自动化。
|
||||||
|
|
||||||
|
## 20. 实现或复刻验收重点
|
||||||
|
|
||||||
|
- 创建后立即持久化的行为必须有清晰反馈,避免用户误以为尚未创建。
|
||||||
|
- 自动保存必须有保存中、已保存和失败状态。
|
||||||
|
- 启用必须建立在全节点配置有效的前提上。
|
||||||
|
- 运行测试必须明确提示可能产生的业务副作用。
|
||||||
|
- 重跑必须显示目标运行、输入来源和副作用警告。
|
||||||
|
- 节点警告、全局禁用状态和属性面板错误必须保持一致。
|
||||||
|
- 节点画布缩放、属性面板和 AI 助手同时展开时仍需保证可操作空间。
|
||||||
|
- 运行历史必须支持状态、耗时和时间范围筛选。
|
||||||
|
- 日志和原始数据可能含凭据或敏感业务数据,必须脱敏并受权限保护。
|
||||||
|
- 删除必须进入回收站,并提供恢复路径。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 官网首页
|
||||||
|
|
||||||
|
## 关键链接与国家货盘
|
||||||
|
|
||||||
|
- 首页“登录”“立即开始”“免费试用”“立即免费使用”等 CTA 统一通过 `window.location.href` 在当前页面跳转到 `https://inkpod.vip/user/login`。
|
||||||
|
- “全球POD货盘”国家列表读取与产品中心相同的后台国家接口,完整展示所有已配置且有关联商品的国家及图标。
|
||||||
|
- 桌面端国家列表按 6 列 × 2 行均匀排列;点击国家只切换下方对应货盘,不发生页面跳转。
|
||||||
|
- 国家下方的品类卡链接到 `/product-center?countryId={后台国家ID}&categoryId={后台品类ID}`,产品中心打开后自动应用国家和品类筛选。
|
||||||
|
|
||||||
|
官网首页位于 `apps/website/app/pages/index.vue`,访问路径为 `/`。页面基于 Figma 节点 `897:2047` 还原,桌面设计基准为 `1440 x 6933px`。
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
首页依次由导航、首屏、平台数据、四步流程、全球 POD 货盘、渠道能力、平台优势、公司简介、用户案例、行动区和页脚组成。各区块保持独立 Vue 组件,首页仅负责组合。
|
||||||
|
|
||||||
|
桌面端主要区块边界为:`80 / 980 / 1580 / 2515 / 3522 / 4276 / 4706 / 5306 / 6234 / 6572px`。流程区默认展示“选择产品”,渠道能力默认展示“店铺集成”;用户主动操作后仍可切换。
|
||||||
|
|
||||||
|
## 素材
|
||||||
|
|
||||||
|
首页图片统一存放于 `apps/website/public/`。Figma 用户案例图使用 `case-*.png` 命名,不依赖临时远程链接或随机占位图。
|
||||||
|
|
||||||
|
`/主视觉.png` 保持为宽高 `800 × 400` 的普通 `<img>`,渲染宽度不超过原图 800px,不作为背景图,也不会随超宽屏幕继续放大。
|
||||||
|
|
||||||
|
## 自动轮播
|
||||||
|
|
||||||
|
- 四步流程和“用强大功能,开启盈利快车道”功能区均每 2 秒自动切换,用户仍可点击或悬停立即切换。四步流程当前项的 2px 黄线会在 2 秒内从 0% 填满至 100%,填满后切换下一张;步骤标题与功能轮播项保持一致,桌面为 36px 常规字重。
|
||||||
|
- “用强大功能,开启盈利快车道”左侧标题桌面端为 30px,四个功能项在 550px 高度内等分排列,与右侧轮播图上下对齐。
|
||||||
|
- 用户案例在桌面和移动端均无缝横向滚动,完整循环放慢至 30 秒;悬停或聚焦时暂停。
|
||||||
|
- `prefers-reduced-motion: reduce` 环境下关闭持续滚动动画。
|
||||||
|
- 导航中的“选品推荐”和“解决方案”入口暂时隐藏,相关实现代码保留以便后续恢复。
|
||||||
|
- 桌面导航使用 18px 字号,移动导航保持原有触控尺寸。
|
||||||
|
- “全球 POD 货盘”使用固定旗帜列对齐国家名称,“更多产品”操作固定在货盘区底部。
|
||||||
|
- 首屏“链通全球”使用主题橙色渐变,0 库存、0 关税、1 件起订以竖线分隔;平台数据卡的统计数值使用主题橙色。“全球 POD 货盘”的“更多产品”为无按钮容器的主题色文本链接。
|
||||||
|
- 官网全局使用随项目提供的 PingFang SC Thin、Regular、Medium、Semibold 字体,首屏副标题使用 Thin 字重。首屏渐变从“通”字开始,“通”字仅保留少量渐变过渡;主标题与副标题共用左对齐容器。平台数据卡会抵消插图上移造成的底部占位,减少卡片留白。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @inkreach/website test --run
|
||||||
|
pnpm --filter @inkreach/website build
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器以 `1440 x 1024` 视口检查首页总高、区块边界、图片加载和横向溢出。
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 产品中心
|
||||||
|
|
||||||
|
官网产品中心位于 `apps/website/app/pages/product-center.vue`,访问路径为 `/product-center`。
|
||||||
|
|
||||||
|
## 默认展示
|
||||||
|
|
||||||
|
页面首次加载时展示全部商品和全部国家,不默认选择分类、物流或工艺标签。每页默认请求 20 条商品,用户操作筛选项后再按真实业务 ID 查询。
|
||||||
|
|
||||||
|
## 筛选与分页
|
||||||
|
|
||||||
|
- 左侧分类树:选择父分类或子分类。
|
||||||
|
- 国家:点击国家胶囊筛选,点击“全部”取消国家筛选。
|
||||||
|
- 物流与工艺:按标签组展示下拉选项,每个分组内暂为单选,不同分组可各选择一项。
|
||||||
|
- 筛选菜单在选择选项或点击组件外部后自动关闭;已选 Tag 使用独立标签行。仅选择工艺时标签从最左侧开始;物流与工艺同时选择时按物流、工艺顺序与上方筛选项对应排列。
|
||||||
|
- 搜索:输入关键词后按回车或点击“搜索”。
|
||||||
|
- 商品卡:整卡链接到 `https://inkpod.vip/portal/detail/{商品ID}`,在新窗口打开。
|
||||||
|
- 公共商品接口的 `id` 来自 `originGood.sdsGoodId`,即 InkPOD 商品 ID;`goods.good_id` 仅作为本站数据库内部主键使用。
|
||||||
|
- URL 国家筛选:支持 `/product-center?countryId={后台国家ID}`,仅接受后台已配置的国家 ID。
|
||||||
|
- 无结果:展示设计稿空状态,可通过“查看全部商品”或“清空筛选”恢复完整列表。
|
||||||
|
- 加载中:展示两行共八个商品骨架卡,筛选栏保持可操作。
|
||||||
|
- 分页:支持页码、前后翻页、每页数量和页码跳转。
|
||||||
|
|
||||||
|
所有查询通过 `useProductCenter.ts` 调用 `/api/backend/*` 代理路由,不在页面中硬编码业务 ID。
|
||||||
|
|
||||||
|
## 后台配置联动
|
||||||
|
|
||||||
|
- 品类树、国家、Tag 与 Tag Group 均来自 NestJS `/public/*` 接口。
|
||||||
|
- 国家旗帜读取 `countryIcon`,品类图标读取 `categoryIcon`;前端不提供外部旗帜 CDN 或固定名称兜底。
|
||||||
|
- `/uploads/*` 与 `/assets/*` 等后台相对资源地址由 `useProductCenter.ts` 根据 `runtimeConfig.public.backendUrl` 转为完整 URL。
|
||||||
|
- 物流和工艺筛选分别由后台名称包含“物流”和“工艺”的 Tag Group 生成;“印刷位置”等其他分组仍可用于商品卡片标签,但不会自动成为顶部筛选项。
|
||||||
|
- Figma 基准图标位于 `apps/api/public/product-center/`,通过后端 `/assets/product-center/*` 提供。
|
||||||
|
- 更新现有数据库图标配置:`pnpm --filter @inkreach/api configure:product-center-icons`。脚本按中文名称幂等更新,不创建缺失的国家或品类。
|
||||||
|
|
||||||
|
## 响应式
|
||||||
|
|
||||||
|
桌面采用 240px 分类栏,页面左右保留 `24–56px` 自适应留白,分类栏可滚动但隐藏可见 scrollbar。商品网格以 240px 为最小卡片宽度自动增加或减少列数:1440px 基准宽度保持四列,1920px 可显示六列。国家筛选区按内容自然增高,不保留固定空白。
|
||||||
|
|
||||||
|
左侧品类栏不显示最左侧边线,一级品类之间保留 10px 间距;国家筛选区及物流、工艺、搜索工具区的底部分隔线从内容区向右延伸至页面边缘。
|
||||||
|
|
||||||
|
宽度低于 1000px 后分类栏变为抽屉;移动端商品网格降为两列或单列,国家筛选仅在自身区域横向滚动,不会撑宽页面。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @inkreach/website test --run
|
||||||
|
pnpm --filter @inkreach/website build
|
||||||
|
pnpm --filter @inkreach/website dev
|
||||||
|
pnpm --filter @inkreach/api test -- --runInBand
|
||||||
|
pnpm --filter @inkreach/api build
|
||||||
|
```
|
||||||
@@ -243,6 +243,32 @@ apps/website/
|
|||||||
> 子项目内部的页面、组件、composable、代理路由、组件响应式断点等详细信息见 `apps/website/docs/references/structs.md`。
|
> 子项目内部的页面、组件、composable、代理路由、组件响应式断点等详细信息见 `apps/website/docs/references/structs.md`。
|
||||||
|
|
||||||
## 数据库
|
## 数据库
|
||||||
|
- `apps/api/public/product-center/`:产品中心 Figma 国家旗帜与一级品类图标,由 `/assets/product-center/*` 对外提供。
|
||||||
|
- `apps/api/prisma/configure-product-center-icons.ts`:按名称幂等写入 `countryIcon/categoryIcon`,不创建业务记录。
|
||||||
|
|
||||||
|
## 产品中心设计还原(2026-07-16)
|
||||||
|
|
||||||
|
- `apps/website/app/components/product/ProductCenterHeader.vue`:Figma 产品中心专用 80px 导航栏。
|
||||||
|
- `apps/website/app/components/product/ProductSidebar.vue`:240px 分类树及移动端抽屉内容。
|
||||||
|
- `apps/website/app/components/product/ProductCountryFilter.vue`:国家胶囊筛选。
|
||||||
|
- `apps/website/app/components/product/ProductTagFilter.vue`:物流与工艺分组筛选。
|
||||||
|
- `apps/website/app/components/product/ProductCard.vue`:270 x 382 桌面商品卡片。
|
||||||
|
- `apps/website/app/components/product/ProductPagination.vue`:总数、页码、每页数量和跳转。
|
||||||
|
- `apps/website/test/useProductCenter.test.ts`:默认筛选名称映射测试。
|
||||||
|
- `docs/references/product-center.md`:产品中心使用与验证说明。
|
||||||
|
- `skills/inkreach-official-website/SKILL.md`:Agent 使用说明。
|
||||||
|
|
||||||
|
## 官网首页设计还原(2026-07-16)
|
||||||
|
|
||||||
|
- `apps/website/app/pages/index.vue`:官网首页组合入口。
|
||||||
|
- `apps/website/app/components/HeroBanner.vue` 至 `AppFooter.vue`:首页分区组件。
|
||||||
|
- `apps/website/public/case-*.png`:Figma 用户案例商品图。
|
||||||
|
- `docs/references/homepage.md`:首页结构、尺寸基准和验证说明。
|
||||||
|
- `apps/website/test/HomepageNavigationCarousel.test.ts`:首页当前页 CTA、自动轮播、主视觉图片和临时导航隐藏的行为测试。
|
||||||
|
|
||||||
|
## 数据库
|
||||||
|
|
||||||
|
- `apps/api/src/public/public.service.ts`:官网公共商品序列化边界;响应中的商品 `id` 使用 `originGood.sdsGoodId`,不暴露本地 `goods.good_id`。
|
||||||
|
|
||||||
- PostgreSQL 14+,Prisma 5.x
|
- PostgreSQL 14+,Prisma 5.x
|
||||||
- 连接配置在根 `.env` 的 `DATABASE_URL` 中
|
- 连接配置在根 `.env` 的 `DATABASE_URL` 中
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Homepage Navigation and Carousel Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make homepage CTAs navigate in the current page, automate homepage carousels, preserve the hero visual as a capped image, add product-center gutters, and temporarily hide two navigation menus.
|
||||||
|
|
||||||
|
**Architecture:** Keep behavior local to the existing Vue components. Use native current-page navigation for CTAs, lifecycle-managed timers for the step carousel, a duplicated CSS marquee for customer cases, and responsive CSS custom properties for product-center gutters.
|
||||||
|
|
||||||
|
**Tech Stack:** Nuxt 4, Vue 3 Composition API, Tailwind CSS, Vitest, Vue Test Utils
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Lock navigation behavior
|
||||||
|
- [x] Add failing tests for current-page CTA and product-card navigation.
|
||||||
|
- [x] Replace new-tab links and inert CTA buttons with immediate current-page navigation.
|
||||||
|
- [x] Hide recommendation and solution navigation entries while retaining implementation code.
|
||||||
|
|
||||||
|
### Task 2: Automate homepage rotating content
|
||||||
|
- [x] Add failing timer tests for the four-step carousel.
|
||||||
|
- [x] Add a lifecycle-managed automatic step interval.
|
||||||
|
- [x] Animate the duplicated customer-case track continuously and add reduced-motion behavior.
|
||||||
|
|
||||||
|
### Task 3: Improve desktop layout
|
||||||
|
- [x] Keep `/主视觉.png` as an `<img>` with intrinsic aspect ratio and a capped rendered size.
|
||||||
|
- [x] Add responsive left and right gutters to the product center.
|
||||||
|
|
||||||
|
### Task 4: Verify and document
|
||||||
|
- [x] Update homepage, product-center, structure, and Agent reference docs.
|
||||||
|
- [x] Run website tests and production build.
|
||||||
|
- [x] Skip browser automation per the user's request; cover navigation, timing, hero image, country alignment, and bottom action behavior with Vitest.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Product Center Backend Integration Implementation Plan
|
||||||
|
|
||||||
|
**Goal:** Make product-center countries, categories, tag filters, and their icons fully driven by backend configuration while matching the Figma assets.
|
||||||
|
|
||||||
|
**Architecture:** Store Figma-derived icons as versioned API static assets and update existing database records through an idempotent Prisma script. The website resolves backend-relative asset URLs against `backendUrl` and builds filter controls from backend tag-group membership rather than sort-order guesses or tag-name allowlists.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS, Prisma, PostgreSQL, Nuxt 4, Vue 3, Vitest, Jest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Backend Icon Assets and Configuration Script
|
||||||
|
|
||||||
|
- [x] Export country and top-level category icons from Figma into `apps/api/public/product-center`.
|
||||||
|
- [x] Serve committed API assets under `/assets/`.
|
||||||
|
- [x] Add an idempotent Prisma script that updates matching country/category icon fields.
|
||||||
|
- [x] Run the script against the configured database and verify public API values.
|
||||||
|
|
||||||
|
### Task 2: Website Backend Asset Resolution
|
||||||
|
|
||||||
|
- [x] Add failing tests for absolute, data, and backend-relative icon URLs.
|
||||||
|
- [x] Implement URL resolution in the product-center data mapper.
|
||||||
|
- [x] Verify country and category components render API-provided icons.
|
||||||
|
|
||||||
|
### Task 3: Backend-Driven Tag Groups
|
||||||
|
|
||||||
|
- [x] Add failing component tests using backend group IDs/names.
|
||||||
|
- [x] Pass tag groups into `ProductTagFilter` and derive logistics/process groups from backend metadata.
|
||||||
|
- [x] Remove sort-order and tag-name allowlist assumptions.
|
||||||
|
|
||||||
|
### Task 4: End-to-End Verification
|
||||||
|
|
||||||
|
- [x] Run website and API tests/builds.
|
||||||
|
- [x] Verify public endpoints contain durable icon URLs.
|
||||||
|
- [x] Browser-test icons and filters on `/product-center`.
|
||||||