Quellcode durchsuchen

Update Nuxt configuration and styles: change CSS path in nuxt.config.ts, enhance SVG loader configuration, and add SCSS support. Update main.css to include Poppins font and theme variables. Refactor index.vue to improve layout and integrate new components.

0es vor 4 Monaten
Ursprung
Commit
1c0b0f889b

+ 19 - 0
app/assets/css/main.css

@@ -1 +1,20 @@
 @import 'tailwindcss';
+
+@font-face {
+  font-family: Poppins;
+  font-style: normal;
+  font-weight: 600;
+  src: url("/Poppins-SemiBold.ttf") format("truetype");
+}
+
+
+
+@theme {
+  --font-title: Poppins, sans-serif;
+  --text-title: 1.375rem;
+
+  --color-bg-primary: #F1F2F5;
+  --color-text-primary: #1D2129;
+  --color-text-secondary: #4E5969;
+  --color-text-description: #3FBFBD;
+}

+ 64 - 0
app/assets/css/mixins.scss

@@ -0,0 +1,64 @@
+@use "sass:math";
+
+// Design base width - 设计稿基准宽度
+$design-base-width: 375;
+
+// Basic size mixin - 固定宽高
+@mixin size($width, $height: $width) {
+  width: $width;
+  height: $height;
+}
+
+// Responsive size with aspect ratio - 响应式宽高比
+// $width: 设计稿宽度(无单位数字),例如 375
+// $height: 设计稿高度(无单位数字),例如 173
+// 会根据设计稿基准宽度动态计算 width 百分比和 padding-top 自适应布局
+@mixin rsize($width, $height, $parentWidth: $design-base-width) {
+  width: math.percentage(math.div($width, $parentWidth));
+  height: 0;
+  padding-top: math.percentage(math.div($height, $parentWidth));
+
+  > * {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+  }
+}
+
+// Background image mixin - 快速设置背景图
+// $url: 图片路径
+// $size: background-size,默认 cover
+// $position: background-position,默认 center
+// $repeat: background-repeat,默认 no-repeat
+@mixin bg($url, $size: cover, $position: center, $repeat: no-repeat) {
+  background-image: url($url);
+  background-size: $size;
+  background-position: $position;
+  background-repeat: $repeat;
+}
+
+// Background cover - 背景图片覆盖整个容器
+@mixin bg-cover($url, $position: center) {
+  background-image: url($url);
+  background-size: cover;
+  background-position: $position;
+  background-repeat: no-repeat;
+}
+
+// Background contain - 背景图片完整显示
+@mixin bg-contain($url, $position: center) {
+  background-image: url($url);
+  background-size: contain;
+  background-position: $position;
+  background-repeat: no-repeat;
+}
+
+// Background full - 背景图片拉伸填充
+@mixin bg-full($url) {
+  background-image: url($url);
+  background-size: 100% 100%;
+  background-position: center;
+  background-repeat: no-repeat;
+}

+ 6 - 0
app/assets/icons/language.svg

@@ -0,0 +1,6 @@
+<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="15" cy="15" r="14.5" fill="white" fill-opacity="0.5" stroke="white"/>
+<path d="M14.9997 6.66666C19.602 6.66666 23.3335 10.3974 23.3337 14.9997L23.3229 15.4284C23.1 19.8317 19.4584 23.3336 14.9997 23.3336L14.571 23.3229C10.168 23.0996 6.66669 19.4582 6.66669 14.9997C6.66686 10.3975 10.3976 6.66683 14.9997 6.66666ZM14.9997 8.33365C11.3181 8.33382 8.33385 11.318 8.33368 14.9997C8.33368 18.6815 11.3179 21.6665 14.9997 21.6667C18.6816 21.6667 21.6667 18.6816 21.6667 14.9997C21.6665 11.3179 18.6815 8.33365 14.9997 8.33365Z" fill="#3FBFBD"/>
+<path d="M21.667 15.8336H7.5V14.1667H21.667V15.8336Z" fill="#3FBFBD"/>
+<path d="M14.9997 6.66666C15.5087 6.66666 16.0071 6.71402 16.4909 6.80142C17.5827 8.30682 18.3336 11.4121 18.3337 14.9997C18.3337 18.5864 17.5823 21.6911 16.4909 23.1969C16.007 23.2844 15.5088 23.3336 14.9997 23.3336C14.4905 23.3336 13.9925 23.2844 13.5085 23.1969C12.4172 21.6909 11.6667 18.5862 11.6667 14.9997C11.6667 11.4124 12.4168 8.30694 13.5085 6.80142C13.9924 6.71396 14.4906 6.66668 14.9997 6.66666ZM14.9997 7.37271C14.961 7.40423 14.9068 7.4537 14.8395 7.53189C14.6016 7.80883 14.3192 8.29734 14.0524 9.03091C13.5232 10.4864 13.1667 12.5965 13.1667 14.9997C13.1667 17.4031 13.5231 19.5138 14.0524 20.9694C14.3192 21.7031 14.6015 22.1915 14.8395 22.4684C14.9066 22.5464 14.9611 22.5951 14.9997 22.6266C15.0384 22.5951 15.0936 22.5466 15.1608 22.4684C15.3988 22.1915 15.6802 21.7028 15.947 20.9694C16.4763 19.5138 16.8337 17.4031 16.8337 14.9997C16.8336 12.5964 16.4762 10.4864 15.947 9.03091C15.6802 8.29747 15.3988 7.80885 15.1608 7.53189C15.0934 7.45343 15.0384 7.40421 14.9997 7.37271Z" fill="#3FBFBD"/>
+</svg>

+ 10 - 0
app/assets/icons/locate.svg

@@ -0,0 +1,10 @@
+<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_105_3058)">
+<path d="M5.5 0.647141C7.77817 0.647141 9.625 2.49397 9.625 4.77214C9.62494 5.49609 9.35522 6.20369 8.99121 6.83464C8.62506 7.46921 8.14358 8.06011 7.67383 8.5612C7.20259 9.06386 6.73289 9.48642 6.38184 9.78288C6.20597 9.93139 6.05886 10.0495 5.95508 10.1305C5.90337 10.1709 5.86256 10.2027 5.83398 10.2243C5.8197 10.2351 5.80762 10.2429 5.7998 10.2487C5.79592 10.2516 5.79321 10.2549 5.79102 10.2565C5.79 10.2572 5.7887 10.258 5.78809 10.2585H5.78711C5.61629 10.3837 5.38273 10.3837 5.21191 10.2585L5.20898 10.2565C5.20679 10.2549 5.20408 10.2516 5.2002 10.2487C5.19238 10.2429 5.1803 10.2351 5.16602 10.2243C5.13744 10.2027 5.09663 10.1709 5.04492 10.1305C4.94114 10.0495 4.79403 9.93139 4.61816 9.78288C4.26711 9.48642 3.79741 9.06386 3.32617 8.5612C2.85642 8.06011 2.37494 7.46921 2.00879 6.83464C1.64478 6.20369 1.37506 5.49609 1.375 4.77214C1.375 2.49397 3.22183 0.647141 5.5 0.647141ZM5.5 1.61784C3.75787 1.61784 2.3457 3.03001 2.3457 4.77214C2.34576 5.26125 2.5313 5.79752 2.84961 6.34929C3.16583 6.89735 3.5942 7.42881 4.03418 7.89812C4.47253 8.36567 4.91287 8.76191 5.24414 9.04167C5.33929 9.12201 5.42609 9.1921 5.5 9.25163C5.57391 9.1921 5.66072 9.12201 5.75586 9.04167C6.08713 8.76191 6.52747 8.36567 6.96582 7.89812C7.4058 7.42881 7.83417 6.89735 8.15039 6.34929C8.4687 5.79752 8.65424 5.26125 8.6543 4.77214C8.6543 3.03001 7.24213 1.61784 5.5 1.61784ZM5.5 2.58855C5.76787 2.58855 5.9851 2.80511 5.98535 3.07292C5.98535 3.34094 5.76802 3.55827 5.5 3.55827C4.96405 3.55827 4.52945 3.99307 4.5293 4.52898C4.5293 5.06502 4.96396 5.49968 5.5 5.49968C6.03604 5.49968 6.4707 5.06502 6.4707 4.52898C6.47086 4.26109 6.68813 4.04362 6.95605 4.04362C7.2239 4.04372 7.44125 4.26114 7.44141 4.52898C7.44141 5.60106 6.57208 6.47038 5.5 6.47038C4.42792 6.47038 3.55859 5.60106 3.55859 4.52898C3.55875 3.45703 4.42801 2.58855 5.5 2.58855Z" fill="#4E5969"/>
+</g>
+<defs>
+<clipPath id="clip0_105_3058">
+<rect width="11" height="11" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 50 - 0
app/assets/icons/more.svg

@@ -0,0 +1,50 @@
+<svg width="51" height="51" viewBox="0 0 51 51" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g filter="url(#filter0_i_271_4724)">
+<rect x="11.9466" y="10.6667" width="38.4" height="38.4" rx="10.6667" fill="url(#paint0_linear_271_4724)"/>
+</g>
+<g style="mix-blend-mode:multiply" filter="url(#filter1_f_271_4724)">
+<rect x="11.7333" y="10.6667" width="33.0667" height="34.1333" rx="8.53333" fill="#8BF6C9"/>
+</g>
+<g style="mix-blend-mode:multiply" filter="url(#filter2_f_271_4724)">
+<rect x="5.33331" y="5.33334" width="35.2" height="33.0667" rx="8.53333" fill="#78E7C3"/>
+</g>
+<foreignObject x="-1.96799" y="-3.03461" width="48.7359" height="48.3849"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.05px);clip-path:url(#bgblur_0_271_4724_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="4.10129" x="2.64596" y="1.57934" width="39.508" height="39.157" rx="10.154" fill="url(#paint1_linear_271_4724)" fill-opacity="0.7" stroke="url(#paint2_linear_271_4724)" stroke-width="1.02532"/>
+<path d="M17.0668 23.4669C18.8341 23.4669 20.267 24.8998 20.267 26.6671V30.9337C20.267 32.7011 18.8341 34.1339 17.0668 34.1339H12.8002C11.033 34.1338 9.60096 32.7009 9.60095 30.9337V26.6671C9.60095 24.8999 11.033 23.4671 12.8002 23.4669H17.0668ZM32.4467 23.4669C34.214 23.4669 35.6469 24.8998 35.6469 26.6671V30.9337C35.6466 32.7009 34.2138 34.133 32.4467 34.133H28.1801C26.4129 34.1329 24.9801 32.7008 24.9799 30.9337V26.6671C24.9799 24.8999 26.4128 23.467 28.1801 23.4669H32.4467ZM17.0668 8.53336C18.834 8.53336 20.2669 9.96633 20.267 11.7336V16.0002C20.267 17.7675 18.8341 19.2003 17.0668 19.2003H12.8002C11.033 19.2002 9.59998 17.7674 9.59998 16.0002V11.7336C9.60009 9.96643 11.0331 8.53351 12.8002 8.53336H17.0668Z" fill="white" fill-opacity="0.7"/>
+<circle cx="29.8667" cy="13.8667" r="5.33333" fill="#FFFFEB"/>
+<defs>
+<filter id="filter0_i_271_4724" x="11.9466" y="10.6667" width="38.4" height="38.4" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
+<feOffset dx="-0.533333"/>
+<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
+<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
+<feBlend mode="normal" in2="shape" result="effect1_innerShadow_271_4724"/>
+</filter>
+<filter id="filter1_f_271_4724" x="6.39994" y="5.33335" width="43.7333" height="44.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
+<feGaussianBlur stdDeviation="2.66667" result="effect1_foregroundBlur_271_4724"/>
+</filter>
+<filter id="filter2_f_271_4724" x="-2.0504e-05" y="1.00136e-05" width="45.8667" height="43.7333" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
+<feGaussianBlur stdDeviation="2.66667" result="effect1_foregroundBlur_271_4724"/>
+</filter>
+<clipPath id="bgblur_0_271_4724_clip_path" transform="translate(1.96799 3.03461)"><rect x="2.64596" y="1.57934" width="39.508" height="39.157" rx="10.154"/>
+</clipPath><linearGradient id="paint0_linear_271_4724" x1="21.5466" y1="9.60002" x2="36.1244" y2="49.0667" gradientUnits="userSpaceOnUse">
+<stop stop-color="#DCF5C4"/>
+<stop offset="0.413135" stop-color="#90E593"/>
+<stop offset="1" stop-color="#73DDC3"/>
+</linearGradient>
+<linearGradient id="paint1_linear_271_4724" x1="8.5333" y1="3.20002" x2="36.2666" y2="40" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F2FDFF"/>
+<stop offset="1" stop-color="#F2FFDC"/>
+</linearGradient>
+<linearGradient id="paint2_linear_271_4724" x1="17.0758" y1="1.86221" x2="45.0081" y2="53.0216" gradientUnits="userSpaceOnUse">
+<stop stop-color="white"/>
+<stop offset="0.475962" stop-color="#F2FFDD" stop-opacity="0"/>
+<stop offset="1" stop-color="white"/>
+</linearGradient>
+</defs>
+</svg>

+ 5 - 0
app/assets/icons/profile.svg

@@ -0,0 +1,5 @@
+<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="15" cy="15" r="14.5" fill="white" fill-opacity="0.5" stroke="white"/>
+<path d="M21.6666 21.6667C21.6666 19.8257 18.6819 18.3333 15 18.3333C11.3181 18.3333 8.33331 19.8257 8.33331 21.6667" stroke="#3FBFBD" stroke-width="2" stroke-linecap="round"/>
+<path d="M19.1666 11.6667C19.1666 13.9679 17.3012 15.8333 15 15.8333C12.6988 15.8333 10.8333 13.9679 10.8333 11.6667C10.8333 9.36548 12.6988 7.5 15 7.5C16.2445 7.5 17.3615 8.04558 18.125 8.9106" stroke="#3FBFBD" stroke-width="2" stroke-linecap="round"/>
+</svg>

+ 3 - 0
app/assets/icons/sort-down.svg

@@ -0,0 +1,3 @@
+<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.6427 12.8333H8.35741C7.44692 12.8333 6.99094 13.914 7.63476 14.546L10.2473 17.1106C10.663 17.5187 11.3371 17.5187 11.7528 17.1106L14.3653 14.546C15.0091 13.914 14.5532 12.8333 13.6427 12.8333Z" fill="#4E5969"/>
+</svg>

+ 3 - 0
app/assets/icons/sort-up.svg

@@ -0,0 +1,3 @@
+<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.35733 9.16667L13.6426 9.16667C14.5531 9.16667 15.0091 8.08604 14.3652 7.45403L11.7527 4.88942C11.337 4.48131 10.6629 4.48131 10.2472 4.88942L7.63467 7.45403C6.99086 8.08604 7.44683 9.16667 8.35733 9.16667Z" fill="#4E5969"/>
+</svg>

+ 4 - 0
app/assets/icons/sort.svg

@@ -0,0 +1,4 @@
+<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.6427 12.8333H8.35741C7.44692 12.8333 6.99094 13.914 7.63476 14.546L10.2473 17.1106C10.663 17.5187 11.3371 17.5187 11.7528 17.1106L14.3653 14.546C15.0091 13.914 14.5532 12.8333 13.6427 12.8333Z" fill="#4E5969"/>
+<path d="M8.35733 9.16667L13.6426 9.16667C14.5531 9.16667 15.0091 8.08604 14.3652 7.45403L11.7527 4.88942C11.337 4.48131 10.6629 4.48131 10.2472 4.88942L7.63467 7.45403C6.99086 8.08604 7.44683 9.16667 8.35733 9.16667Z" fill="#4E5969"/>
+</svg>

+ 3 - 0
app/assets/icons/star-active.svg

@@ -0,0 +1,3 @@
+<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.6985 3.1763C18.9618 1.90383 21.0388 1.90374 22.302 3.1763L22.5598 3.46829L27.9573 10.3628L35.5627 12.9263C37.6062 13.6148 38.4255 16.0647 37.2063 17.8433L31.9045 25.5747L32.1243 32.6812C32.1951 34.9754 29.9296 36.6191 27.7708 35.8394L20.0002 33.0308L12.2297 35.8394C10.0709 36.6192 7.80539 34.9755 7.87622 32.6812L8.09399 25.5747L2.79419 17.8433C1.57491 16.0648 2.39351 13.615 4.43677 12.9263L12.0413 10.3628L17.4407 3.46829L17.6985 3.1763Z" fill="#FFDF51"/>
+</svg>

+ 3 - 0
app/assets/icons/star.svg

@@ -0,0 +1,3 @@
+<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.6985 3.1763C18.9618 1.90383 21.0388 1.90374 22.302 3.1763L22.5598 3.46829L27.9573 10.3628L35.5627 12.9263C37.6062 13.6148 38.4255 16.0647 37.2063 17.8433L31.9045 25.5747L32.1243 32.6812C32.1951 34.9754 29.9296 36.6191 27.7708 35.8394L20.0002 33.0308L12.2297 35.8394C10.0709 36.6192 7.80539 34.9755 7.87622 32.6812L8.09399 25.5747L2.79419 17.8433C1.57491 16.0648 2.39351 13.615 4.43677 12.9263L12.0413 10.3628L17.4407 3.46829L17.6985 3.1763Z" fill="#E5E6EB"/>
+</svg>

+ 3 - 0
app/assets/icons/tab-active.svg

@@ -0,0 +1,3 @@
+<svg width="42" height="22" viewBox="0 0 42 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M14.6323 22C14.3208 22 14.0093 21.9721 13.6979 21.9164C13.3864 21.9164 13.0438 21.8886 12.6701 21.8329C11.9226 21.6101 11.2997 21.1365 10.8013 20.4124C10.5522 19.9667 10.4276 19.3539 10.4276 18.5741C10.4276 17.7942 10.7079 16.5129 11.2685 14.7303C11.8291 12.9477 12.7324 10.3017 13.9782 6.79219C12.6078 7.23784 11.0194 7.76705 9.2129 8.37982C7.46874 8.99258 5.66229 9.60535 3.79355 10.2181C3.10834 10.4409 2.42314 10.4131 1.73793 10.1346C0.990434 9.80033 0.460957 9.29897 0.149499 8.63049C-0.0996663 8.01772 -0.0373748 7.40496 0.336374 6.79219C0.647831 6.12371 1.17731 5.65021 1.92481 5.37168C4.60334 4.48038 7.09499 3.64478 9.39978 2.86489C11.7669 2.085 13.6979 1.44438 15.1929 0.943026C16.6879 0.44167 17.4354 0.190994 17.4354 0.190994C18.0583 -0.0318312 18.6501 -0.0596861 19.2107 0.107433C20.0205 0.274551 20.6122 0.692349 20.986 1.36082C21.2974 1.86218 21.3909 2.30783 21.2663 2.69777C21.204 3.08772 21.1106 3.50552 20.986 3.95117C20.8614 4.17399 20.768 4.42467 20.7057 4.7032C20.6434 4.92603 20.5499 5.20456 20.4254 5.53879C19.9893 6.4858 19.5221 7.65563 19.0238 9.04829C18.5878 10.3852 18.1206 11.75 17.6222 13.1427C17.3108 13.9226 17.0616 14.5911 16.8747 15.1481C16.6879 15.7052 16.5321 16.2065 16.4076 16.6522C17.5911 16.4294 19.3041 15.7887 21.5466 14.7303C23.4776 13.7833 25.5333 12.6971 27.7135 11.4715C29.956 10.1903 32.2296 8.85332 34.5344 7.46066C35.0327 7.18213 35.531 6.9036 36.0294 6.62507C36.59 6.34654 37.0883 6.04015 37.5244 5.70591C38.2096 5.31597 38.9259 5.20456 39.6734 5.37168C40.4832 5.48309 41.1061 5.84518 41.5422 6.45795C41.9782 7.01501 42.1028 7.62778 41.9159 8.29625C41.7913 8.96473 41.3864 9.49394 40.7012 9.88388C40.2029 10.2181 39.6734 10.5524 39.1128 10.8866C38.6145 11.1651 38.085 11.4715 37.5244 11.8057C35.1573 13.1984 32.8525 14.5354 30.61 15.8166C28.3675 17.0978 26.2496 18.212 24.2563 19.159C20.2073 21.053 16.9993 22 14.6323 22Z" fill="#7CDFFF"/>
+</svg>

BIN
app/assets/images/common/audio.png


BIN
app/assets/images/common/diamond.png


BIN
app/assets/images/common/female.png


BIN
app/assets/images/common/male.png


BIN
app/assets/images/home/bubble.png


BIN
app/assets/images/home/header-bg.png


+ 62 - 0
app/components/common/Gender.vue

@@ -0,0 +1,62 @@
+<script setup lang="ts">
+import MaleIcon from '~/assets/images/common/male.png'
+import FemaleIcon from '~/assets/images/common/female.png'
+
+interface Props {
+  gender: 0 | 1 | 2 // 0=未知,1=男,2=女
+  age?: number
+}
+
+const props = defineProps<Props>()
+
+// If gender is unknown (0), don't display the component
+const shouldDisplay = computed(() => props.gender !== 0)
+
+const genderIcon = computed(() => {
+  return props.gender === 1 ? MaleIcon : FemaleIcon
+})
+
+const genderText = computed(() => {
+  return props.gender === 1 ? '男' : '女'
+})
+</script>
+
+<template>
+  <div
+    v-if="shouldDisplay"
+    class="gender-display inline-flex items-center justify-end"
+  >
+    <img
+      :src="genderIcon"
+      :alt="genderText"
+      class="gender-icon absolute"
+    >
+    <div class="age-container flex items-center justify-center">
+      <span class="age-text text-xs font-medium text-white z-10">
+        {{ age || '未知' }}
+      </span>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.gender-display {
+  width: 32px;
+  height: 15px;
+  position: relative;
+
+  .gender-icon {
+    object-fit: contain;
+  }
+
+  .age-container {
+    width: 14px;
+    margin-right: 4px;
+
+    .age-text {
+      font-size: 11px;
+      line-height: 14px;
+    }
+  }
+}
+</style>

+ 117 - 0
app/components/common/StarRating.vue

@@ -0,0 +1,117 @@
+<script setup lang="ts">
+import StarSvg from '~/assets/icons/star.svg'
+import StarActiveSvg from '~/assets/icons/star-active.svg'
+
+interface Props {
+  // Current rating value (1-5)
+  modelValue?: number
+  // Display mode: 'display' for readonly, 'rate' for interactive
+  mode?: 'display' | 'rate'
+  // Size of the star icon
+  size?: number
+  // Total number of stars
+  maxStars?: number
+}
+
+interface Emits {
+  (e: 'update:modelValue' | 'change', value: number): void
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  modelValue: 0,
+  mode: 'display',
+  size: 40,
+  maxStars: 5,
+})
+
+const emit = defineEmits<Emits>()
+
+const hoveredStar = ref<number | null>(null)
+
+const handleStarClick = (star: number) => {
+  if (props.mode === 'rate') {
+    emit('update:modelValue', star)
+    emit('change', star)
+  }
+}
+
+const handleStarHover = (star: number) => {
+  if (props.mode === 'rate') {
+    hoveredStar.value = star
+  }
+}
+
+const handleStarLeave = () => {
+  if (props.mode === 'rate') {
+    hoveredStar.value = null
+  }
+}
+
+const isStarActive = (star: number) => {
+  if (props.mode === 'rate' && hoveredStar.value !== null) {
+    return star <= hoveredStar.value
+  }
+  return star <= props.modelValue
+}
+</script>
+
+<template>
+  <div
+    class="star-rating flex items-center gap-1"
+    :class="{ 'star-rating--interactive': mode === 'rate' }"
+  >
+    <button
+      v-for="star in maxStars"
+      :key="`star-${star}`"
+      type="button"
+      class="star-button"
+      :class="{
+        'star-button--interactive': mode === 'rate',
+        'star-button--active': isStarActive(star),
+      }"
+      :disabled="mode === 'display'"
+      @click="handleStarClick(star)"
+      @mouseenter="handleStarHover(star)"
+      @mouseleave="handleStarLeave"
+    >
+      <component
+        :is="isStarActive(star) ? StarActiveSvg : StarSvg"
+        :style="{ width: `${size}px`, height: `${size}px` }"
+      />
+    </button>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.star-rating {
+  display: flex;
+  align-items: center;
+
+  .star-button {
+    padding: 0;
+    border: none;
+    background: none;
+    cursor: default;
+    transition: transform 0.15s ease;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    &--interactive {
+      cursor: pointer;
+
+      &:hover {
+        transform: scale(1.1);
+      }
+
+      &:active {
+        transform: scale(0.95);
+      }
+    }
+
+    &:disabled {
+      cursor: default;
+    }
+  }
+}
+</style>

+ 113 - 0
app/components/home/NavTabs.vue

@@ -0,0 +1,113 @@
+<script setup lang="ts">
+import TabActiveSvg from '~/assets/icons/tab-active.svg'
+import MoreSvg from '~/assets/icons/more.svg'
+
+type NavTab = {
+  label: string
+  value: string
+}
+
+type FeaturedGame = {
+  label: string
+  image?: string
+  isMore?: boolean
+}
+
+const assets = {
+  gameMobile: 'https://www.figma.com/api/mcp/asset/caa36564-9d8d-4632-976c-9fc682b3d8d9',
+  gameFreeFire: 'https://www.figma.com/api/mcp/asset/d475b197-3a66-4191-a968-215b443b6323',
+  gamePubg: 'https://www.figma.com/api/mcp/asset/3931728a-428b-4f5e-a603-108352c802a3',
+} as const
+
+const navTabs: NavTab[] = [
+  { label: 'Online Play', value: 'online' },
+  { label: 'PC Game', value: 'pc' },
+  { label: 'Activity', value: 'activity' },
+]
+
+const featuredGames: FeaturedGame[] = [
+  { label: 'Mobile Legends', image: assets.gameMobile },
+  { label: 'FREE FIRE', image: assets.gameFreeFire },
+  { label: 'PUBG', image: assets.gamePubg },
+  { label: 'More', isMore: true },
+]
+
+const activeTab = ref<NavTab['value']>(navTabs[0]?.value ?? '')
+const activeCategory = ref<FeaturedGame['label']>(featuredGames[0]?.label ?? '')
+</script>
+
+<template>
+  <section>
+    <nav class="flex items-center gap-4 text-base">
+      <div
+        v-for="tab in navTabs"
+        :key="tab.value"
+        class="relative flex items-center justify-center"
+        :class="activeTab === tab.value ? 'text-text-primary text-title' : 'text-text-secondary'"
+        @click="activeTab = tab.value"
+      >
+        <TabActiveSvg
+          v-if="activeTab === tab.value"
+          class="absolute z-0"
+        />
+        <span class="relative z-10 font-title">{{ tab.label }}</span>
+      </div>
+    </nav>
+
+    <div class="mt-2 flex gap-4 overflow-x-auto no-scrollbar">
+      <div
+        v-for="game in featuredGames"
+        :key="game.label"
+        class="category-item flex min-w-[72px] flex-col items-center justify-end gap-1"
+        @click="activeCategory = game.label"
+      >
+        <div
+          v-if="activeCategory === game.label"
+          class="category-item-active absolute z-0 mt-auto"
+        />
+        <div class="category-image flex relative z-10">
+          <template v-if="game.image">
+            <img
+              :src="game.image"
+              :alt="game.label"
+              class="size-full"
+              loading="lazy"
+            >
+          </template>
+          <template v-else>
+            <MoreSvg />
+          </template>
+        </div>
+        <p class="max-w-full truncate font-sans text-xs font-light text-[#1b1919] relative shrink-0 z-10 mb-2">
+          {{ game.label }}
+        </p>
+      </div>
+    </div>
+  </section>
+</template>
+
+<style lang="scss" scoped>
+.no-scrollbar {
+  -ms-overflow-style: none;
+  scrollbar-width: none;
+}
+
+.no-scrollbar::-webkit-scrollbar {
+  display: none;
+}
+.category-item {
+  width: 102px;
+
+  .category-item-active {
+    width: 102px;
+    height: 68px;
+    flex-shrink: 0;
+    border-radius: 30px 12px 12px 12px;
+    border: 1px solid #FFF;
+    background: linear-gradient(228deg, #DEF8DE 0%, #BDF5FF 73.53%);
+  }
+  .category-image {
+    @include size(50px, 50px);
+  }
+}
+</style>

+ 200 - 0
app/components/home/PlaymateCards.vue

@@ -0,0 +1,200 @@
+<script setup lang="ts">
+import LocateSvg from '~/assets/icons/locate.svg'
+
+type PlaymateCard = {
+  id: number
+  name: string
+  age: number
+  gender: 0 | 1 | 2 // 0=未知,1=男,2=女
+  location: string
+  latency: string
+  rate: number
+  rating: number
+  bio: string
+  avatar: string
+  gallery: string[]
+}
+
+const assets = {
+  avatar: 'https://www.figma.com/api/mcp/asset/90a1b39b-5843-4d4a-b3cf-87bc6d1041a5',
+  galleryOne: 'https://www.figma.com/api/mcp/asset/57bca0f4-24ad-4c4f-8aeb-57bc2db6dbe2',
+  galleryTwo: 'https://www.figma.com/api/mcp/asset/90a34be7-3f84-4b53-abfb-e52f9b328407',
+  galleryThree: 'https://www.figma.com/api/mcp/asset/28911d37-523b-4b61-930b-84625ec26511',
+} as const
+
+const playmateCards: PlaymateCard[] = [
+  {
+    id: 1,
+    name: 'Super Beautiful',
+    age: 20,
+    gender: 2,
+    location: 'Jakarta',
+    latency: '15"',
+    rate: 1000,
+    rating: 4,
+    bio: 'Hello brother, multiple seasons of S, online technology, online awareness in ...',
+    avatar: assets.avatar,
+    gallery: [assets.galleryOne, assets.galleryTwo, assets.galleryThree],
+  },
+  {
+    id: 2,
+    name: 'Super Beautiful',
+    age: 20,
+    gender: 2,
+    location: 'Timur',
+    latency: '15"',
+    rate: 1000,
+    rating: 4,
+    bio: 'Always ready for ranked matches, can guide team strategy and communication.',
+    avatar: assets.avatar,
+    gallery: [assets.galleryOne, assets.galleryTwo, assets.galleryThree],
+  },
+  {
+    id: 3,
+    name: 'Super Beautiful',
+    age: 20,
+    gender: 1,
+    location: 'Timur',
+    latency: '15"',
+    rate: 1000,
+    rating: 4,
+    bio: 'Specialized in Mobile Legends jungle role, offering duo coaching service.',
+    avatar: assets.avatar,
+    gallery: [assets.galleryOne, assets.galleryTwo, assets.galleryThree],
+  },
+]
+
+const formatRate = (rate: number) => new Intl.NumberFormat().format(rate)
+</script>
+
+<template>
+  <section class="flex flex-col gap-4 pb-10">
+    <article
+      v-for="card in playmateCards"
+      :key="card.id"
+      class="playmate-card p-3 overflow-hidden"
+    >
+      <div class="flex gap-3">
+        <div class="flex w-[76px] flex-col items-center">
+          <div class="avatar-box relative flex flex-col items-center">
+            <img
+              :src="card.avatar"
+              :alt="card.name"
+              class="avatar"
+              loading="lazy"
+            >
+            <div class="avatar-audio absolute bottom-0" />
+          </div>
+          <div class="flex items-center gap-1">
+            <div class="diamond-icon" />
+            <p class="text-xs">
+              <span class="font-title">{{ formatRate(card.rate) }}</span>
+              <span class="text-[#8e8e8e]">/h</span>
+            </p>
+          </div>
+        </div>
+
+        <div class="flex-1">
+          <div class="flex items-start justify-between gap-3">
+            <div class="flex items-center gap-2">
+              <h2 class="text-sm font-semibold">
+                {{ card.name }}
+              </h2>
+              <CommonGender
+                :gender="card.gender"
+                :age="card.age"
+              />
+            </div>
+            <div class="flex items-center gap-1 text-xs text-text-secondary">
+              <LocateSvg />
+              <span>{{ card.location }}</span>
+            </div>
+          </div>
+
+          <div class="mt-1">
+            <CommonStarRating
+              :model-value="card.rating"
+              mode="display"
+              :size="10"
+            />
+          </div>
+
+          <div class="summary mt-2 flex items-center">
+            <div class="summary-triangle z-0" />
+            <div class="summary-bg z-0" />
+            <div class="summary-content z-10">
+              <p class="text-xs text-text-description">
+                {{ card.bio }}
+              </p>
+            </div>
+          </div>
+
+          <div class="mt-2 flex gap-2">
+            <div
+              v-for="(image, index) in card.gallery"
+              :key="`${card.id}-gallery-${index}`"
+              class="h-[72px] w-[72px] overflow-hidden rounded-xl"
+            >
+              <img
+                :src="image"
+                :alt="`${card.name} gallery ${index + 1}`"
+                class="size-full object-cover"
+                loading="lazy"
+              >
+            </div>
+          </div>
+        </div>
+      </div>
+    </article>
+  </section>
+</template>
+
+<style lang="scss" scoped>
+.playmate-card {
+  @include size(100%, auto);
+  flex-shrink: 0;
+  border-radius: 49px 12px 12px 12px;
+  background: #FFF;
+
+  .diamond-icon {
+    @include size(10px, 10px);
+    @include bg('~/assets/images/common/diamond.png');
+  }
+}
+.avatar-box {
+  .avatar {
+    @include size(76px, 76px);
+    border-radius: 50%;
+  }
+  .avatar-audio {
+    @include size(64px, 22px);
+    @include bg('~/assets/images/common/audio.png');
+  }
+}
+.summary {
+  @include size(100%, 38px);
+  position: relative;
+
+  .summary-triangle {
+    position: absolute;
+    width: 0;
+    height: 0;
+    border-style: solid;
+    border-width: 0 4px 4px 4px;
+    border-color: transparent transparent rgba(217, 255, 248, 0.70) transparent;
+    left: 12px;
+    top: -4px;
+  }
+  .summary-bg {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    border-radius: 12px;
+    background: linear-gradient(90deg, rgba(217, 255, 248, 0.70) 0%, rgba(249, 255, 242, 0.70) 99.53%);
+  }
+  .summary-content {
+    position: relative;
+    padding: 0 12px;
+  }
+}
+</style>

+ 91 - 0
app/components/home/SortSection.vue

@@ -0,0 +1,91 @@
+<script setup lang="ts">
+import SortSvg from '~/assets/icons/sort.svg'
+import SortUpSvg from '~/assets/icons/sort-up.svg'
+import SortDownSvg from '~/assets/icons/sort-down.svg'
+
+type SortOption = {
+  label: string
+  value: string
+}
+
+type SortDirection = 'none' | 'asc' | 'desc'
+
+const sortOptions: SortOption[] = [
+  { label: '评分', value: 'rating' },
+  { label: '价格', value: 'price' },
+]
+
+const activeSort = ref<SortOption['value']>(sortOptions[0]?.value ?? '')
+const sortDirections = ref<Record<string, SortDirection>>({
+  rating: 'none',
+  price: 'none',
+})
+
+// Handle sort click
+const handleSort = (sortValue: string) => {
+  const currentDirection = sortDirections.value[sortValue]
+
+  // Cycle through: none -> desc -> asc -> none -> ...
+  if (currentDirection === 'none') {
+    sortDirections.value[sortValue] = 'desc'
+  }
+  else if (currentDirection === 'desc') {
+    sortDirections.value[sortValue] = 'asc'
+  }
+  else {
+    sortDirections.value[sortValue] = 'none'
+  }
+
+  activeSort.value = sortValue
+}
+
+// Get sort icon component based on direction
+const getSortIcon = (sortValue: string) => {
+  const direction = sortDirections.value[sortValue]
+  if (direction === 'asc')
+    return SortUpSvg
+  if (direction === 'desc')
+    return SortDownSvg
+  return SortSvg
+}
+</script>
+
+<template>
+  <section class="flex flex-wrap items-center justify-between bg-bg-primary z-50">
+    <div class="flex gap-4 text-xs font-normal text-text-secondary">
+      <button
+        v-for="sortOption in sortOptions"
+        :key="sortOption.value"
+        class="flex items-center gap-1 py-1"
+        :class="sortDirections[sortOption.value] !== 'none' ? 'text-text-primary' : ''"
+        @click="handleSort(sortOption.value)"
+      >
+        {{ sortOption.label }}
+        <component :is="getSortIcon(sortOption.value)" />
+      </button>
+    </div>
+    <van-button
+      round
+      size="small"
+      class="find-btn flex items-center justify-center"
+      plain
+      type="primary"
+    >
+      <span class="text-xs font-semibold text-text-secondary">Find your partner</span>
+      <van-icon
+        class="ml-1 text-text-secondary"
+        name="arrow"
+        size="14"
+      />
+    </van-button>
+  </section>
+</template>
+
+<style lang="scss" scoped>
+.find-btn {
+  @include size(152px, 40px);
+  border-radius: 20px;
+  border: 1px solid #FFF;
+  background: linear-gradient(90deg, rgba(183, 238, 255, 0.60) 0.01%, rgba(221, 255, 183, 0.60) 26.99%, rgba(255, 255, 255, 0.60) 75.07%);
+}
+</style>

+ 22 - 180
app/pages/index.vue

@@ -1,197 +1,39 @@
 <script setup lang="ts">
-// This page requires authentication
+import LanguageSvg from '~/assets/icons/language.svg'
+import ProfileSvg from '~/assets/icons/profile.svg'
+
 definePageMeta({
   auth: false,
 })
-
-const { logout, userProfile, isAuthenticated } = useAuth()
-const router = useRouter()
-
-const handleLogout = () => {
-  logout()
-  router.push('/login')
-}
 </script>
 
 <template>
-  <div class="home-container">
-    <div class="home-content">
-      <header class="header">
-        <h1 class="title">
-          欢迎来到 Lanu
-        </h1>
-        <button
-          v-if="isAuthenticated"
-          class="logout-button"
-          @click="handleLogout"
-        >
-          退出登录
-        </button>
-      </header>
+  <div class="min-h-screen bg-bg-primary text-text-primary">
+    <div class="relative mx-auto flex flex-col px-4 pt-6 pb-8">
+      <div class="header-background absolute left-0 right-0 top-0" />
 
-      <div class="placeholder-content">
-        <div class="placeholder-card">
-          <div class="placeholder-icon">
-            🏠
-          </div>
-          <h2 class="placeholder-title">
-            首页占位
-          </h2>
-          <p class="placeholder-description">
-            这是首页的占位内容,后续会添加更多功能
-          </p>
+      <header class="z-10 flex items-center justify-between">
+        <div class="bg-white/80 px-4 py-2 text-sm font-semibold shadow-[0_12px_30px_rgba(110,110,110,0.2)] backdrop-blur">
+          logo占位
         </div>
-
-        <div class="info-section">
-          <h3>用户信息</h3>
-          <div
-            v-if="isAuthenticated"
-            class="user-info"
-          >
-            <p>已登录状态</p>
-            <p class="user-token">
-              {{ userProfile }}
-            </p>
-          </div>
+        <div class="flex items-center gap-2">
+          <LanguageSvg />
+          <ProfileSvg />
         </div>
+      </header>
+
+      <div class="relative z-10 flex flex-col">
+        <HomeNavTabs class="mt-2" />
+        <HomeSortSection class="sticky top-0 py-2" />
+        <HomePlaymateCards />
       </div>
     </div>
   </div>
 </template>
 
-<style scoped>
-.home-container {
-  min-height: 100vh;
-  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
-  padding: 20px;
-}
-
-.home-content {
-  max-width: 1200px;
-  margin: 0 auto;
-}
-
-.header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  padding: 30px;
-  background: white;
-  border-radius: 16px;
-  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
-  margin-bottom: 30px;
-}
-
-.title {
-  font-size: 32px;
-  font-weight: 700;
-  color: #2d3748;
-  margin: 0;
-}
-
-.logout-button {
-  padding: 12px 24px;
-  background: #667eea;
-  color: white;
-  border: none;
-  border-radius: 8px;
-  font-size: 16px;
-  font-weight: 500;
-  cursor: pointer;
-  transition: all 0.3s ease;
-}
-
-.logout-button:hover {
-  background: #5568d3;
-  transform: translateY(-2px);
-  box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
-}
-
-.placeholder-content {
-  display: grid;
-  gap: 30px;
-  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
-}
-
-.placeholder-card {
-  background: white;
-  border-radius: 16px;
-  padding: 40px;
-  text-align: center;
-  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
-  transition: transform 0.3s ease;
-}
-
-.placeholder-card:hover {
-  transform: translateY(-4px);
-}
-
-.placeholder-icon {
-  font-size: 64px;
-  margin-bottom: 20px;
-}
-
-.placeholder-title {
-  font-size: 24px;
-  font-weight: 600;
-  color: #2d3748;
-  margin: 0 0 15px 0;
-}
-
-.placeholder-description {
-  font-size: 16px;
-  color: #718096;
-  line-height: 1.6;
-  margin: 0;
-}
-
-.info-section {
-  background: white;
-  border-radius: 16px;
-  padding: 40px;
-  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
-}
-
-.info-section h3 {
-  font-size: 20px;
-  font-weight: 600;
-  color: #2d3748;
-  margin: 0 0 20px 0;
-}
-
-.user-info {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-}
-
-.user-info p {
-  margin: 0;
-  color: #4a5568;
-  font-size: 14px;
-}
-
-.user-token {
-  font-family: monospace;
-  background: #f7fafc;
-  padding: 10px;
-  border-radius: 6px;
-  word-break: break-all;
-}
-
-@media (max-width: 768px) {
-  .header {
-    flex-direction: column;
-    gap: 20px;
-    text-align: center;
-  }
-
-  .title {
-    font-size: 24px;
-  }
-
-  .placeholder-content {
-    grid-template-columns: 1fr;
-  }
+<style lang="scss" scoped>
+.header-background {
+  @include size(100%, 173px);
+  @include bg('~/assets/images/home/header-bg.png');
 }
 </style>

+ 25 - 2
nuxt.config.ts

@@ -5,7 +5,7 @@ import svgLoader from 'vite-svg-loader'
 export default defineNuxtConfig({
   modules: ['@nuxt/eslint', '@vant/nuxt', '@pinia/nuxt'],
   devtools: { enabled: true },
-  css: ['./app/assets/css/main.css'],
+  css: ['~/assets/css/main.css'],
   runtimeConfig: {
     public: {
       apiBase: process.env.NUXT_PUBLIC_API_BASE,
@@ -17,8 +17,31 @@ export default defineNuxtConfig({
   vite: {
     plugins: [
       tailwindcss(),
-      svgLoader(),
+      svgLoader({
+        svgoConfig: {
+          multipass: true,
+          plugins: [
+            {
+              name: 'preset-default',
+              params: {
+                overrides: {
+                  removeViewBox: false,
+                },
+              },
+            },
+          ],
+        },
+      }),
     ],
+    css: {
+      preprocessorOptions: {
+        scss: {
+          additionalData: `
+            @use "~/assets/css/mixins.scss" as *;
+          `,
+        },
+      },
+    },
   },
   eslint: {
     config: {

BIN
public/Poppins-SemiBold.ttf


+ 2 - 0
vite-env.d.ts

@@ -0,0 +1,2 @@
+/// <reference types="vite/client" />
+/// <reference types="vite-svg-loader" />