整体UI调整

This commit is contained in:
谢宇宁
2026-10-26 19:51:53 +07:00
parent 61d0a11dfa
commit c8895f407c
59 changed files with 0 additions and 4104 deletions
-3
View File
@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9457 9.54601C11.773 9.71335 11.6937 9.95535 11.733 10.1927L12.3257 13.4727C12.3757 13.7507 12.2584 14.032 12.0257 14.1927C11.7977 14.3593 11.4944 14.3793 11.2457 14.246L8.29305 12.706C8.19038 12.6513 8.07638 12.622 7.95971 12.6187H7.77905C7.71638 12.628 7.65505 12.648 7.59905 12.6787L4.64571 14.226C4.49971 14.2993 4.33438 14.3253 4.17238 14.2993C3.77771 14.2247 3.51438 13.8487 3.57905 13.452L4.17238 10.172C4.21171 9.93268 4.13238 9.68935 3.95971 9.51935L1.55238 7.18602C1.35105 6.99068 1.28105 6.69735 1.37305 6.43268C1.46238 6.16868 1.69038 5.97602 1.96571 5.93268L5.27905 5.45202C5.53105 5.42602 5.75238 5.27268 5.86571 5.04602L7.32571 2.05268C7.36038 1.98602 7.40505 1.92468 7.45905 1.87268L7.51905 1.82602C7.55038 1.79135 7.58638 1.76268 7.62638 1.73935L7.69905 1.71268L7.81238 1.66602H8.09305C8.34371 1.69202 8.56438 1.84202 8.67971 2.06602L10.159 5.04602C10.2657 5.26402 10.473 5.41535 10.7124 5.45202L14.0257 5.93268C14.3057 5.97268 14.5397 6.16602 14.6324 6.43268C14.7197 6.70002 14.6444 6.99335 14.439 7.18602L11.9457 9.54601Z" fill="#EBE5F2"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.5037 4H8.49635C6.08759 4 4 6.1581 4 8.64822V17.9447C4 20.4348 6.08759 22.5929 8.49635 22.5929H8.9781C8.9781 22.5929 8.9781 22.7589 9.13869 22.7589L10.9051 24.751C11.2263 25.083 11.708 25.083 11.8686 24.751L13.4745 22.9249L13.635 22.7589H21.5037C23.9124 22.7589 26 20.6008 26 18.1107V8.64822C26 6.1581 23.9124 4 21.5037 4ZM8.81752 15.1225C7.85401 15.1225 7.21168 14.2925 7.21168 13.2964C7.21168 12.3004 8.0146 11.4704 8.9781 11.4704C9.94161 11.4704 10.7445 12.3004 10.7445 13.2964C10.5839 14.2925 9.78102 15.1225 8.81752 15.1225ZM14.9197 15.1225C13.9562 15.1225 13.1533 14.2925 13.1533 13.2964C13.1533 12.3004 13.9562 11.4704 14.9197 11.4704C15.8832 11.4704 16.6861 12.3004 16.6861 13.2964C16.6861 14.2925 15.8832 15.1225 14.9197 15.1225ZM21.1825 15.1225C20.219 15.1225 19.4161 14.2925 19.4161 13.2964C19.4161 12.3004 20.219 11.4704 21.1825 11.4704C22.146 11.4704 22.9489 12.3004 22.9489 13.2964C22.7883 14.2925 21.9854 15.1225 21.1825 15.1225Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.5937 9.02503H23.7499C24.0815 9.02503 24.3994 9.15673 24.6338 9.39115C24.8682 9.62557 24.9999 9.94351 24.9999 10.275V16.525C24.9999 16.8566 24.8682 17.1745 24.6338 17.4089C24.3994 17.6433 24.0815 17.775 23.7499 17.775H21.4743L16.2437 10.2188L15.6356 9.35753L11.9131 11.0763C11.6725 11.1873 11.4028 11.2191 11.1431 11.1669C10.0068 10.9388 9.62556 10.5413 9.99931 9.97378C10.3612 9.42441 11.9012 8.31941 14.6199 6.65753C14.6912 6.61378 14.7649 6.57628 14.8406 6.54628C15.9824 6.00878 17.2856 6.20253 18.7493 7.12816C19.9594 7.89544 21.248 8.53133 22.5931 9.02503H22.5937ZM5.85369 17.7107C5.60502 17.6275 5.38878 17.4683 5.23556 17.2556C5.08235 17.0428 4.99992 16.7872 4.99994 16.525V10.275C4.99994 9.94351 5.13163 9.62557 5.36606 9.39115C5.60048 9.15673 5.91842 9.02503 6.24994 9.02503H9.13244L9.12744 9.02941C8.95343 9.18616 8.8155 9.3788 8.72318 9.59403C8.63086 9.80927 8.58634 10.042 8.5927 10.2761C8.59905 10.5102 8.65614 10.7401 8.76 10.9501C8.86387 11.16 9.01205 11.3448 9.19431 11.4919C9.64481 11.8555 10.1817 12.0965 10.7527 12.1914C11.3238 12.2864 11.9098 12.2321 12.4537 12.0338L15.2656 11.0088L17.2374 13.8588C17.2981 13.9169 17.3527 13.981 17.4006 14.05L20.8568 19.045C20.9589 19.1926 21.028 19.3604 21.0595 19.5371C21.0909 19.7138 21.084 19.8951 21.0391 20.0689C20.9942 20.2426 20.9124 20.4046 20.7993 20.544C20.6862 20.6833 20.5445 20.7966 20.3837 20.8763L14.8624 23.6082C14.6062 23.735 14.3249 23.7663 14.0624 23.7132L14.2668 23.5063C14.7294 23.0383 14.9888 22.4068 14.9888 21.7488C14.9888 21.0908 14.7294 20.4593 14.2668 19.9913L10.2668 15.9438C10.0924 15.7673 9.88473 15.6272 9.65578 15.5315C9.42684 15.4359 9.18118 15.3866 8.93306 15.3866C8.68494 15.3866 8.43929 15.4359 8.21034 15.5315C7.9814 15.6272 7.77371 15.7673 7.59931 15.9438L5.85306 17.7107H5.85369ZM9.77244 17.1732L13.3199 20.7619C13.4729 20.9168 13.5828 21.1089 13.6387 21.3192C13.6946 21.5296 13.6946 21.7509 13.6387 21.9613L13.4599 22.6382C13.4197 22.7899 13.343 22.9296 13.2364 23.045C13.1299 23.1603 12.9968 23.2479 12.8487 23.3001C12.7005 23.3523 12.5419 23.3675 12.3866 23.3444C12.2313 23.3213 12.0839 23.2606 11.9574 23.1675L7.39619 19.8125C7.25165 19.7062 7.1317 19.57 7.0445 19.4131C6.9573 19.2563 6.90489 19.0825 6.89084 18.9036C6.87679 18.7247 6.90142 18.5449 6.96306 18.3763C7.0247 18.2078 7.1219 18.0545 7.24806 17.9269L7.99369 17.1719C8.10995 17.0543 8.24839 16.9609 8.40101 16.8971C8.55362 16.8334 8.71736 16.8006 8.88275 16.8006C9.04814 16.8006 9.21188 16.8334 9.3645 16.8971C9.51711 16.9609 9.65556 17.0543 9.77181 17.1719L9.77244 17.1732Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.244 2.25H21.552L14.325 10.51L22.827 21.75H16.17L10.956 14.933L4.99001 21.75H1.68003L9.41002 12.915L1.25403 2.25H8.08004L12.793 8.481L18.244 2.25ZM17.083 19.77H18.916L7.08401 4.12601H5.117L17.083 19.77Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 334 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.00004 14.6673C4.32004 14.6673 1.33337 11.6873 1.33337 8.00065C1.33337 4.32065 4.32004 1.33398 8.00004 1.33398C11.6867 1.33398 14.6667 4.32065 14.6667 8.00065C14.6667 11.6873 11.6867 14.6673 8.00004 14.6673ZM10.1267 10.474C10.2067 10.5207 10.2934 10.5473 10.3867 10.5473C10.5534 10.5473 10.72 10.4607 10.8134 10.3007C10.9534 10.0673 10.88 9.76065 10.64 9.61398L8.26671 8.20065V5.12065C8.26671 4.84065 8.04004 4.62065 7.76671 4.62065C7.49337 4.62065 7.26671 4.84065 7.26671 5.12065V8.48732C7.26671 8.66065 7.36004 8.82065 7.51337 8.91398L10.1267 10.474Z" fill="#EBE5F2"/>
</svg>

Before

Width:  |  Height:  |  Size: 725 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.68029 1.8439L13.569 5.35274C13.8054 5.52246 13.9981 5.74604 14.131 6.005C14.2639 6.26395 14.3333 6.55083 14.3333 6.8419V12.5002C14.3333 13.5127 13.5125 14.3336 12.5 14.3336H9.99996V11.6669C9.99996 10.5624 9.10446 9.6669 7.99996 9.6669C6.89546 9.6669 5.99996 10.5624 5.99996 11.6669V14.3336H3.49996C2.48746 14.3336 1.66663 13.5127 1.66663 12.5002V6.84207C1.66664 6.55097 1.73597 6.26406 1.86888 6.00507C2.00179 5.74609 2.19447 5.52247 2.43096 5.35274L7.31963 1.8439C7.51792 1.70156 7.75587 1.625 7.99996 1.625C8.24405 1.625 8.482 1.70156 8.68029 1.8439Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 684 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="22" height="21" viewBox="0 0 22 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.25731 0.913926C9.45132 0.632201 9.71152 0.401732 10.0154 0.242481C10.3192 0.08323 10.6575 0 11.001 0C11.3445 0 11.6828 0.08323 11.9867 0.242481C12.2905 0.401732 12.5507 0.632201 12.7447 0.913926L15.3437 4.68251C15.5195 4.93642 15.7769 5.12351 16.0737 5.21037L20.4841 6.50189C20.8135 6.59856 21.1142 6.7735 21.3603 7.01158C21.6064 7.24966 21.7904 7.54373 21.8965 7.86835C22.0026 8.19298 22.0276 8.53841 21.9693 8.87477C21.9109 9.21114 21.7711 9.52832 21.5617 9.79892L18.7581 13.4195C18.5689 13.6639 18.47 13.9655 18.4796 14.2729L18.6054 18.8405C18.6148 19.1817 18.5404 19.52 18.3886 19.8263C18.2369 20.1325 18.0124 20.3974 17.7346 20.598C17.4567 20.7987 17.1339 20.9291 16.794 20.978C16.4541 21.0268 16.1073 20.9926 15.7836 20.8784L11.452 19.3473C11.16 19.2441 10.8411 19.2441 10.5491 19.3473L6.21747 20.8784C5.89381 20.9929 5.54696 21.0272 5.20694 20.9785C4.86692 20.9297 4.54398 20.7993 4.26607 20.5986C3.98816 20.3979 3.76366 20.1329 3.61199 19.8266C3.46033 19.5202 3.38607 19.1817 3.39565 18.8405L3.52243 14.2729C3.53106 13.9651 3.43269 13.6637 3.24389 13.4195L0.43936 9.79892C0.229598 9.52832 0.0893836 9.21098 0.0308657 8.87438C-0.0276522 8.53778 -0.00270912 8.19208 0.103534 7.86722C0.209777 7.54237 0.394116 7.24816 0.640579 7.01008C0.887043 6.772 1.1882 6.59724 1.51795 6.50093L5.92741 5.20847C6.22515 5.12256 6.48255 4.93546 6.65736 4.68155L9.25731 0.912972V0.913926Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="18" height="16" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.86557 15.7314C9.3385 16.0897 8.66224 16.0897 8.13564 15.7304C6.6909 14.745 5.6664 14.1472 3.75858 12.524C-4.52307 6.04287 2.54537 -3.88171 8.99967 1.58305C15.4375 -3.87772 22.5318 6.0274 14.2408 12.524C12.3447 14.1387 11.3042 14.7529 9.86557 15.7314Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 382 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.5208 7.77456C16.017 7.77325 15.5197 7.66441 15.0635 7.45569C14.6074 7.24696 14.2036 6.94338 13.8803 6.56623L9.53973 8.58588C9.76828 9.12026 9.88395 9.69419 9.87982 10.2733C9.87569 10.8523 9.75184 11.4247 9.51569 11.9559L12.9487 13.6921C13.418 12.9583 14.1511 12.4222 15.0043 12.1887C15.8576 11.9552 16.7693 12.0413 17.5609 12.4301C18.3525 12.8189 18.9668 13.4823 19.2834 14.2902C19.6 15.0982 19.596 15.9925 19.2723 16.7978C18.9485 17.603 18.3284 18.2612 17.5334 18.6432C16.7384 19.0253 15.8258 19.1037 14.9747 18.863C14.1236 18.6223 13.3953 18.08 12.9325 17.3423C12.4697 16.6045 12.3058 15.7247 12.4731 14.8751L8.81557 13.0228C8.22859 13.7043 7.44038 14.1921 6.55866 14.4196C5.67695 14.6472 4.74476 14.6034 3.88953 14.2941C3.0343 13.9849 2.29777 13.4253 1.78061 12.692C1.26345 11.9586 0.990907 11.0872 1.00023 10.1969C1.00956 9.30659 1.3003 8.44082 1.83271 7.71793C2.36512 6.99504 3.11322 6.45032 3.97474 6.15822C4.83627 5.86611 5.76918 5.84089 6.64594 6.08599C7.52271 6.3311 8.30054 6.83457 8.87312 7.52758L13.2497 5.49002C13.0102 4.8109 12.998 4.07504 13.2149 3.38871C13.4318 2.70238 13.8665 2.10113 14.4563 1.67179C15.0461 1.24245 15.7605 1.00725 16.4961 1.00016C17.2318 0.993081 17.9507 1.21448 18.5491 1.63238C19.1475 2.05028 19.5943 2.64305 19.825 3.32506C20.0557 4.00708 20.0584 4.74304 19.8326 5.42664C19.6069 6.11024 19.1644 6.70609 18.5691 7.12813C17.9738 7.55017 17.2565 7.77654 16.5208 7.77456Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 4L9 7L6 10" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="#8F8F9F"/>
</svg>

Before

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9024 1.90607H13.9699L9.45307 7.06857L14.7668 14.0936H10.6062L7.34744 9.83294L3.61868 14.0936H1.54994L6.38119 8.57169L1.28369 1.90607H5.54995L8.49557 5.80044L11.9024 1.90607ZM11.1768 12.8561H12.3224L4.92743 3.07857H3.69805L11.1768 12.8561Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 371 B

-5
View File
@@ -1,5 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 15V5" stroke="#F12B9E" stroke-width="1.66667" stroke-linecap="round"/>
<path d="M10 15.0003L10 10.417" stroke="#F12B9E" stroke-width="1.66667" stroke-linecap="round"/>
<path d="M5 15.0003V9.16699" stroke="#F12B9E" stroke-width="1.66667" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 378 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.99963 10.1162C10.8921 10.1162 13.3336 10.587 13.3336 12.4004C13.3332 14.2141 10.8758 14.667 7.99963 14.667C5.10803 14.667 2.66663 14.1971 2.66663 12.3838C2.66678 10.57 5.12345 10.1162 7.99963 10.1162ZM7.99963 1.33398C9.95903 1.33398 11.5289 2.90332 11.5289 4.86133C11.5289 6.81935 9.95904 8.38965 7.99963 8.38965C6.04105 8.38947 4.47034 6.81924 4.47034 4.86133C4.47035 2.90343 6.04106 1.33416 7.99963 1.33398Z" fill="#EBE5F2"/>
</svg>

Before

Width:  |  Height:  |  Size: 543 B

-5
View File
@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="18" height="18" rx="3" stroke="white" stroke-width="2"/>
<path d="M12 7H15C16.1046 7 17 7.89543 17 9V12" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M12 17L9 17C7.89543 17 7 16.1046 7 15L7 12" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 400 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.25 18.5004C16.25 17.9481 15.8023 17.5004 15.25 17.5004H13.75C11.6995 17.4996 9.68779 18.0593 7.93234 19.119C6.46694 20.0036 5.22666 21.2085 4.30157 22.6382C4.14181 22.8851 3.74985 22.7944 3.75 22.5004C3.75 15.9332 8.81391 10.5491 15.2501 10.0398C15.8007 9.99619 16.25 9.55264 16.25 9.00035V5.83098C16.25 4.99247 17.2199 4.5263 17.8747 5.05011L27.7739 12.9695C28.2743 13.3698 28.2743 14.1309 27.7739 14.5312L17.8747 22.4506C17.2199 22.9744 16.25 22.5082 16.25 21.6697V18.5004Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

-5
View File
@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="18" height="18" rx="3" stroke="white" stroke-width="2"/>
<path d="M18 11L15 11C13.8954 11 13 10.1046 13 9L13 6" stroke-width="2" stroke-linecap="round" stroke="white"/>
<path d="M6 13H9C10.1046 13 11 13.8954 11 15V18" stroke-width="2" stroke-linecap="round" stroke="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 403 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

-12
View File
@@ -1,12 +0,0 @@
import type { Metadata } from "next";
import { FavoritesPage } from "@/components/favorites/favorites-page";
export const metadata: Metadata = {
title: "我的收藏 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <FavoritesPage />;
}
-12
View File
@@ -1,12 +0,0 @@
import type { Metadata } from "next";
import { HistoryPage } from "@/components/history/history-page";
export const metadata: Metadata = {
title: "观看历史 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <HistoryPage />;
}
-12
View File
@@ -1,12 +0,0 @@
import type { Metadata } from "next";
import { LikesPage } from "@/components/likes/likes-page";
export const metadata: Metadata = {
title: "我的点赞 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <LikesPage />;
}
-12
View File
@@ -1,12 +0,0 @@
import type { Metadata } from "next";
import { ProfilePage } from "@/components/profile/profile-page";
export const metadata: Metadata = {
title: "个人中心 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <ProfilePage />;
}
-12
View File
@@ -1,12 +0,0 @@
import type { Metadata } from "next";
import { SettingsPage } from "@/components/settings/settings-page";
export const metadata: Metadata = {
title: "账号设置 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <SettingsPage />;
}
-12
View File
@@ -1,12 +0,0 @@
import type { Metadata } from "next";
import { EditProfilePage } from "@/components/settings/edit-profile-page";
export const metadata: Metadata = {
title: "编辑个人资料 - 海角社区",
robots: { index: false, follow: false },
};
export default function Page() {
return <EditProfilePage />;
}
@@ -1,212 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { EmptyState } from "@/components/common/empty-state";
import { Pagination } from "@/components/common/pagination";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
type CollectionPageProps<T extends string, S extends string> = {
title: string;
subtitle: string;
breadcrumb: string;
types: readonly T[];
mobileTypes?: readonly T[];
scopes: readonly S[];
defaultScope: S;
totalPages: number;
getItems: (type: T, scope: S, page: number) => ShortDramaItem[];
clearable?: boolean;
showMobileScopes?: boolean;
showPcScopes?: boolean;
showVideoScopes?: boolean;
showMobileEpisodes?: boolean;
emptyText: string;
};
type FilterPillsProps<T extends string> = {
options: readonly T[];
value: T;
onChange: (value: T) => void;
className?: string;
itemClassName: string;
};
function FilterPills<T extends string>({ options, value, onChange, className = "", itemClassName }: FilterPillsProps<T>) {
return (
<div className={`items-center ${className}`}>
{options.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === value}
onClick={() => onChange(option)}
className={`cursor-pointer flex items-center justify-center rounded-2xl transition-colors ${itemClassName} ${
option === value ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{option}
</button>
))}
</div>
);
}
export function CollectionPage<T extends string, S extends string>({
title,
subtitle,
breadcrumb,
types,
mobileTypes = types,
scopes,
defaultScope,
totalPages,
getItems,
clearable = false,
showMobileScopes = true,
showPcScopes = true,
showVideoScopes = true,
showMobileEpisodes = true,
emptyText,
}: CollectionPageProps<T, S>) {
const router = useRouter();
const [type, setType] = useState<T>(types[0]);
const [scope, setScope] = useState<S>(defaultScope);
const [page, setPage] = useState(1);
const [cleared, setCleared] = useState(false);
const topRef = useRef<HTMLDivElement>(null);
const items = cleared ? [] : getItems(type, scope, page);
const isVideo = type === "视频";
const PcCard = isVideo ? VideoCard : ShortDramaCard;
const MobileCard = isVideo ? H5VideoCard : H5ShortDramaCard;
const changeType = (next: T) => {
setType(next);
setPage(1);
};
const changeScope = (next: S) => {
setScope(next);
setPage(1);
};
const changePage = (next: number) => {
setPage(next);
topRef.current?.scrollIntoView({ behavior: "smooth" });
};
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push("/profile");
};
return (
<main data-component="collection-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
<div className="relative flex h-12 items-center justify-center px-4 lg:hidden">
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<h1 className="text-base leading-[22px] font-bold text-white">{title}</h1>
{clearable ? (
<button
type="button"
onClick={() => setCleared(true)}
disabled={cleared}
className="cursor-pointer absolute right-4 text-xs leading-[18px] font-bold text-[#F12B9E] disabled:opacity-40"
>
清空
</button>
) : null}
</div>
<div
ref={topRef}
data-pc-scale
className="mx-auto flex w-full scroll-mt-24 flex-col gap-3 px-4 py-3 lg:w-[1700px] lg:gap-5 lg:px-0 lg:py-[30px] lg:[zoom:var(--page-scale,1)]"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: "个人中心", href: "/profile" },
{ name: breadcrumb },
]}
/>
<div className="hidden items-center lg:flex">
<div className="flex flex-1 flex-col gap-px">
<h2 className="text-[30px] leading-9 font-semibold text-white">{title}</h2>
<p className="text-sm leading-5 text-[#9790A6]">{subtitle}</p>
</div>
{clearable ? (
<button
type="button"
onClick={() => setCleared(true)}
disabled={cleared}
className="cursor-pointer h-9 w-24 rounded-[18px] bg-white/6 text-xs leading-[18px] font-semibold text-[#B5AEC1] transition-colors hover:text-white disabled:opacity-40"
>
清空记录
</button>
) : null}
</div>
<div className="flex flex-col gap-3 lg:gap-2.5">
<FilterPills
options={mobileTypes}
value={type}
onChange={changeType}
className="flex gap-3 lg:hidden"
itemClassName="h-8 px-4 text-sm leading-5"
/>
<FilterPills
options={types}
value={type}
onChange={changeType}
className="hidden gap-[9px] lg:flex"
itemClassName="h-8 w-[88px] px-4 text-xs leading-[18px]"
/>
{isVideo && !showVideoScopes ? null : (
<FilterPills
options={scopes}
value={scope}
onChange={changeScope}
className={`flex gap-2 lg:gap-[9px] ${showMobileScopes ? "" : "max-lg:hidden"} ${showPcScopes ? "" : "lg:hidden"}`}
itemClassName="h-6 px-3 text-xs leading-[18px] lg:h-8 lg:px-4"
/>
)}
</div>
{items.length === 0 ? (
<EmptyState
title={emptyText}
description="去首页发现更多好剧吧"
onReload={null}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : (
<>
<div
className={`hidden justify-between gap-x-5 gap-y-6 lg:grid ${
isVideo ? "grid-cols-[repeat(auto-fill,324px)]" : "grid-cols-[repeat(auto-fill,266px)]"
}`}
>
{items.map((item) => (
<PcCard key={item.id} item={item} />
))}
</div>
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3 lg:hidden">
{items.map((item) => (
<MobileCard key={item.id} item={item} showEpisodes={showMobileEpisodes} />
))}
</div>
<Pagination page={page} total={totalPages} onChange={changePage} showJump={false} />
</>
)}
</div>
</main>
);
}
@@ -1,284 +0,0 @@
"use client";
import { useState, type FormEvent } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { useMockUser } from "@/components/profile/use-mock-user";
import type { CommentInfo } from "@/lib/api/types";
import { COMMENT_MAX_LENGTH, COMMENT_SELF_NAME, COMMENT_SORTS, formatCommentTime, type CommentTarget } from "./data";
import { CommentReplyPreview } from "./comment-reply-preview";
import { HeartIcon } from "./heart-icon";
import { InlineReplyBox } from "./inline-reply-box";
import { useComments } from "./use-comments";
const fallbackAvatar = "/images/default_avatar.svg";
const panelStyles = {
detail: {
section: "rounded-2xl bg-[rgba(24,23,46,0.88)] outline-[#383657]",
item: "py-4",
more: "mt-4 h-[43px] rounded-[7px]",
},
play: {
section: "rounded-xl bg-[rgba(19,15,22,0.91)] outline-[rgba(199,145,89,0.7)]",
item: "py-3",
more: "mt-3 h-8 rounded-lg",
},
};
type ReplyTarget = {
id: number;
userName: string;
};
function CommentComposer({
onSubmit,
posting,
error,
}: {
onSubmit: (content: string) => Promise<boolean>;
posting: boolean;
error: string | null;
}) {
const { user } = useMockUser();
const avatar = user?.avatar || fallbackAvatar;
const displayName = user?.name || COMMENT_SELF_NAME;
const [text, setText] = useState("");
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting) return;
const ok = await onSubmit(content);
if (ok) setText("");
};
return (
<div className="mt-2 flex flex-col gap-2">
<form onSubmit={submit} className="flex h-[76px] items-center gap-4 rounded-[10px] bg-[#211F38] px-4 py-3">
<DecryptedImage
src={avatar}
fallback={fallbackAvatar}
alt=""
className="size-9 shrink-0 rounded-full object-cover"
/>
<label className="flex h-[52px] min-w-0 flex-1 items-center gap-3 rounded-lg bg-[#110F1F] px-3 py-2.5 outline outline-1 -outline-offset-1 outline-[#383657] focus-within:outline-[#F12B9E]">
<input
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting}
onChange={(event) => setText(event.target.value)}
placeholder="说说你对这部剧的看法…"
aria-label="评论内容"
className="min-w-0 flex-1 bg-transparent text-xs leading-[18px] text-white outline-none placeholder:text-[#948CA8] disabled:opacity-60"
/>
<span className="flex shrink-0 gap-2 text-xs leading-[18px]">
<span className="text-[#F12B9E]">已登录</span>
<span className="text-[#948CA8]">{displayName}</span>
<span className="text-[#736E87]">
{text.length} / {COMMENT_MAX_LENGTH}
</span>
</span>
</label>
<button
type="submit"
disabled={!text.trim() || posting}
className="cursor-pointer h-[52px] w-[120px] shrink-0 rounded-[10px] bg-[#F12B9E] text-xs leading-[18px] font-semibold text-white transition-colors enabled:hover:bg-[#F23DA8] disabled:opacity-60"
>
{posting ? "发布中…" : "发表评论"}
</button>
</form>
{error && !posting ? <p className="text-xs leading-[18px] text-[#FF8FA8]">{error}</p> : null}
</div>
);
}
/** PC 评论面板(详情 / 播放页)— GET /api/app/comment/list */
export function CommentPanel({
variant = "detail",
target,
}: {
variant?: keyof typeof panelStyles;
target: CommentTarget | null;
}) {
const {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
error,
loadMore,
toggleLike,
post,
posting,
postError,
reload,
} = useComments({ target });
const styles = panelStyles[variant];
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
const startReply = (comment: CommentInfo) => {
const id = Number(comment.id);
if (!Number.isInteger(id) || id < 1) return;
setReplyTo({ id, userName: (comment.userName || "").trim() || "用户" });
};
const toggleExpanded = (commentId: number) => {
setExpandedIds((current) => {
const next = new Set(current);
if (next.has(commentId)) next.delete(commentId);
else next.add(commentId);
return next;
});
};
const submitRoot = async (content: string) => post(content);
const submitReply = async (content: string) => {
if (!replyTo) return false;
const ok = await post(content, { parentsId: replyTo.id });
if (ok) setReplyTo(null);
return ok;
};
return (
<section
data-component="comment-panel"
className={`flex w-[1196px] shrink-0 flex-col p-6 outline outline-1 -outline-offset-1 ${styles.section}`}
>
<div className="flex h-10 items-center justify-between">
<h2 className="text-[22px] leading-[30px] text-white">评论 ({total})</h2>
<div className="flex h-8 items-center gap-3 rounded-[7px] bg-[#110F1F] px-2 text-xs leading-[18px]">
{COMMENT_SORTS.map((item) => (
<button
key={item}
type="button"
aria-pressed={sort === item}
onClick={() => setSort(item)}
className={`cursor-pointer ${sort === item ? "text-white" : "text-[#948CA8] hover:text-white"}`}
>
{item}
</button>
))}
</div>
</div>
<CommentComposer
onSubmit={submitRoot}
posting={posting && !replyTo}
error={!replyTo ? postError : null}
/>
{loading ? (
<p className="py-8 text-center text-sm text-[#948CA8]">评论加载中…</p>
) : error ? (
<div className="flex flex-col items-center gap-3 py-8">
<p className="text-sm text-[#948CA8]">{error}</p>
<button
type="button"
onClick={reload}
className="cursor-pointer rounded-lg bg-[#211F38] px-4 py-2 text-xs text-white hover:opacity-90"
>
重试
</button>
</div>
) : comments.length === 0 ? (
<p className="py-8 text-center text-sm text-[#948CA8]">暂无评论</p>
) : (
<ul>
{comments.map((comment) => {
const liked = Boolean(comment.isLike);
const commentId = Number(comment.id) || 0;
const expanded = expandedIds.has(commentId);
const replies = comment.subComment ?? [];
const isReplying = replyTo?.id === commentId;
return (
<li key={comment.id} className={`flex gap-4 border-b border-[#2E2B47] ${styles.item}`}>
<DecryptedImage
src={comment.avatar}
fallback="/images/default_avatar.svg"
alt=""
className="size-12 shrink-0 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<p className="text-sm leading-[21px] text-[#948CA8]">{comment.userName}</p>
<p className="text-base leading-6 text-white">{comment.text}</p>
<div className="flex items-center gap-2 text-xs leading-[18px] text-[#948CA8]">
<span className="font-semibold">{formatCommentTime(comment.createdAt)}</span>
<button
type="button"
aria-label={liked ? "取消点赞" : "点赞"}
aria-pressed={liked}
onClick={() => void toggleLike(comment)}
className="cursor-pointer flex items-center gap-2 font-semibold"
>
<HeartIcon filled={liked} />
{comment.countLike ?? 0}
</button>
<button
type="button"
onClick={() => (isReplying ? setReplyTo(null) : startReply(comment))}
className={`cursor-pointer hover:text-white ${isReplying ? "text-[#F12B9E]" : ""}`}
>
回复
</button>
</div>
{isReplying && replyTo ? (
<InlineReplyBox
userName={replyTo.userName}
posting={posting}
error={postError}
onSubmit={submitReply}
onCancel={() => setReplyTo(null)}
/>
) : null}
{expanded && replies.length > 1 ? (
<div className="flex flex-col gap-2 rounded-lg bg-[#211F38] px-3 py-2.5 text-xs leading-[18px] text-[#EBE8F5]">
{replies.map((reply) => (
<p key={reply.id}>
{reply.replyUserName
? `${reply.userName || ""} 回复 ${reply.replyUserName}:${reply.text || ""}`
: `${reply.userName || ""}:${reply.text || ""}`}
</p>
))}
<button
type="button"
onClick={() => toggleExpanded(commentId)}
className="cursor-pointer self-start text-[#F12B9E]"
>
收起回复
</button>
</div>
) : (
<CommentReplyPreview
comment={comment}
className="flex flex-col gap-1.5 rounded-lg bg-[#211F38] px-3 py-2.5 text-xs leading-[18px]"
textClassName="text-[#EBE8F5]"
onMore={replies.length > 1 ? () => toggleExpanded(commentId) : undefined}
/>
)}
</div>
</li>
);
})}
</ul>
)}
{!loading && !error && comments.length > 0 ? (
<button
type="button"
disabled={!hasMore || loadingMore}
onClick={loadMore}
className={`cursor-pointer ${styles.more} bg-[#211F38] text-xs leading-[18px] text-[#948CA8] enabled:hover:text-white disabled:opacity-60`}
>
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
</button>
) : null}
</section>
);
}
@@ -1,35 +0,0 @@
import type { CommentInfo } from "@/lib/api/types";
/** 直接展示接口 subComment / commentCount,不做字段改名 */
export function CommentReplyPreview({
comment,
className = "",
textClassName = "",
onMore,
}: {
comment: CommentInfo;
className?: string;
textClassName?: string;
onMore?: () => void;
}) {
const first = comment.subComment?.[0];
const more = Number(comment.commentCount) || comment.subComment?.length || 0;
if (!first && more < 1) return null;
const replyLabel = first
? first.replyUserName
? `${first.userName || ""} 回复 ${first.replyUserName}:${first.text || ""}`
: `${first.userName || ""}:${first.text || ""}`
: `共 ${more} 条回复`;
return (
<div data-component="comment-reply-preview" className={className}>
<p className={textClassName}>{replyLabel}</p>
{more > 0 && onMore ? (
<button type="button" onClick={onMore} className="cursor-pointer self-start text-[#F12B9E]">
查看更多 {more} 条回复 ›
</button>
) : null}
</div>
);
}
-82
View File
@@ -1,82 +0,0 @@
import { COMMENT_OBJECT_TYPE, type CommentInfo, type CommentObjectType } from "@/lib/api/types";
export type { CommentInfo };
export type CommentTarget = {
objectId: number;
objectType: CommentObjectType;
};
export const COMMENT_SORTS = ["热门", "最新"] as const;
export type CommentSort = (typeof COMMENT_SORTS)[number];
export const COMMENT_SELF_NAME = "剧迷1024";
export const COMMENT_MAX_LENGTH = 200;
export const COMMENT_PAGE_SIZE = 6;
/** 列表请求超时,避免上游卡住拖住 UI */
export const COMMENT_FETCH_TIMEOUT_MS = 8000;
export function sortToOrder(sort: CommentSort): 1 | 2 {
return sort === "热门" ? 1 : 2;
}
/** 过滤无效评论,保留接口原字段 */
export function filterCommentList(list: CommentInfo[] | null | undefined): CommentInfo[] {
return (list ?? []).filter((item) => {
const id = Number(item?.id);
return Number.isInteger(id) && id > 0;
});
}
/** 本地乐观插入用,字段名与 CommentInfo 一致 */
export function createSelfComment(
text: string,
options?: { userName?: string; avatar?: string },
): CommentInfo {
return {
id: -Date.now(),
userName: (options?.userName || "").trim() || COMMENT_SELF_NAME,
avatar: (options?.avatar || "").trim() || "/images/default_avatar.svg",
text,
createdAt: new Date().toISOString(),
countLike: 0,
isLike: false,
commentCount: 0,
subComment: [],
};
}
export function formatCommentCount(value: number) {
return value >= 10000 ? `${(value / 10000).toFixed(1)}w` : String(value);
}
/** 仅做展示格式化,不改字段名;空则回退原串 */
export function formatCommentTime(value?: string | null) {
const raw = (value || "").trim();
if (!raw) return "";
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
export function commentTarget(
objectId: number | string | null | undefined,
objectType: CommentObjectType,
): CommentTarget | null {
const id = Number(objectId);
if (!Number.isInteger(id) || id < 1) return null;
return { objectId: id, objectType };
}
export function playletCommentTarget(playletId: number | string | null | undefined) {
return commentTarget(playletId, COMMENT_OBJECT_TYPE.playlet);
}
export function playletChapterCommentTarget(chapterId: number | string | null | undefined) {
return commentTarget(chapterId, COMMENT_OBJECT_TYPE.playletChapter);
}
export function videoCommentTarget(videoId: number | string | null | undefined) {
return commentTarget(videoId, COMMENT_OBJECT_TYPE.video);
}
@@ -1,204 +0,0 @@
"use client";
import { useState, type FormEvent, type ReactNode } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { COMMENT_MAX_LENGTH, COMMENT_SORTS, formatCommentTime, type CommentTarget } from "./data";
import { CommentReplyPreview } from "./comment-reply-preview";
import { HeartIcon } from "./heart-icon";
import { InlineReplyBox } from "./inline-reply-box";
import { useComments } from "./use-comments";
function SheetHead({ title, onClose, children }: { title: ReactNode; onClose: () => void; children?: ReactNode }) {
return (
<div className="flex items-center gap-3">
<button
type="button"
aria-label="收起"
onClick={onClose}
className="cursor-pointer flex size-6 items-center justify-center text-white"
>
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
<h2 className="min-w-0 flex-1 text-center text-base leading-[22px] font-medium text-white">{title}</h2>
{children ?? <span className="size-6" />}
</div>
);
}
/** H5 短剧播放页评论底部弹层 */
export function H5CommentSheet({ onClose, target }: { onClose: () => void; target: CommentTarget | null }) {
const {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
error,
loadMore,
toggleLike,
post,
posting,
postError,
reload,
} = useComments({ target });
const [text, setText] = useState("");
const [replyTo, setReplyTo] = useState<{ id: number; userName: string } | null>(null);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting || replyTo) return;
const ok = await post(content);
if (ok) setText("");
};
const submitReply = async (content: string) => {
if (!replyTo) return false;
const ok = await post(content, { parentsId: replyTo.id });
if (ok) setReplyTo(null);
return ok;
};
return (
<div data-component="h5-comment-sheet" className="flex h-[70vh] flex-col">
<div className="px-4 pt-[18px] pb-3">
<SheetHead title={`评论 ${total}`} onClose={onClose}>
<div className="flex gap-3 text-xs leading-[18px]">
{COMMENT_SORTS.map((item) => (
<button
key={item}
type="button"
aria-pressed={sort === item}
onClick={() => setSort(item)}
className={`cursor-pointer ${sort === item ? "text-white" : "text-white/35"}`}
>
{item}
</button>
))}
</div>
</SheetHead>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4">
{loading ? (
<p className="py-10 text-center text-sm text-white/50">评论加载中…</p>
) : error ? (
<div className="flex flex-col items-center gap-3 py-10">
<p className="text-sm text-white/50">{error}</p>
<button
type="button"
onClick={reload}
className="cursor-pointer rounded-md bg-white/10 px-4 py-2 text-xs text-white"
>
重试
</button>
</div>
) : comments.length === 0 ? (
<p className="py-10 text-center text-sm text-white/50">暂无评论</p>
) : (
<ul>
{comments.map((comment) => {
const liked = Boolean(comment.isLike);
const commentId = Number(comment.id) || 0;
const isReplying = replyTo?.id === commentId;
return (
<li key={comment.id} className="flex gap-3 border-b border-white/5 py-3">
<DecryptedImage
src={comment.avatar}
fallback="/images/default_avatar.svg"
alt=""
className="size-8 shrink-0 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<p className="text-xs leading-[18px] text-white/50">{comment.userName}</p>
<p className="text-sm leading-5 text-white">{comment.text}</p>
<div className="flex items-center gap-3 text-[11px] leading-4 text-white/35">
<span>{formatCommentTime(comment.createdAt)}</span>
<button
type="button"
aria-pressed={liked}
onClick={() => void toggleLike(comment)}
className="cursor-pointer flex items-center gap-1"
>
<HeartIcon filled={liked} />
{comment.countLike ?? 0}
</button>
<button
type="button"
onClick={() => {
if (isReplying) {
setReplyTo(null);
return;
}
if (!Number.isInteger(commentId) || commentId < 1) return;
setReplyTo({
id: commentId,
userName: (comment.userName || "").trim() || "用户",
});
}}
className={`cursor-pointer ${isReplying ? "text-[#F12B9E]" : "hover:text-white"}`}
>
回复
</button>
</div>
{isReplying && replyTo ? (
<InlineReplyBox
compact
userName={replyTo.userName}
posting={posting}
error={postError}
onSubmit={submitReply}
onCancel={() => setReplyTo(null)}
/>
) : null}
<CommentReplyPreview
comment={comment}
className="rounded-md bg-white/5 px-2.5 py-2 text-xs leading-[18px]"
textClassName="text-white/70"
/>
</div>
</li>
);
})}
</ul>
)}
{!loading && !error && comments.length > 0 ? (
<button
type="button"
disabled={!hasMore || loadingMore}
onClick={loadMore}
className="cursor-pointer my-3 h-9 w-full rounded-md bg-white/5 text-xs text-white/50 disabled:opacity-50"
>
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
</button>
) : null}
</div>
<div className="border-t border-white/5 px-4 py-2.5">
{!replyTo && postError ? <p className="mb-2 text-xs text-[#FF8FA8]">{postError}</p> : null}
<form onSubmit={submit} className="flex items-center gap-2.5">
<input
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting || Boolean(replyTo)}
onChange={(event) => setText(event.target.value)}
placeholder="发表评论…"
aria-label="评论内容"
className="h-9 min-w-0 flex-1 rounded-full bg-white/5 px-4 text-sm text-white outline-none placeholder:text-white/35 disabled:opacity-60"
/>
<button
type="submit"
disabled={!text.trim() || posting || Boolean(replyTo)}
className="cursor-pointer h-9 shrink-0 rounded-full bg-[#F12B9E] px-4 text-sm text-white disabled:opacity-50"
>
{posting && !replyTo ? "发送中" : "发送"}
</button>
</form>
</div>
</div>
);
}
@@ -1,209 +0,0 @@
"use client";
import { useState, type FormEvent, type ReactNode } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { COMMENT_MAX_LENGTH, COMMENT_SORTS, formatCommentCount, formatCommentTime } from "./data";
import { CommentReplyPreview } from "./comment-reply-preview";
import { InlineReplyBox } from "./inline-reply-box";
import type { UseCommentsResult } from "./use-comments";
type H5CommentsTabProps = {
/** 由页面 useComments 托管,便于 Tab 标题同步总数 */
state: UseCommentsResult;
footerExtra?: ReactNode;
};
/** H5 视频播放页「评论」Tab */
export function H5CommentsTab({ state, footerExtra }: H5CommentsTabProps) {
const {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
error,
loadMore,
toggleLike,
post,
posting,
postError,
reload,
} = state;
const [text, setText] = useState("");
const [replyTo, setReplyTo] = useState<{ id: number; userName: string } | null>(null);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting || replyTo) return;
const ok = await post(content);
if (ok) setText("");
};
const submitReply = async (content: string) => {
if (!replyTo) return false;
const ok = await post(content, { parentsId: replyTo.id });
if (ok) setReplyTo(null);
return ok;
};
return (
<div data-component="h5-comments-tab" className="flex flex-col">
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex items-center justify-between">
<h2 className="text-base leading-[22px] font-medium text-white">{total}条评论</h2>
<div className="flex rounded-full bg-white/5 p-0.5">
{COMMENT_SORTS.map((item) => (
<button
key={item}
type="button"
aria-pressed={sort === item}
onClick={() => setSort(item)}
className={`cursor-pointer rounded-full px-2.5 py-1 text-[10px] leading-4 ${
sort === item ? "bg-white/10 text-white" : "text-white/65"
}`}
>
{item === "最新" ? "最新" : "最热"}
</button>
))}
</div>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-white/50">评论加载中…</p>
) : error ? (
<div className="flex flex-col items-center gap-3 py-8">
<p className="text-sm text-white/50">{error}</p>
<button
type="button"
onClick={reload}
className="cursor-pointer rounded-[3px] bg-white/10 px-4 py-2 text-xs text-white"
>
重试
</button>
</div>
) : comments.length === 0 ? (
<p className="py-8 text-center text-sm text-white/50">暂无评论</p>
) : (
<ul className="flex flex-col gap-5">
{comments.map((comment) => {
const liked = Boolean(comment.isLike);
const commentId = Number(comment.id) || 0;
const isReplying = replyTo?.id === commentId;
return (
<li key={comment.id} className="flex gap-3">
<DecryptedImage
src={comment.avatar}
fallback="/images/default_avatar.svg"
alt=""
className="size-10 shrink-0 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<p className="text-sm leading-5 text-white/65">{comment.userName}</p>
<p className="text-sm leading-5 break-words text-white">{comment.text}</p>
<div className="flex items-center justify-between text-xs leading-[18px]">
<div className="flex items-center gap-3">
<span className="text-white/65">{formatCommentTime(comment.createdAt)}</span>
<button
type="button"
className={`cursor-pointer ${isReplying ? "text-[#F12B9E]" : "text-white"}`}
onClick={() => {
if (isReplying) {
setReplyTo(null);
return;
}
if (!Number.isInteger(commentId) || commentId < 1) return;
setReplyTo({
id: commentId,
userName: (comment.userName || "").trim() || "用户",
});
}}
>
回复
</button>
</div>
<button
type="button"
aria-pressed={liked}
onClick={() => void toggleLike(comment)}
className="cursor-pointer flex items-center gap-1 text-white/65"
>
<svg viewBox="0 0 18 18" className="size-[18px]" aria-hidden="true">
<path
d="M9 15.5c-.22 0-.45-.08-.62-.23C5.97 13.25 2.25 10.65 2.25 7.2c0-2.25 1.6-4.05 3.6-4.05 1.24 0 2.37.68 3.15 1.7.78-1.02 1.9-1.7 3.15-1.7 2 0 3.6 1.8 3.6 4.05 0 3.45-3.72 6.05-6.13 8.07-.17.15-.4.23-.62.23Z"
fill={liked ? "#F52C56" : "none"}
stroke="#F52C56"
strokeWidth="1.4"
/>
</svg>
{formatCommentCount(Number(comment.countLike) || 0)}
</button>
</div>
{isReplying && replyTo ? (
<InlineReplyBox
compact
userName={replyTo.userName}
posting={posting}
error={postError}
onSubmit={submitReply}
onCancel={() => setReplyTo(null)}
/>
) : null}
<CommentReplyPreview
comment={comment}
className="flex flex-col gap-1 rounded-[3px] bg-white/5 px-3 py-1.5 text-xs leading-[18px]"
textClassName="text-white/80"
/>
</div>
</li>
);
})}
</ul>
)}
{!loading && !error && comments.length > 0 ? (
<button
type="button"
disabled={!hasMore || loadingMore}
onClick={loadMore}
className="cursor-pointer h-9 w-full rounded-[3px] bg-white/5 text-xs text-white/50 disabled:opacity-50"
>
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
</button>
) : null}
</div>
<div className="sticky bottom-0 z-10 bg-[#1A1A1A]">
{!replyTo && postError ? <p className="px-4 pt-2 text-xs text-[#FF8FA8]">{postError}</p> : null}
<form onSubmit={submit} className="flex h-[58px] items-center px-4 py-1.5">
<label className="flex h-full flex-1 items-center gap-2 rounded-[3px] bg-white/5 px-3 py-1.5">
<input
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting || Boolean(replyTo)}
onChange={(event) => setText(event.target.value)}
placeholder={posting && !replyTo ? "发布中…" : "发表评论…"}
enterKeyHint="send"
className="min-w-0 flex-1 bg-transparent text-sm leading-5 text-white outline-none placeholder:text-white/35 disabled:opacity-60"
/>
<svg viewBox="0 0 24 24" className="size-6 shrink-0" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="rgba(255,255,255,0.65)" strokeWidth="1.5" />
<path
d="M8.5 14a4 4 0 0 0 7 0"
stroke="rgba(255,255,255,0.65)"
strokeWidth="1.5"
strokeLinecap="round"
/>
<circle cx="9" cy="10" r="1" fill="rgba(255,255,255,0.65)" />
<circle cx="15" cy="10" r="1" fill="rgba(255,255,255,0.65)" />
</svg>
</label>
</form>
</div>
{footerExtra}
</div>
);
}
@@ -1,12 +0,0 @@
export function HeartIcon({ filled, className = "size-[18px]" }: { filled: boolean; className?: string }) {
return (
<svg viewBox="0 0 14 14" fill="none" className={className} aria-hidden="true">
<path
d="M7 11.5c-.2 0-.4-.07-.55-.2C4.6 9.8 2.5 8.2 2.5 5.9 2.5 4.5 3.5 3.5 4.8 3.5c.9 0 1.7.5 2.2 1.2.5-.7 1.3-1.2 2.2-1.2 1.3 0 2.3 1 2.3 2.4 0 2.3-2.1 3.9-3.95 5.4-.15.13-.35.2-.55.2Z"
fill={filled ? "#F12B9E" : "none"}
stroke={filled ? "#F12B9E" : "rgba(255,255,255,0.55)"}
strokeWidth="1.5"
/>
</svg>
);
}
-25
View File
@@ -1,25 +0,0 @@
export {
COMMENT_FETCH_TIMEOUT_MS,
COMMENT_MAX_LENGTH,
COMMENT_PAGE_SIZE,
COMMENT_SELF_NAME,
COMMENT_SORTS,
commentTarget,
createSelfComment,
filterCommentList,
formatCommentCount,
formatCommentTime,
playletChapterCommentTarget,
playletCommentTarget,
sortToOrder,
videoCommentTarget,
type CommentSort,
type CommentTarget,
} from "./data";
export { CommentPanel } from "./comment-panel";
export { CommentReplyPreview } from "./comment-reply-preview";
export { H5CommentSheet } from "./h5-comment-sheet";
export { H5CommentsTab } from "./h5-comments-tab";
export { HeartIcon } from "./heart-icon";
export { useComments, type UseCommentsOptions, type UseCommentsResult } from "./use-comments";
export type { CommentInfo } from "@/lib/api/types";
@@ -1,102 +0,0 @@
"use client";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { COMMENT_MAX_LENGTH } from "./data";
/** 挂在单条评论下方的回复输入框 */
export function InlineReplyBox({
userName,
posting,
error,
onSubmit,
onCancel,
compact = false,
}: {
userName: string;
posting: boolean;
error?: string | null;
onSubmit: (content: string) => Promise<boolean>;
onCancel: () => void;
compact?: boolean;
}) {
const [text, setText] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting) return;
const ok = await onSubmit(content);
if (ok) setText("");
};
return (
<div
data-component="inline-reply-box"
className={`flex flex-col gap-2 ${
compact
? "mt-1 rounded-md bg-white/[0.06] p-2.5 outline outline-1 -outline-offset-1 outline-white/15"
: "mt-2 rounded-xl bg-[#211F38] p-3 outline outline-1 -outline-offset-1 outline-[#4A4668]"
}`}
>
<div
className={`flex items-center justify-between leading-[18px] ${
compact ? "text-[11px] text-white/55" : "text-xs text-[#948CA8]"
}`}
>
<span>
回复 <span className="font-medium text-[#F12B9E]">@{userName}</span>
</span>
<button
type="button"
onClick={onCancel}
className="cursor-pointer text-white/50 transition-colors hover:text-white"
>
取消
</button>
</div>
<form onSubmit={submit} className="flex items-center gap-2">
<label
className={`flex min-w-0 flex-1 items-center gap-2 transition-[outline-color] ${
compact
? "h-10 rounded-[3px] bg-[#0F0F0F] px-3 outline outline-1 -outline-offset-1 outline-white/20 focus-within:outline-[#F12B9E]"
: "h-11 rounded-lg bg-[#110F1F] px-3 outline outline-1 -outline-offset-1 outline-[#5A5678] focus-within:outline-[#F12B9E]"
}`}
>
<input
ref={inputRef}
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting}
onChange={(event) => setText(event.target.value)}
placeholder={`回复 @${userName}…`}
aria-label={`回复 ${userName}`}
className={`min-w-0 flex-1 bg-transparent text-white outline-none placeholder:text-white/40 disabled:opacity-60 ${
compact ? "text-sm leading-5" : "text-sm leading-5"
}`}
/>
<span className="shrink-0 text-[10px] leading-4 text-white/30">
{text.length}/{COMMENT_MAX_LENGTH}
</span>
</label>
<button
type="submit"
disabled={!text.trim() || posting}
className={`cursor-pointer shrink-0 font-medium text-white transition-colors enabled:hover:bg-[#F23DA8] disabled:opacity-45 ${
compact
? "h-10 rounded-[3px] bg-[#F12B9E] px-3.5 text-xs"
: "h-11 rounded-lg bg-[#F12B9E] px-4 text-sm"
}`}
>
{posting ? "发送中" : "发送"}
</button>
</form>
{error ? <p className="text-xs leading-[18px] text-[#FF8FA8]">{error}</p> : null}
</div>
);
}
@@ -1,315 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "@heroui/react";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { getCommentList, sendComment, setCommentLike } from "@/lib/api/comment";
import type { CommentInfo } from "@/lib/api/types";
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import {
COMMENT_FETCH_TIMEOUT_MS,
COMMENT_PAGE_SIZE,
filterCommentList,
sortToOrder,
type CommentSort,
type CommentTarget,
} from "./data";
export type UseCommentsOptions = {
target: CommentTarget | null;
initialSort?: CommentSort;
pageSize?: number;
};
export type UseCommentsResult = {
sort: CommentSort;
setSort: (sort: CommentSort) => void;
comments: CommentInfo[];
total: number;
hasMore: boolean;
loading: boolean;
loadingMore: boolean;
posting: boolean;
error: string | null;
postError: string | null;
loadMore: () => void;
toggleLike: (comment: CommentInfo) => Promise<void>;
post: (text: string, options?: { parentsId?: number }) => Promise<boolean>;
reload: () => void;
};
function formatCommentError(err: unknown, fallback = "评论加载失败") {
if (err instanceof DOMException && err.name === "TimeoutError") return "加载超时,请重试";
if (err instanceof Error) {
if (/aborted|timeout|超时/i.test(err.message) || err.name === "AbortError") return "加载超时,请重试";
return err.message || fallback;
}
return fallback;
}
function abortAfter(ms: number) {
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
return AbortSignal.timeout(ms);
}
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
function mergeSignals(a: AbortSignal, b: AbortSignal) {
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.any === "function") {
return AbortSignal.any([a, b]);
}
const controller = new AbortController();
const onAbort = () => controller.abort();
if (a.aborted || b.aborted) {
controller.abort();
return controller.signal;
}
a.addEventListener("abort", onAbort, { once: true });
b.addEventListener("abort", onAbort, { once: true });
return controller.signal;
}
async function resolveAuthToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
function patchComment(list: CommentInfo[], id: number, patch: Partial<CommentInfo>): CommentInfo[] {
return list.map((item) => (Number(item.id) === id ? { ...item, ...patch } : item));
}
export function useComments({
target,
initialSort = "热门",
pageSize = COMMENT_PAGE_SIZE,
}: UseCommentsOptions): UseCommentsResult {
const [sort, setSort] = useState<CommentSort>(initialSort);
const [comments, setComments] = useState<CommentInfo[]>([]);
const [total, setTotal] = useState(0);
const [pageNum, setPageNum] = useState(1);
const [loading, setLoading] = useState(Boolean(target));
const [loadingMore, setLoadingMore] = useState(false);
const [posting, setPosting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [postError, setPostError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const fetchSeq = useRef(0);
const likingIds = useRef(new Set<number>());
const objectId = target?.objectId ?? 0;
const objectType = target?.objectType;
const enabled = Boolean(target && objectId > 0 && objectType != null);
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
void (async () => {
await Promise.resolve();
if (cancelled) return;
if (!enabled || objectType == null) {
setComments([]);
setTotal(0);
setLoading(false);
setError(null);
return;
}
const seq = ++fetchSeq.current;
setLoading(true);
setError(null);
setPageNum(1);
likingIds.current.clear();
try {
const data = await getCommentList(
{
objectId,
objectType,
pageNum: 1,
pageSize,
sortOrder: sortToOrder(sort),
},
{
token: getAuthToken(),
signal: mergeSignals(controller.signal, abortAfter(COMMENT_FETCH_TIMEOUT_MS)),
},
);
if (cancelled || seq !== fetchSeq.current) return;
setComments(filterCommentList(data?.list));
setTotal(Number(data?.totalCount) || 0);
setPageNum(1);
} catch (err: unknown) {
if (cancelled || seq !== fetchSeq.current || controller.signal.aborted) return;
setComments([]);
setTotal(0);
setError(formatCommentError(err));
} finally {
if (!cancelled && seq === fetchSeq.current) setLoading(false);
}
})();
return () => {
cancelled = true;
controller.abort();
};
}, [enabled, objectId, objectType, sort, pageSize, reloadKey]);
const hasMore = comments.length < total;
const loadMore = useCallback(() => {
if (!enabled || objectType == null || loading || loadingMore || !hasMore) return;
const nextPage = pageNum + 1;
const controller = new AbortController();
setLoadingMore(true);
void getCommentList(
{
objectId,
objectType,
pageNum: nextPage,
pageSize,
sortOrder: sortToOrder(sort),
},
{
token: getAuthToken(),
signal: mergeSignals(controller.signal, abortAfter(COMMENT_FETCH_TIMEOUT_MS)),
},
)
.then((data) => {
const mapped = filterCommentList(data?.list);
setComments((current) => {
const seen = new Set(current.map((item) => Number(item.id)));
return [...current, ...mapped.filter((item) => !seen.has(Number(item.id)))];
});
setTotal(Number(data?.totalCount) || total);
setPageNum(nextPage);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setError(formatCommentError(err, "加载更多失败"));
})
.finally(() => setLoadingMore(false));
}, [enabled, objectId, objectType, loading, loadingMore, hasMore, pageNum, pageSize, sort, total]);
const toggleLike = useCallback(async (comment: CommentInfo) => {
const commentId = Number(comment.id);
if (!Number.isInteger(commentId) || commentId < 1) return;
if (likingIds.current.has(commentId)) return;
const token = await resolveAuthToken();
if (!token) {
openLoginDialog();
return;
}
const nextIsLike = !Boolean(comment.isLike);
const nextCountLike = Math.max(0, (Number(comment.countLike) || 0) + (nextIsLike ? 1 : -1));
const snapshot = { isLike: comment.isLike, countLike: comment.countLike };
likingIds.current.add(commentId);
setComments((current) => patchComment(current, commentId, { isLike: nextIsLike, countLike: nextCountLike }));
try {
const result = await setCommentLike(
{ commentId, isLike: nextIsLike },
token,
abortAfter(COMMENT_FETCH_TIMEOUT_MS),
);
setComments((current) =>
patchComment(current, commentId, {
isLike: result?.isLike ?? nextIsLike,
countLike: typeof result?.countLike === "number" ? result.countLike : nextCountLike,
}),
);
} catch (err: unknown) {
setComments((current) => patchComment(current, commentId, snapshot));
handleUnauthorized(err);
} finally {
likingIds.current.delete(commentId);
}
}, []);
const post = useCallback(
async (text: string, options?: { parentsId?: number }) => {
const trimmed = text.trim();
if (!trimmed || posting) return false;
if (!enabled || objectType == null) {
setPostError("无法发布评论");
return false;
}
setPostError(null);
const token = await resolveAuthToken();
if (!token) {
openLoginDialog();
return false;
}
setPosting(true);
try {
await sendComment(
{
objectId,
objectType,
text: trimmed,
parentsId: options?.parentsId ?? 0,
},
token,
abortAfter(COMMENT_FETCH_TIMEOUT_MS),
);
// 审核通过前不写入列表(对齐 1045)
toast.success("发布成功,评论审核后才会展示");
return true;
} catch (err: unknown) {
if (handleUnauthorized(err)) {
setPostError("登录已过期或没有权限,请重新登录");
return false;
}
const message = formatCommentError(err, "发表失败,请稍后重试");
setPostError(message);
toast.danger(message);
return false;
} finally {
setPosting(false);
}
},
[enabled, objectId, objectType, posting],
);
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
return {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
posting,
error,
postError,
loadMore,
toggleLike,
post,
reload,
};
}
-306
View File
@@ -1,306 +0,0 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { CornerFlagBadge, resolveContentBadge } from "@/components/home/section-heading";
import { dramaHref } from "@/components/short-drama/data";
import {
getEpisodeChunks,
getEpisodeRanges,
getEpisodeState,
totalEpisodeOf,
updateStatusLabel,
type DramaDetail,
type RelatedDrama,
} from "./data";
type EpisodeProps = {
detail: DramaDetail;
current: number;
onSelect: (episode: number) => void;
};
function LockIcon({ className = "" }: { className?: string }) {
return (
<svg viewBox="0 0 18 18" fill="none" className={className} aria-hidden="true">
<rect x="3.75" y="7.5" width="10.5" height="8.25" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M6 7.5V5.25a3 3 0 0 1 6 0V7.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M9 10.5v2.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
/** 免费/未解锁均进播放页;未解锁由播放页在播放器上盖下载遮罩 */
function useEpisodeClick(_detail: DramaDetail, onSelect: (episode: number) => void) {
return (episode: number) => onSelect(episode);
}
/** 与播放页「更多好剧」一致的三列推荐卡片 */
export function MorePlayletsGrid({ items }: { items: RelatedDrama[] }) {
if (!items.length) {
return <p className="py-8 text-center text-xs text-white/35">暂无更多推荐</p>;
}
return (
<div className="grid grid-cols-3 gap-x-[5px] gap-y-3">
{items.map((item) => {
const badge = resolveContentBadge(item);
return (
<Link key={item.id} href={dramaHref(item.id, item.slug)} className="flex flex-col gap-1">
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-md bg-[#525252]">
<DecryptedImage src={item.cover} alt="" className="size-full object-cover" />
<CornerFlagBadge
tone={badge}
className="absolute top-2 right-2 z-[1]"
badgeClassName="h-[18px]"
/>
</div>
<span className="truncate text-sm leading-5 text-white">{item.title}</span>
<span className="truncate text-xs leading-[18px] text-white/50">
{item.tagNames?.[0]}
{item.tagNames?.[0] && item.totalEpisode ? " · " : ""}
{item.totalEpisode ? `${item.totalEpisode}集` : ""}
</span>
</Link>
);
})}
</div>
);
}
export function EpisodePanel({ detail, current, onSelect }: EpisodeProps) {
const totalEpisode = totalEpisodeOf(detail);
const ranges = getEpisodeRanges(totalEpisode, 20);
const [rangeIndex, setRangeIndex] = useState(-1);
const range = ranges[rangeIndex];
const start = range?.start ?? 1;
const end = range?.end ?? totalEpisode;
const handleClick = useEpisodeClick(detail, onSelect);
return (
<section
data-component="episode-panel"
data-episodes
className="flex flex-col gap-4 rounded-2xl bg-[rgba(20,23,46,0.9)] p-6 outline outline-1 -outline-offset-1 outline-[#333354]"
>
<div className="flex h-10 items-center gap-6">
<h2 className="text-[22px] leading-[30px] text-white">选集</h2>
<p className="flex-1 text-sm text-[#8C879C]">共 {totalEpisode} 集</p>
<div className="flex items-center gap-8 text-sm">
{[{ label: "全部", index: -1 }, ...ranges.map((item, index) => ({ label: item.label, index }))].map(
(item) => (
<button
key={item.label}
type="button"
aria-pressed={rangeIndex === item.index}
onClick={() => setRangeIndex(item.index)}
className={`cursor-pointer ${rangeIndex === item.index ? "text-[#F12B9E]" : "text-[#8C879C] hover:text-white"}`}
>
{item.label}
</button>
),
)}
</div>
</div>
<div className="grid grid-cols-[repeat(10,154px)] justify-between gap-y-3">
{Array.from({ length: end - start + 1 }, (_, index) => start + index).map((episode) => {
const locked = getEpisodeState(episode, detail) === "locked";
const playing = episode === current;
return (
<button
key={episode}
type="button"
aria-current={playing || undefined}
aria-label={`第${episode}集${locked ? "(未解锁)" : ""}`}
onClick={() => handleClick(episode)}
className={`cursor-pointer relative flex h-[58px] items-center justify-center gap-0.5 overflow-hidden rounded-lg text-lg leading-6 font-semibold transition-colors ${
playing
? "bg-[rgba(241,43,158,0.1)] text-[#F12B9E] outline outline-1 -outline-offset-1 outline-[#F12B9E]"
: locked
? "bg-white/10 text-[#828186] outline outline-1 -outline-offset-1 outline-white/5 hover:bg-white/15"
: "bg-[#3A3D53] text-white hover:bg-[#454866]"
}`}
>
第{episode}集
{locked ? <LockIcon className="size-[18px]" /> : null}
{!locked ? (
<span className="absolute top-1 right-1 flex h-5 w-9 items-center justify-center rounded bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[10px] font-normal leading-4 text-[#002D27]">
免费
</span>
) : null}
</button>
);
})}
</div>
</section>
);
}
export function H5EpisodeStrip({ detail, onSelect, current }: Omit<EpisodeProps, "current"> & { current?: number }) {
const totalEpisode = totalEpisodeOf(detail);
const ranges = getEpisodeChunks(totalEpisode, 30);
const [rangeIndex, setRangeIndex] = useState(0);
const { start, end } = ranges[rangeIndex] ?? ranges[0];
const handleClick = useEpisodeClick(detail, onSelect);
const hasPreview = Boolean(detail.preview?.trim());
const previewChapter = detail.chapterList?.find(
(item) => item.title?.trim() === "预告" || Number(item.currentEpisode) === 0,
);
const showPreview = hasPreview || Boolean(previewChapter);
return (
<section data-component="h5-episode-strip" data-episodes className="inline-flex w-full flex-col items-start gap-3">
<div className="inline-flex w-full items-center justify-between">
<h2 className="text-sm leading-5 font-medium text-white/90">剧集</h2>
<div className="flex items-center gap-4">
<span className="flex items-center gap-0.5 text-xs leading-[18px] text-[#656565]">
{updateStatusLabel(detail.updateStatus)} 共{totalEpisode}集
<img src="/images/arrow_rught.svg" alt="" className="size-[18px] opacity-50" />
</span>
</div>
</div>
<div className="inline-flex w-full items-center gap-6 overflow-x-auto text-sm leading-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{ranges.map((item, index) => (
<button
key={item.label}
type="button"
aria-pressed={rangeIndex === index}
onClick={() => setRangeIndex(index)}
className={`cursor-pointer shrink-0 ${rangeIndex === index ? "text-white" : "text-white/35"}`}
>
{item.start}-{item.end}
</button>
))}
<Link href="/short-drama/hot" className="shrink-0 text-white/35">
更多好剧
</Link>
</div>
<div className="inline-flex w-full items-center gap-1.5 overflow-x-auto overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{showPreview && rangeIndex === 0 ? (
<button
type="button"
aria-label="预告"
onClick={() => {
if (previewChapter && Number(previewChapter.currentEpisode) > 0) {
handleClick(Number(previewChapter.currentEpisode));
return;
}
handleClick(1);
}}
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-[rgba(246,103,38,0.1)]"
>
<span className="absolute top-[21px] inset-x-0 flex items-end justify-center text-center text-base leading-[22px] font-medium text-[#F12B9E]">
预告
</span>
<img src="/images/playing.svg" alt="" className="absolute top-0 left-[38px] size-5" />
</button>
) : null}
{Array.from({ length: end - start + 1 }, (_, index) => start + index).map((episode) => {
const locked = getEpisodeState(episode, detail) === "locked";
const playing = current != null && episode === current;
if (playing) {
return (
<button
key={episode}
type="button"
aria-current="true"
aria-label={`第${episode}集`}
onClick={() => handleClick(episode)}
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-[rgba(246,103,38,0.1)]"
>
<span className="absolute top-[21px] inset-x-0 flex items-end justify-center text-center text-base leading-[22px] font-medium text-[#F12B9E]">
{episode}
</span>
<img src="/images/playing.svg" alt="" className="absolute top-0 left-[38px] size-5" />
{!locked ? (
<img
src="/images/tag_mf.png"
alt="免费"
className="pointer-events-none absolute top-0 right-0 h-[14px] w-[25px]"
/>
) : null}
</button>
);
}
if (locked) {
return (
<button
key={episode}
type="button"
aria-label={`第${episode}集(未解锁)`}
onClick={() => handleClick(episode)}
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-white/5 outline outline-1 -outline-offset-1 outline-[#222222]"
>
<span className="absolute top-[21px] left-2.5 inline-flex w-10 items-center justify-center text-base leading-[22px] font-medium text-[#828186]">
<span className="min-w-0 flex-1 text-center">{episode}</span>
<LockIcon className="size-[18px] shrink-0" />
</span>
</button>
);
}
return (
<button
key={episode}
type="button"
aria-label={`第${episode}集`}
onClick={() => handleClick(episode)}
className="relative size-[58px] shrink-0 cursor-pointer overflow-hidden rounded-md bg-white/5"
>
<span className="absolute top-[21px] inset-x-0 flex items-end justify-center text-center text-base leading-[22px] font-medium text-white">
{episode}
</span>
<img
src="/images/tag_mf.png"
alt="免费"
className="pointer-events-none absolute top-0 right-0 h-[14px] w-[25px]"
/>
</button>
);
})}
</div>
</section>
);
}
/** H5 详情:热门推荐 — 一行横向滚动 */
export function H5MorePlaylets({ items }: { items: RelatedDrama[] }) {
if (!items.length) return null;
return (
<section id="more-playlets" data-component="h5-more-playlets" className="flex scroll-mt-4 flex-col gap-3">
<h2 className="text-sm leading-5 font-medium text-white/90">热门推荐</h2>
<div className="-mx-4 flex gap-[9px] overflow-x-auto px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{items.map((item) => {
const badge = resolveContentBadge(item);
return (
<Link key={item.id} href={dramaHref(item.id, item.slug)} className="flex w-[98px] shrink-0 flex-col gap-1">
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-md bg-[#525252]">
<DecryptedImage src={item.cover} alt="" className="size-full object-cover" />
<CornerFlagBadge
tone={badge}
className="absolute top-2 right-2 z-[1]"
badgeClassName="h-[18px]"
/>
</div>
<span className="truncate text-xs leading-[18px] font-medium text-white">{item.title}</span>
<span className="truncate text-xs leading-[18px] text-white/65">
{item.tagNames?.[0]}
{item.tagNames?.[0] && item.totalEpisode ? " · " : ""}
{item.totalEpisode ? `${item.totalEpisode}集` : ""}
</span>
</Link>
);
})}
</div>
</section>
);
}
@@ -1,48 +0,0 @@
import Link from "next/link";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { formatHeat } from "@/components/ranking/data";
import { dramaHref } from "@/components/short-drama/data";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import type { RelatedDrama } from "./data";
export function RelatedAside({ items }: { items: RelatedDrama[] }) {
if (!items.length) return null;
return (
<aside data-component="drama-related-aside" className="flex min-w-0 flex-1 flex-col gap-4 rounded-2xl bg-[rgba(24,23,46,0.88)] p-6 outline outline-1 -outline-offset-1 outline-[#383657]">
<div className="flex h-9 items-center justify-between">
<h2 className="text-[22px] font-bold text-white">热门推荐</h2>
<Link href="/short-drama/hot" className="flex items-center gap-1 text-xs leading-[18px] text-[#B8B2C2] hover:text-white">
更多
<img src="/images/more_arrow.svg" alt="" className="size-3.5" />
</Link>
</div>
{items.map((item) => (
<Link
key={item.id}
href={dramaHref(String(item.id), item.slug)}
className="group flex h-24 items-center gap-4"
>
<DecryptedImage
src={item.cover}
fallback={IMG_PLACEHOLDER}
alt=""
className="h-24 w-[72px] shrink-0 rounded-lg object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<h3 className="truncate text-base leading-[23px] text-[#F5F0FA] group-hover:text-[#F12B9E]">
{item.title}
</h3>
<p className="truncate text-sm leading-[21px] text-[#8C859C]">{item.desc}</p>
<div className="flex items-center justify-between text-xs leading-[18px]">
<span className="text-[#A89EB5]">{item.tagNames?.[0]}</span>
<span className="text-[#ABA1B8]">{formatHeat(item.heatValue ?? item.countBrowse)}</span>
</div>
</div>
</Link>
))}
</aside>
);
}
@@ -1,169 +0,0 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
dramaDetailHref,
getEpisodeChunks,
getEpisodeState,
playHref,
totalEpisodeOf,
type DramaDetail,
} from "@/components/drama-detail/data";
import { ChevronIcon } from "./icons";
export const EPISODE_CHUNK = 30;
function LockIcon({ className = "size-[18px]" }: { className?: string }) {
return (
<svg viewBox="0 0 18 18" fill="none" className={className} aria-hidden="true">
<path
d="M5.29 7.65V5.25a3.71 3.71 0 0 1 7.42 0v2.4"
stroke="currentColor"
strokeWidth="1.35"
strokeLinecap="round"
/>
<rect x="3.75" y="7.5" width="10.5" height="8.25" rx="1.5" stroke="currentColor" strokeWidth="1.35" />
<path d="M9 10.09v2.79" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" />
</svg>
);
}
function FreeBadge() {
return (
<span className="absolute top-[5px] left-11 flex h-5 w-9 items-center justify-center rounded bg-[linear-gradient(136deg,#74FFED_0%,#10B9A2_100%)] text-[10px] leading-4 text-[#002D27]">
免费
</span>
);
}
export function useEpisodeRange(detail: DramaDetail, current: number) {
const ranges = getEpisodeChunks(totalEpisodeOf(detail), EPISODE_CHUNK);
const [rangeIndex, setRangeIndex] = useState(() => Math.floor((current - 1) / EPISODE_CHUNK));
const { start, end } = ranges[rangeIndex] ?? ranges[0];
const episodes = Array.from({ length: end - start + 1 }, (_, index) => start + index);
return { ranges, rangeIndex, setRangeIndex, episodes };
}
export function PlayEpisodePanel({
detail,
current,
wide = false,
}: {
detail: DramaDetail;
current: number;
wide?: boolean;
}) {
const { ranges, rangeIndex, setRangeIndex, episodes } = useEpisodeRange(detail, current);
return (
<aside
data-component="play-episode-panel"
className={`inline-flex shrink-0 flex-col items-start justify-start gap-3 overflow-hidden rounded-xl px-6 py-5 outline outline-1 -outline-offset-1 outline-[rgba(199,145,89,0.7)] ${
wide ? "w-full" : "h-[648px] w-[528px]"
} bg-[rgba(19,15,22,0.79)]`}
>
<div className="inline-flex h-9 w-full shrink-0 items-center justify-between gap-3">
<h2 className="min-w-0 text-[22px] leading-[30px] font-normal text-white">选集</h2>
<Link
href={dramaDetailHref(detail)}
className="inline-flex w-auto shrink-0 items-center gap-2 whitespace-nowrap text-xs leading-[18px] text-[#9991AB] hover:text-white"
>
<ChevronIcon direction="left" />
返回详情
</Link>
</div>
<div
className={`relative inline-flex h-8 w-full shrink-0 items-center ${
wide ? "justify-start gap-20" : "justify-between"
}`}
>
{ranges.map((range, index) => {
const active = index === rangeIndex;
return (
<button
key={range.label}
type="button"
aria-pressed={active}
onClick={() => setRangeIndex(index)}
className={`cursor-pointer relative h-8 text-base leading-[22px] ${
active ? "font-medium text-[#F12B9E]" : "font-normal text-[#6E6B75] hover:text-white"
}`}
>
{range.label}
{active ? (
<span className="absolute top-[30px] left-0 h-0.5 w-[39px] rounded-sm bg-[#FF1A8C]" />
) : null}
</button>
);
})}
</div>
<div
className={`inline-flex w-full flex-wrap content-start items-start justify-start gap-3 overflow-hidden ${
wide ? "" : "min-h-0 flex-1 overflow-y-auto [scrollbar-width:thin]"
}`}
>
{episodes.map((episode) => {
const playing = episode === current;
const locked = getEpisodeState(episode, detail) === "locked";
if (playing) {
return (
<Link
key={episode}
href={playHref(detail.id, episode, detail.slug)}
replace
scroll={false}
aria-current="true"
className="relative h-[76px] w-[86px] overflow-hidden rounded-lg bg-[rgba(241,43,158,0.1)] outline outline-1 -outline-offset-1 outline-[#F12B9E]"
>
<span className="absolute top-[29px] left-[14px] flex w-[58px] items-end justify-center text-center text-lg leading-6 text-[#F12B9E]">
{episode}
</span>
<img src="/images/playing.svg" alt="" className="absolute top-2 left-[58px] size-5" />
{!locked ? <FreeBadge /> : null}
</Link>
);
}
if (!locked) {
return (
<Link
key={episode}
href={playHref(detail.id, episode, detail.slug)}
replace
scroll={false}
className="relative h-[76px] w-[86px] overflow-hidden rounded-lg bg-[#3A3D53] transition-colors hover:bg-[#454866]"
>
<span className="absolute top-[29px] left-[14px] flex w-[58px] items-end justify-center text-center text-lg leading-6 text-white">
{episode}
</span>
<FreeBadge />
</Link>
);
}
return (
<Link
key={episode}
href={playHref(detail.id, episode, detail.slug)}
replace
scroll={false}
aria-label={`第${episode}集(未解锁)`}
className="relative h-[76px] w-[86px] overflow-hidden rounded-lg bg-white/10 outline outline-1 -outline-offset-1 outline-white/5 transition-colors hover:bg-white/15"
>
<span className="absolute top-[29px] left-[14px] inline-flex w-[58px] items-center justify-center gap-0.5 text-lg leading-6 text-[#828186]">
<span className="min-w-0 flex-1 text-center">{episode}</span>
<LockIcon className="size-[18px] shrink-0" />
</span>
</Link>
);
})}
</div>
</aside>
);
}
@@ -1,49 +0,0 @@
"use client";
import { useCallback, useRef } from "react";
import { reportPlayletHistory } from "@/lib/api/playlet-actions";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
async function ensureToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
/** 播放成功后上报观看历史;同一 playletId+chapterId 只报一次 */
export function useReportPlayletHistory(playletId: number, chapterId?: number) {
const reportedKeyRef = useRef<string | null>(null);
return useCallback(async () => {
if (!Number.isInteger(playletId) || playletId < 1) return;
if (!Number.isInteger(chapterId) || !chapterId || chapterId < 1) return;
const key = `${playletId}:${chapterId}`;
if (reportedKeyRef.current === key) return;
reportedKeyRef.current = key;
const token = await ensureToken();
if (!token) {
reportedKeyRef.current = null;
return;
}
try {
await reportPlayletHistory({ playletId, chapterId }, token);
} catch (error) {
reportedKeyRef.current = null;
if (process.env.NODE_ENV === "development") {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[playlet/history] report failed: ${message}`);
}
}
}, [playletId, chapterId]);
}
-46
View File
@@ -1,46 +0,0 @@
import { pickPlayletCardTags, pickVideoCardTags } from "@/components/common/card-tags";
import { resolveContentBadge } from "@/components/home/section-heading";
import { formatHeat } from "@/components/ranking/data";
import type { ShortDramaItem } from "@/components/short-drama/data";
import type { FavoriteVideoListItem, PlayletData } from "@/lib/api/types";
export const favoriteTypes = ["短剧", "视频"] as const;
export type FavoriteType = (typeof favoriteTypes)[number];
export const FAVORITES_PAGE_SIZE = 24;
/** 短剧收藏列表项 → 卡片(接口 PlayletData) */
export function mapFavoritePlayletItem(item: PlayletData): ShortDramaItem | null {
const id = Number(item.id);
if (!Number.isInteger(id) || id < 1) return null;
return {
kind: "drama",
id: String(id),
slug: item.slug,
title: (item.title || "").trim() || "未命名短剧",
subtitle: (item.desc || "").trim(),
tags: pickPlayletCardTags(item),
heat: formatHeat(item.heatValue ?? item.countBrowse),
episodes: item.totalEpisode ? `${item.totalEpisode}集` : "",
badge: resolveContentBadge(item),
cover: (item.cover || "").trim(),
};
}
/** 视频收藏列表项 → 卡片 */
export function mapFavoriteVideoItem(item: FavoriteVideoListItem): ShortDramaItem | null {
const id = Number(item.id);
if (!Number.isInteger(id) || id < 1) return null;
return {
kind: "drama",
id: String(id),
slug: item.slug,
title: (item.title || "").trim() || "未命名视频",
subtitle: "",
tags: pickVideoCardTags(item),
heat: formatHeat(item.watchNum),
episodes: "",
cover: (item.coverImg || "").trim(),
};
}
-221
View File
@@ -1,221 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { EmptyState } from "@/components/common/empty-state";
import { Pagination } from "@/components/common/pagination";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
import { ApiError } from "@/lib/api/client";
import { getPlayletFavoriteList } from "@/lib/api/playlet-actions";
import { getVideoFavoriteList } from "@/lib/api/video-actions";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import {
FAVORITES_PAGE_SIZE,
favoriteTypes,
mapFavoritePlayletItem,
mapFavoriteVideoItem,
type FavoriteType,
} from "./data";
async function resolveToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
/** 我的收藏:短剧 GET /api/app/playlet/favorite/list · 视频 GET /api/app/video/favorite/list */
export function FavoritesPage() {
const router = useRouter();
const topRef = useRef<HTMLDivElement>(null);
const [type, setType] = useState<FavoriteType>("短剧");
const [page, setPage] = useState(1);
const [items, setItems] = useState<ShortDramaItem[]>([]);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
void (async () => {
await Promise.resolve();
if (cancelled) return;
setLoading(true);
setError(null);
try {
const token = await resolveToken();
if (cancelled) return;
if (!token) {
setItems([]);
setTotalPages(1);
setError("请先登录后查看收藏");
return;
}
if (type === "短剧") {
const data = await getPlayletFavoriteList({ pageNum: page, pageSize: FAVORITES_PAGE_SIZE }, token);
if (cancelled) return;
const list = (data?.list ?? [])
.map(mapFavoritePlayletItem)
.filter((item): item is ShortDramaItem => Boolean(item));
const total = Number(data?.totalCount) || 0;
setItems(list);
setTotalPages(Math.max(1, Math.ceil(total / FAVORITES_PAGE_SIZE) || 1));
} else {
const data = await getVideoFavoriteList({ pageNum: page, pageSize: FAVORITES_PAGE_SIZE }, token);
if (cancelled) return;
const list = (data?.list ?? [])
.map(mapFavoriteVideoItem)
.filter((item): item is ShortDramaItem => Boolean(item));
const total = Number(data?.totalCount) || 0;
setItems(list);
setTotalPages(Math.max(1, Math.ceil(total / FAVORITES_PAGE_SIZE) || 1));
}
} catch (err) {
if (cancelled) return;
setItems([]);
setTotalPages(1);
setError(err instanceof ApiError ? err.message : "加载失败,请稍后重试");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [type, page, reloadKey]);
const changeType = (next: FavoriteType) => {
setType(next);
setPage(1);
};
const changePage = (next: number) => {
setPage(next);
topRef.current?.scrollIntoView({ behavior: "smooth" });
};
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push("/profile");
};
const isVideo = type === "视频";
const PcCard = isVideo ? VideoCard : ShortDramaCard;
const MobileCard = isVideo ? H5VideoCard : H5ShortDramaCard;
const empty = !loading && !error && items.length === 0;
return (
<main data-component="favorites-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
<div className="relative flex h-12 items-center justify-center px-4 lg:hidden">
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<h1 className="text-base leading-[22px] font-bold text-white">我的收藏</h1>
</div>
<div
ref={topRef}
data-pc-scale
className="mx-auto flex w-full scroll-mt-24 flex-col gap-3 px-4 py-3 lg:w-[1700px] lg:gap-5 lg:px-0 lg:py-[30px] lg:[zoom:var(--page-scale,1)]"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: "个人中心", href: "/profile" },
{ name: "追剧与收藏" },
]}
/>
<div className="hidden flex-col gap-px lg:flex">
<h2 className="text-[30px] leading-9 font-semibold text-white">我的收藏</h2>
<p className="text-sm leading-5 text-[#9790A6]">继续关注喜欢的内容</p>
</div>
<div className="flex gap-3 lg:hidden">
{favoriteTypes.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === type}
onClick={() => changeType(option)}
className={`cursor-pointer flex h-8 items-center justify-center rounded-2xl px-4 text-sm leading-5 transition-colors ${
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{option}
</button>
))}
</div>
<div className="hidden gap-[9px] lg:flex">
{favoriteTypes.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === type}
onClick={() => changeType(option)}
className={`cursor-pointer flex h-8 w-[88px] items-center justify-center rounded-2xl px-4 text-xs leading-[18px] transition-colors ${
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{option}
</button>
))}
</div>
{loading ? (
<p className="py-16 text-center text-sm text-[#948CA8]">加载中…</p>
) : error ? (
<EmptyState
title={error}
description="请检查网络后重试"
onReload={() => setReloadKey((value) => value + 1)}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : empty ? (
<EmptyState
title="暂无收藏内容"
description="去首页发现更多好剧吧"
onReload={null}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : (
<>
<div
className={`hidden justify-between gap-x-5 gap-y-6 lg:grid ${
isVideo ? "grid-cols-[repeat(auto-fill,324px)]" : "grid-cols-[repeat(auto-fill,266px)]"
}`}
>
{items.map((item) => (
<PcCard key={`${type}-${item.id}`} item={item} />
))}
</div>
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3 lg:hidden">
{items.map((item) => (
<MobileCard key={`${type}-${item.id}`} item={item} showEpisodes={false} />
))}
</div>
<Pagination page={page} total={totalPages} onChange={changePage} showJump={false} />
</>
)}
</div>
</main>
);
}
-58
View File
@@ -1,58 +0,0 @@
import { pickPlayletCardTags, pickVideoCardTags } from "@/components/common/card-tags";
import { playHref } from "@/components/drama-detail/data";
import { formatHeat } from "@/components/ranking/data";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { videoHref } from "@/components/video/video-card";
import type { PlayletHistoryItem, VideoHistoryItem } from "@/lib/api/types";
export const historyTypes = ["短剧", "视频"] as const;
export type HistoryType = (typeof historyTypes)[number];
export const HISTORY_PAGE_SIZE = 24;
export function formatWatchedAt(value?: string | null) {
const raw = (value || "").trim();
if (!raw) return "";
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
/** 短剧历史 → 卡片展示(保留接口字段语义,仅做展示文案) */
export function mapPlayletHistoryItem(item: PlayletHistoryItem): ShortDramaItem | null {
const id = Number(item.id);
if (!Number.isInteger(id) || id < 1) return null;
const episode = Number(item.episode) || 0;
const href = episode > 0 ? playHref(id, episode, item.slug) : undefined;
return {
kind: "drama",
id: String(id),
slug: item.slug,
title: (item.title || "").trim() || "未命名短剧",
subtitle: (item.desc || "").trim(),
tags: pickPlayletCardTags(item),
heat: formatHeat(item.heatValue ?? item.countBrowse),
episodes: episode > 0 ? `看到第${episode}集` : item.totalEpisode ? `全${item.totalEpisode}集` : "",
cover: (item.cover || "").trim(),
href,
};
}
/** 视频历史 → 卡片展示 */
export function mapVideoHistoryItem(item: VideoHistoryItem): ShortDramaItem | null {
const id = Number(item.id);
if (!Number.isInteger(id) || id < 1) return null;
return {
kind: "drama",
id: String(id),
slug: item.slug,
title: (item.title || "").trim() || "未命名视频",
subtitle: (item.desc || "").trim(),
tags: pickVideoCardTags(item),
heat: formatHeat(item.watchNum),
episodes: formatWatchedAt(item.watchedAt),
cover: (item.coverImg || "").trim(),
href: videoHref(String(id), item.slug),
};
}
-310
View File
@@ -1,310 +0,0 @@
"use client";
import { AlertDialog, Button, toast } from "@heroui/react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { EmptyState } from "@/components/common/empty-state";
import { Pagination } from "@/components/common/pagination";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
import { ApiError } from "@/lib/api/client";
import { clearPlayletHistory, getPlayletHistoryList } from "@/lib/api/playlet-actions";
import { clearVideoHistory, getVideoHistoryList } from "@/lib/api/video-actions";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import {
HISTORY_PAGE_SIZE,
historyTypes,
mapPlayletHistoryItem,
mapVideoHistoryItem,
type HistoryType,
} from "./data";
async function resolveToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
/** 观看历史:短剧 GET /api/app/playlet/history/list · 视频 GET /api/app/video/history/list */
export function HistoryPage() {
const router = useRouter();
const topRef = useRef<HTMLDivElement>(null);
const [type, setType] = useState<HistoryType>("短剧");
const [page, setPage] = useState(1);
const [items, setItems] = useState<ShortDramaItem[]>([]);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
void (async () => {
await Promise.resolve();
if (cancelled) return;
setLoading(true);
setError(null);
try {
const token = await resolveToken();
if (cancelled) return;
if (!token) {
setItems([]);
setTotalPages(1);
setError("请先登录后查看观看历史");
return;
}
if (type === "短剧") {
const data = await getPlayletHistoryList({ pageNum: page, pageSize: HISTORY_PAGE_SIZE }, token);
if (cancelled) return;
const list = (data?.list ?? [])
.map(mapPlayletHistoryItem)
.filter((item): item is ShortDramaItem => Boolean(item));
const total = Number(data?.totalCount) || 0;
setItems(list);
setTotalPages(Math.max(1, Math.ceil(total / HISTORY_PAGE_SIZE) || 1));
} else {
const data = await getVideoHistoryList({ pageNum: page, pageSize: HISTORY_PAGE_SIZE }, token);
if (cancelled) return;
const list = (data?.list ?? [])
.map(mapVideoHistoryItem)
.filter((item): item is ShortDramaItem => Boolean(item));
const total = Number(data?.totalCount) || 0;
setItems(list);
setTotalPages(Math.max(1, Math.ceil(total / HISTORY_PAGE_SIZE) || 1));
}
} catch (err) {
if (cancelled) return;
setItems([]);
setTotalPages(1);
setError(err instanceof ApiError ? err.message : "加载失败,请稍后重试");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [type, page, reloadKey]);
const changeType = (next: HistoryType) => {
setType(next);
setPage(1);
};
const changePage = (next: number) => {
setPage(next);
topRef.current?.scrollIntoView({ behavior: "smooth" });
};
const openClearConfirm = () => {
if (clearing || items.length === 0) return;
setConfirmOpen(true);
};
const clearHistory = async () => {
if (clearing || items.length === 0) return;
const label = type === "短剧" ? "短剧" : "视频";
const token = await resolveToken();
if (!token) {
setConfirmOpen(false);
toast.danger("请先登录后再清空");
return;
}
setClearing(true);
setError(null);
try {
// DELETE /api/app/playlet/history · DELETE /api/app/video/history
const result = type === "短剧" ? await clearPlayletHistory(token) : await clearVideoHistory(token);
const deleted = Number(result?.deletedCount) || 0;
setItems([]);
setTotalPages(1);
setPage(1);
setConfirmOpen(false);
setReloadKey((value) => value + 1);
toast.success(deleted > 0 ? `已清空${deleted}条${label}历史` : `${label}历史已清空`);
} catch (err) {
const message = err instanceof ApiError ? err.message : "清空失败,请稍后重试";
setError(message);
toast.danger(message);
} finally {
setClearing(false);
}
};
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push("/profile");
};
const isVideo = type === "视频";
const PcCard = isVideo ? VideoCard : ShortDramaCard;
const MobileCard = isVideo ? H5VideoCard : H5ShortDramaCard;
const empty = !loading && !error && items.length === 0;
const canClear = !clearing && items.length > 0;
const clearLabel = type === "短剧" ? "短剧" : "视频";
return (
<main data-component="history-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
<div className="relative flex h-12 items-center justify-center px-4 lg:hidden">
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<h1 className="text-base leading-[22px] font-bold text-white">观看历史</h1>
<button
type="button"
onClick={openClearConfirm}
disabled={!canClear}
className="cursor-pointer absolute right-4 text-xs leading-[18px] font-bold text-[#F12B9E] disabled:opacity-40"
>
{clearing ? "清空中" : "清空"}
</button>
</div>
<div
ref={topRef}
data-pc-scale
className="mx-auto flex w-full scroll-mt-24 flex-col gap-3 px-4 py-3 lg:w-[1700px] lg:gap-5 lg:px-0 lg:py-[30px] lg:[zoom:var(--page-scale,1)]"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: "个人中心", href: "/profile" },
{ name: "观看历史" },
]}
/>
<div className="hidden items-center lg:flex">
<div className="flex flex-1 flex-col gap-px">
<h2 className="text-[30px] leading-9 font-semibold text-white">观看历史</h2>
<p className="text-sm leading-5 text-[#9790A6]">找回最近观看的剧集</p>
</div>
<button
type="button"
onClick={openClearConfirm}
disabled={!canClear}
className="cursor-pointer h-9 w-24 rounded-[18px] bg-white/6 text-xs leading-[18px] font-semibold text-[#B5AEC1] transition-colors hover:text-white disabled:opacity-40"
>
{clearing ? "清空中" : "清空记录"}
</button>
</div>
<div className="flex gap-3 lg:gap-[9px]">
{historyTypes.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === type}
onClick={() => changeType(option)}
className={`cursor-pointer flex h-8 items-center justify-center rounded-2xl px-4 text-sm leading-5 transition-colors lg:w-[88px] lg:text-xs lg:leading-[18px] ${
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{option}
</button>
))}
</div>
{loading ? (
<p className="py-16 text-center text-sm text-[#948CA8]">加载中…</p>
) : error ? (
<EmptyState
title={error}
description="请检查网络后重试"
onReload={() => setReloadKey((value) => value + 1)}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : empty ? (
<EmptyState
title="暂无观看记录"
description="去首页发现更多好剧吧"
onReload={null}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : (
<>
<div
className={`hidden justify-between gap-x-5 gap-y-6 lg:grid ${
isVideo ? "grid-cols-[repeat(auto-fill,324px)]" : "grid-cols-[repeat(auto-fill,266px)]"
}`}
>
{items.map((item) => (
<PcCard key={item.id} item={item} />
))}
</div>
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3 lg:hidden">
{items.map((item) => (
<MobileCard key={item.id} item={item} showEpisodes />
))}
</div>
<Pagination page={page} total={totalPages} onChange={changePage} showJump={false} />
</>
)}
</div>
<AlertDialog
isOpen={confirmOpen}
onOpenChange={(open) => {
if (clearing) return;
setConfirmOpen(open);
}}
>
<AlertDialog.Backdrop variant="opaque" className="bg-black/70">
<AlertDialog.Container placement="center" size="sm">
<AlertDialog.Dialog className="w-[min(100vw-32px,360px)] rounded-2xl bg-[#211F2E] p-5 text-white shadow-[0_12px_40px_rgba(241,43,158,0.18)] outline outline-1 -outline-offset-1 outline-[#3A3652]">
<AlertDialog.Header className="mb-3 flex flex-col items-start gap-3">
<AlertDialog.Icon
status="danger"
className="size-9 rounded-full bg-[#F12B9E]/15 text-[#F12B9E] outline outline-1 -outline-offset-1 outline-[#F12B9E]/55"
/>
<AlertDialog.Heading className="text-lg leading-6 font-semibold text-white">
清空观看历史
</AlertDialog.Heading>
</AlertDialog.Header>
<AlertDialog.Body className="mb-5">
<p className="text-sm leading-5 text-[#B8B0C8]">
确定清空全部{clearLabel}观看历史?此操作不可恢复。
</p>
</AlertDialog.Body>
<AlertDialog.Footer className="flex items-center justify-end gap-3">
<Button
variant="ghost"
isDisabled={clearing}
onPress={() => setConfirmOpen(false)}
className="h-9 min-w-[72px] rounded-full px-4 text-sm text-[#C8C0D6] hover:bg-white/8 hover:text-white"
>
取消
</Button>
<Button
isDisabled={clearing}
onPress={() => void clearHistory()}
className="h-9 min-w-[96px] rounded-full bg-[#F12B9E] px-4 text-sm font-medium text-white hover:bg-[#F23DA8] disabled:opacity-60"
>
{clearing ? "清空中…" : "确定清空"}
</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
</AlertDialog>
</main>
);
}
@@ -1,21 +0,0 @@
"use client";
import Link from "next/link";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { useMockUser } from "@/components/profile/use-mock-user";
const fallbackAvatar = "/images/default_avatar.svg";
export function HeaderUserChip() {
const { user } = useMockUser();
const avatar = user?.avatar || fallbackAvatar;
const displayName = user?.name || "游客";
return (
<Link href="/profile" className="flex shrink-0 items-center gap-2">
<DecryptedImage src={avatar} fallback={fallbackAvatar} priority alt="" className="size-10 rounded-full object-cover" />
<span className="hidden max-w-[96px] truncate text-sm leading-5 text-white xl:inline">{displayName}</span>
</Link>
);
}
-220
View File
@@ -1,220 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { EmptyState } from "@/components/common/empty-state";
import { Pagination } from "@/components/common/pagination";
import {
FAVORITES_PAGE_SIZE,
favoriteTypes,
mapFavoritePlayletItem,
mapFavoriteVideoItem,
type FavoriteType,
} from "@/components/favorites/data";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { H5ShortDramaCard, ShortDramaCard } from "@/components/short-drama/short-drama-card";
import { H5VideoCard, VideoCard } from "@/components/video/video-card";
import { ApiError } from "@/lib/api/client";
import { getPlayletLikeList } from "@/lib/api/playlet-actions";
import { getVideoLikeList } from "@/lib/api/video-actions";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
async function resolveToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
/** 我的点赞:短剧 GET /api/app/playlet/like/list · 视频 GET /api/app/video/like/list */
export function LikesPage() {
const router = useRouter();
const topRef = useRef<HTMLDivElement>(null);
const [type, setType] = useState<FavoriteType>("短剧");
const [page, setPage] = useState(1);
const [items, setItems] = useState<ShortDramaItem[]>([]);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
void (async () => {
await Promise.resolve();
if (cancelled) return;
setLoading(true);
setError(null);
try {
const token = await resolveToken();
if (cancelled) return;
if (!token) {
setItems([]);
setTotalPages(1);
setError("请先登录后查看点赞");
return;
}
if (type === "短剧") {
const data = await getPlayletLikeList({ pageNum: page, pageSize: FAVORITES_PAGE_SIZE }, token);
if (cancelled) return;
const list = (data?.list ?? [])
.map(mapFavoritePlayletItem)
.filter((item): item is ShortDramaItem => Boolean(item));
const total = Number(data?.totalCount) || 0;
setItems(list);
setTotalPages(Math.max(1, Math.ceil(total / FAVORITES_PAGE_SIZE) || 1));
} else {
const data = await getVideoLikeList({ pageNum: page, pageSize: FAVORITES_PAGE_SIZE }, token);
if (cancelled) return;
const list = (data?.list ?? [])
.map(mapFavoriteVideoItem)
.filter((item): item is ShortDramaItem => Boolean(item));
const total = Number(data?.totalCount) || 0;
setItems(list);
setTotalPages(Math.max(1, Math.ceil(total / FAVORITES_PAGE_SIZE) || 1));
}
} catch (err) {
if (cancelled) return;
setItems([]);
setTotalPages(1);
setError(err instanceof ApiError ? err.message : "加载失败,请稍后重试");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [type, page, reloadKey]);
const changeType = (next: FavoriteType) => {
setType(next);
setPage(1);
};
const changePage = (next: number) => {
setPage(next);
topRef.current?.scrollIntoView({ behavior: "smooth" });
};
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push("/profile");
};
const isVideo = type === "视频";
const PcCard = isVideo ? VideoCard : ShortDramaCard;
const MobileCard = isVideo ? H5VideoCard : H5ShortDramaCard;
const empty = !loading && !error && items.length === 0;
return (
<main data-component="likes-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
<div className="relative flex h-12 items-center justify-center px-4 lg:hidden">
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path d="M15 5l-7 7 7 7" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<h1 className="text-base leading-[22px] font-bold text-white">我的点赞</h1>
</div>
<div
ref={topRef}
data-pc-scale
className="mx-auto flex w-full scroll-mt-24 flex-col gap-3 px-4 py-3 lg:w-[1700px] lg:gap-5 lg:px-0 lg:py-[30px] lg:[zoom:var(--page-scale,1)]"
>
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: "个人中心", href: "/profile" },
{ name: "我的点赞" },
]}
/>
<div className="hidden flex-col gap-px lg:flex">
<h2 className="text-[30px] leading-9 font-semibold text-white">我的点赞</h2>
<p className="text-sm leading-5 text-[#9790A6]">我喜欢的我都点赞</p>
</div>
<div className="flex gap-3 lg:hidden">
{favoriteTypes.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === type}
onClick={() => changeType(option)}
className={`cursor-pointer flex h-8 items-center justify-center rounded-2xl px-4 text-sm leading-5 transition-colors ${
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{option}
</button>
))}
</div>
<div className="hidden gap-[9px] lg:flex">
{favoriteTypes.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === type}
onClick={() => changeType(option)}
className={`cursor-pointer flex h-8 w-[88px] items-center justify-center rounded-2xl px-4 text-xs leading-[18px] transition-colors ${
option === type ? "bg-[#F12B9E] text-white" : "bg-[#242138] text-[#B4AFC4] hover:text-white"
}`}
>
{option}
</button>
))}
</div>
{loading ? (
<p className="py-16 text-center text-sm text-[#948CA8]">加载中…</p>
) : error ? (
<EmptyState
title={error}
description="请检查网络后重试"
onReload={() => setReloadKey((value) => value + 1)}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : empty ? (
<EmptyState
title="暂无点赞内容"
description="去首页发现更多好剧吧"
onReload={null}
className="rounded-2xl px-4 py-12 lg:rounded-3xl lg:py-24"
/>
) : (
<>
<div
className={`hidden justify-between gap-x-5 gap-y-6 lg:grid ${
isVideo ? "grid-cols-[repeat(auto-fill,324px)]" : "grid-cols-[repeat(auto-fill,266px)]"
}`}
>
{items.map((item) => (
<PcCard key={`${type}-${item.id}`} item={item} />
))}
</div>
<div className="grid grid-cols-2 gap-x-[7px] gap-y-3 lg:hidden">
{items.map((item) => (
<MobileCard key={`${type}-${item.id}`} item={item} showEpisodes={false} />
))}
</div>
<Pagination page={page} total={totalPages} onChange={changePage} showJump={false} />
</>
)}
</div>
</main>
);
}
-235
View File
@@ -1,235 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { getPlayletFavoriteList, getPlayletLikeList } from "@/lib/api/playlet-actions";
import { getVideoFavoriteList, getVideoLikeList } from "@/lib/api/video-actions";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
import { useMockUser } from "./use-mock-user";
const fallbackAvatar = "/images/default_avatar.svg";
type MenuItem = {
label: string;
icon: string;
href: string;
extra?: string;
small?: boolean;
highlight?: boolean;
};
const staticMenuItems: MenuItem[] = [
{ label: "我的点赞", icon: "/images/icon_like.svg", href: "/likes", small: true },
{ label: "我的收藏", icon: "/images/collect.svg", href: "/favorites" },
{ label: "观看历史", icon: "/images/history.svg", href: "/history", extra: "继续观看" },
{ label: "回家的路", icon: "/images/home.svg", href: "/way-home", highlight: true },
{ label: "账号设置", icon: "/images/profile.svg", href: "/settings" },
{ label: "问题反馈", icon: "/images/wtfk.svg", href: "/feedback" },
];
function formatBuCount(count: number | null) {
if (count == null) return "";
return `${count}部`;
}
async function resolveToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
async function readTotal(
loader: (query: { pageNum: number; pageSize: number }, token: string) => Promise<{ totalCount?: number } | null>,
token: string,
) {
try {
const data = await loader({ pageNum: 1, pageSize: 1 }, token);
return Number(data?.totalCount) || 0;
} catch (error) {
console.warn("[profile/counts]", error);
return 0;
}
}
function Avatar({ src }: { src: string }) {
return (
<DecryptedImage
src={src}
fallback={fallbackAvatar}
priority
alt=""
className="size-[60px] shrink-0 rounded-full object-cover outline-2 -outline-offset-2 outline-[#FFC590]"
/>
);
}
const guestActionBtn =
"cursor-pointer flex h-7 shrink-0 items-center justify-center gap-1 rounded-full bg-white px-2 text-[#120F1C]";
function GuestCard({ name, avatar, id }: { name: string; avatar: string; id?: string }) {
return (
<section className="flex items-start gap-3 bg-[#121117] px-2 py-3">
<Avatar src={avatar} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h2 className="truncate text-lg leading-[30px] font-semibold text-white">{name}</h2>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5">
<span className="shrink-0 text-sm leading-5 text-[#AEA8BC]">
{id ? `ID:${id}` : "游客登录中…"}
</span>
<button type="button" onClick={() => openLoginDialog()} className={guestActionBtn}>
<span className="text-sm leading-5 font-semibold">→</span>
<span className="text-xs leading-[18px] font-medium">登录账号</span>
</button>
<button type="button" onClick={() => openLoginDialog()} className={guestActionBtn}>
<span className="text-sm leading-5 font-semibold">→</span>
<span className="text-xs leading-[18px] font-medium">绑定账号</span>
</button>
</div>
<p className="text-xs leading-[18px] text-[#F12B9E]">
登录正式账号后可同步收藏、追剧、观看历史和金币余额。
</p>
</div>
</section>
);
}
function UserCard({ name, avatar, id, onSwitch }: { name: string; avatar: string; id: string; onSwitch: () => void }) {
return (
<section className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<Avatar src={avatar} />
<div className="flex min-w-0 flex-col gap-1">
<h2 className="truncate text-lg leading-[30px] font-semibold text-white">{name}</h2>
<div className="flex items-center gap-2 text-xs leading-[18px] text-[#948CA6]">
<span>ID:{id}</span>
<button type="button" onClick={() => navigator.clipboard?.writeText(id)} className="cursor-pointer underline">
复制
</button>
</div>
</div>
</div>
<button
type="button"
onClick={onSwitch}
className="cursor-pointer flex h-7 shrink-0 items-center rounded-full bg-white/10 px-[13px] text-xs leading-[18px] text-white/90"
>
切换账号
</button>
</section>
);
}
export function ProfileH5() {
const router = useRouter();
const { user, logout } = useMockUser({ refreshOnMount: true });
const [likeCount, setLikeCount] = useState<number | null>(null);
const [favoriteCount, setFavoriteCount] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
const token = await resolveToken();
if (!token || cancelled) {
if (!cancelled) {
setLikeCount(0);
setFavoriteCount(0);
}
return;
}
const [playletLike, videoLike, playletFav, videoFav] = await Promise.all([
readTotal(getPlayletLikeList, token),
readTotal(getVideoLikeList, token),
readTotal(getPlayletFavoriteList, token),
readTotal(getVideoFavoriteList, token),
]);
if (cancelled) return;
setLikeCount(playletLike + videoLike);
setFavoriteCount(playletFav + videoFav);
})();
return () => {
cancelled = true;
};
}, [user?.id]);
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push("/");
};
const switchAccount = () => {
logout();
openLoginDialog();
};
const menuItems = staticMenuItems.map((item) => {
if (item.label === "我的点赞") return { ...item, extra: formatBuCount(likeCount) };
if (item.label === "我的收藏") return { ...item, extra: formatBuCount(favoriteCount) };
return item;
});
return (
<div data-component="profile-h5" className="flex flex-col lg:hidden">
<div className="relative flex h-12 items-center justify-center px-4">
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path
d="M15 5l-7 7 7 7"
fill="none"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<h1 className="text-base leading-[22px] font-bold text-white">我的</h1>
</div>
<div className="flex flex-col gap-4 px-4 pb-6">
{user?.isRegister ? (
<UserCard name={user.name} avatar={user.avatar} id={user.id} onSwitch={switchAccount} />
) : (
<GuestCard
name={user?.name ?? "游客"}
avatar={user?.avatar ?? "/images/default_avatar.svg"}
id={user?.id}
/>
)}
<nav className="flex flex-col overflow-hidden rounded-2xl bg-[#121117]" aria-label="个人中心菜单">
{menuItems.map((item) => (
<Link key={item.label} href={item.href} className="flex h-12 items-center justify-between px-3.5">
<span
className={`flex items-center gap-1 text-sm leading-5 ${item.highlight ? "text-white" : "text-[#EBE5F2]"}`}
>
<img src={item.icon} alt="" className={`size-4 object-contain ${item.small ? "p-px" : ""}`} />
{item.label}
</span>
<span className="flex items-center gap-1.5 text-xs leading-[18px] text-[#857D94]">
{item.extra}
<img src="/images/arrow_rught.svg" alt="" className="size-[18px]" />
</span>
</Link>
))}
</nav>
</div>
</div>
);
}
-68
View File
@@ -1,68 +0,0 @@
"use client";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { useMockUser } from "./use-mock-user";
const fallbackAvatar = "/images/default_avatar.svg";
const primaryBtn =
"cursor-pointer flex h-[42px] items-center justify-center gap-2 rounded-full bg-white px-4 text-[#120F1C]";
const secondaryBtn =
"cursor-pointer flex h-[42px] items-center justify-center rounded-full bg-white/10 px-4 text-sm leading-5 font-semibold text-white outline outline-1 -outline-offset-1 outline-white/20 hover:bg-white/15";
export function ProfileHero() {
const { user, logout } = useMockUser({ refreshOnMount: true });
const isGuest = !user || !user.isRegister;
const switchAccount = () => {
logout();
openLoginDialog();
};
return (
<section data-component="profile-hero" className="flex items-center gap-4 overflow-hidden rounded-3xl bg-[linear-gradient(90deg,rgba(241,43,158,0.17)_0%,rgba(62,102,255,0.18)_100%)] p-5 outline outline-white/14 -outline-offset-1 lg:h-44 lg:gap-5 lg:px-7 lg:py-6">
<DecryptedImage
src={user?.avatar}
fallback={fallbackAvatar}
priority
alt=""
className="size-16 shrink-0 rounded-full object-cover lg:size-[84px]"
/>
{isGuest ? (
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h2 className="truncate text-xl leading-7 font-semibold text-white lg:text-2xl lg:leading-[30px]">
{user?.name ?? "游客"}
</h2>
<div className="flex flex-wrap gap-x-4 text-sm leading-5">
<span className="text-[#AEA8BC]">{user ? `ID:${user.id}` : "游客登录中…"}</span>
<span className="text-[#F12B9E]">登录正式账号后可同步收藏、追剧、观看历史和金币余额。</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 lg:mt-0">
<button type="button" onClick={() => openLoginDialog()} className={primaryBtn}>
<span className="text-base leading-[22px] font-semibold">→</span>
<span className="text-sm leading-5 font-semibold">登录账号</span>
</button>
<button type="button" onClick={() => openLoginDialog()} className={primaryBtn}>
<span className="text-base leading-[22px] font-semibold">→</span>
<span className="text-sm leading-5 font-semibold">绑定账号</span>
</button>
</div>
</div>
) : (
<div className="flex min-w-0 flex-1 items-center justify-between gap-4">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="truncate text-xl leading-7 font-semibold text-white lg:text-2xl lg:leading-[30px]">
{user.name}
</h2>
<p className="truncate text-sm leading-5 text-[#AEA8BC]">{user.accountNo || user.email}</p>
</div>
<button type="button" onClick={switchAccount} className={`shrink-0 ${secondaryBtn}`}>
切换账号
</button>
</div>
)}
</section>
);
}
-69
View File
@@ -1,69 +0,0 @@
import Link from "next/link";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import { ProfileH5 } from "./profile-h5";
import { ProfileHero } from "./profile-hero";
const entries = [
{
title: "我的点赞",
description: "我喜欢的我都点赞",
href: "/likes",
image: "/images/my_dz.png",
imageClassName: "h-[93px] w-[98px]",
},
{
title: "追剧与收藏",
description: "继续关注喜欢的内容",
href: "/favorites",
image: "/images/my_sc.png",
imageClassName: "h-[100px] w-[110px]",
},
{
title: "账号设置",
description: "资料与安全设置",
href: "/settings",
image: "/images/my_sz.png",
imageClassName: "h-[100px] w-[98px]",
},
];
export function ProfilePage() {
return (
<main data-component="profile-page" className="flex flex-1 flex-col lg:bg-[linear-gradient(90deg,#1F081D_0%,#070714_48%,#09183A_100%)]">
<SeoBreadcrumb items={[{ name: "首页", href: "/" }, { name: "个人中心" }]} />
<ProfileH5 />
<div className="mx-auto hidden w-full max-w-[1748px] flex-col gap-[18px] px-6 py-[26px] lg:flex">
<div className="flex flex-col gap-0.5">
<p className="text-xs leading-[18px] font-semibold text-[#F160B1]">ACCOUNT</p>
<p className="text-2xl leading-8 font-semibold text-white lg:text-[34px] lg:leading-10">个人中心</p>
<p className="text-sm leading-5 text-[#9790A6]">欢迎回来,继续你的追剧旅程</p>
</div>
<ProfileHero />
<div className="grid gap-5 lg:grid-cols-3">
{entries.map((entry) => (
<Link
key={entry.title}
href={entry.href}
className="group relative flex h-[132px] items-center justify-between rounded-[32px_64px_32px_32px] bg-[linear-gradient(180deg,#241929_0%,rgba(30,16,34,0.31)_100%)] pt-[15px] pr-[46px] pb-[17px] pl-[31px] outline-1 outline-transparent -outline-offset-1 backdrop-blur-[1px] transition-[outline-color,box-shadow] duration-300 hover:shadow-[0_0_4px_rgba(191,0,101,0.3),0_0_5px_rgba(255,55,161,0.4),0_0_3px_rgba(255,55,161,0.6),0.25px_0.25px_1px_#C40068,-0.25px_-0.25px_1px_#C40068] hover:outline-[#FF37A1]"
>
<div className="flex min-w-0 flex-col gap-[5px]">
<h3 className="text-lg leading-6 font-semibold text-white">{entry.title}</h3>
<p className="text-xs leading-[18px] text-[#9790A6] transition-colors duration-300 group-hover:text-[#D6D6D6]">
{entry.description}
</p>
</div>
<div className="relative shrink-0 overflow-hidden">
<span className="absolute top-1/2 left-1/2 h-[71px] w-[73px] -translate-1/2 rounded-full bg-[#66103D] blur-[20px] transition-colors duration-300 group-hover:bg-[#A6225F]" />
<img src={entry.image} alt="" className={`relative object-contain ${entry.imageClassName}`} />
</div>
</Link>
))}
</div>
</div>
</main>
);
}
@@ -1,193 +0,0 @@
"use client";
import { useEffect, useState, type FormEvent } from "react";
import {
CREDENTIAL_MAX_LEN,
isValidCredential,
PASSWORD_RULE_HINT,
} from "@/lib/auth/credentials";
import { updateUserProfile } from "@/lib/auth/session";
type Field = "oldPassword" | "newPassword" | "confirmPassword";
const fields: { key: Field; label: string; placeholder: string }[] = [
{ key: "oldPassword", label: "旧密码", placeholder: "请输入旧密码" },
{ key: "newPassword", label: "新密码", placeholder: "请输入新密码" },
{ key: "confirmPassword", label: "再次确认新密码", placeholder: "请再次输入新密码" },
];
const emptyValues: Record<Field, string> = { oldPassword: "", newPassword: "", confirmPassword: "" };
function validate(values: Record<Field, string>) {
if (!values.oldPassword) return "请输入旧密码";
if (!isValidCredential(values.newPassword)) return PASSWORD_RULE_HINT;
if (values.newPassword === values.oldPassword) return "新密码不能与旧密码相同";
if (values.confirmPassword !== values.newPassword) return "两次输入的新密码不一致";
return "";
}
export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const [values, setValues] = useState(emptyValues);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => event.key === "Escape" && !saving && onClose();
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = overflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [open, onClose, saving]);
if (!open) return null;
const close = () => {
if (saving) return;
setValues(emptyValues);
setError("");
onClose();
};
const submit = async (event: FormEvent) => {
event.preventDefault();
const message = validate(values);
if (message) {
setError(message);
return;
}
setSaving(true);
setError("");
try {
await updateUserProfile({
oldPassword: values.oldPassword,
newPassword: values.newPassword,
});
setValues(emptyValues);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "修改密码失败");
} finally {
setSaving(false);
}
};
return (
<div data-component="change-password-dialog"
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10"
onClick={(event) => event.target === event.currentTarget && close()}
>
<div className="my-auto flex flex-col items-center gap-2.5 lg:gap-6">
<form
role="dialog"
aria-modal="true"
aria-labelledby="change-password-title"
onSubmit={submit}
className="flex w-[310px] flex-col items-center gap-4 rounded-3xl bg-[#0A0915]/90 px-8 py-7 shadow-[0_20px_50px_rgba(0,0,0,0.35)] outline outline-1 -outline-offset-1 outline-white/13 lg:w-[400px] lg:gap-3.5"
>
<img src="/logo.svg" alt="海角社区" className="h-12 w-auto" />
<div className="flex flex-col gap-2 self-stretch">
<h2 id="change-password-title" className="text-[30px] leading-9 font-semibold text-white">
修改密码
</h2>
<p className="text-sm leading-5 text-[#A8A1B6]">新密码需为 4-20 位,不能包含空格,且不同于旧密码</p>
</div>
{fields.map((field) => (
<PasswordField
key={field.key}
label={field.label}
placeholder={field.placeholder}
value={values[field.key]}
onChange={(value) => {
setValues((current) => ({ ...current, [field.key]: value }));
setError("");
}}
/>
))}
{error ? <p className="-my-1 self-stretch text-xs leading-[18px] text-[#FF5A7A]">{error}</p> : null}
<div className="flex gap-3.5 self-stretch">
<button
type="button"
onClick={close}
disabled={saving}
className="cursor-pointer h-12 flex-1 rounded-xl bg-[#1B1A26] text-sm leading-5 font-semibold text-white transition-colors hover:bg-[#252333] disabled:opacity-60"
>
取消
</button>
<button
type="submit"
disabled={saving}
className="cursor-pointer h-12 flex-1 rounded-xl bg-[#F12B9E] text-sm leading-5 font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-60"
>
{saving ? "提交中…" : "确定"}
</button>
</div>
</form>
<button type="button" aria-label="关闭" onClick={close} className="cursor-pointer size-11 lg:size-9">
<svg viewBox="0 0 44 44" className="size-full" aria-hidden="true">
<circle cx="22" cy="22" r="21" fill="none" stroke="white" strokeWidth="2" />
<path d="M16 16l12 12M28 16L16 28" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
}
function PasswordField({
label,
placeholder,
value,
onChange,
}: {
label: string;
placeholder: string;
value: string;
onChange: (value: string) => void;
}) {
const [visible, setVisible] = useState(false);
return (
<label className="flex flex-col gap-2 self-stretch">
<span className="text-sm leading-5 font-semibold text-[#E0DDE8]">{label}</span>
<span className="flex h-12 items-center gap-2 rounded-xl bg-white/7 px-3.5 outline outline-1 -outline-offset-1 outline-white/10 focus-within:outline-[#F12B9E]/60">
<svg viewBox="0 0 20 20" className="size-5 shrink-0" aria-hidden="true">
<rect x="4" y="8.5" width="12" height="8.5" rx="2" fill="none" stroke="#A8A1B8" strokeWidth="1.5" />
<path d="M7 8.5V6.5a3 3 0 0 1 6 0v2" fill="none" stroke="#A8A1B8" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
type={visible ? "text" : "password"}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
maxLength={CREDENTIAL_MAX_LEN}
autoComplete={label === "旧密码" ? "current-password" : "new-password"}
className="min-w-0 flex-1 bg-transparent text-sm leading-5 text-white outline-none placeholder:text-[#746E85]"
/>
<button
type="button"
aria-label={visible ? "隐藏密码" : "显示密码"}
onClick={() => setVisible((current) => !current)}
className="cursor-pointer flex size-5 shrink-0 items-center justify-center"
>
<svg viewBox="0 0 20 16" className="h-[13.33px] w-[17.5px]" aria-hidden="true">
<path
d="M1 8s3.3-6 9-6 9 6 9 6-3.3 6-9 6-9-6-9-6z"
fill="none"
stroke="#A8A1B8"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<circle cx="10" cy="8" r="2.6" fill="#A8A1B8" />
{visible ? null : <path d="M3 15L17 1" stroke="#A8A1B8" strokeWidth="1.6" strokeLinecap="round" />}
</svg>
</button>
</span>
</label>
);
}
@@ -1,174 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
import { BackButton } from "@/components/common/back-button";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { useMockUser } from "@/components/profile/use-mock-user";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import { useMockProfile } from "@/components/settings/use-mock-profile";
import { updateUserProfile } from "@/lib/auth/session";
const fallbackAvatar = "/images/default_avatar.svg";
type ProfileEditFormProps = {
onSaved?: () => void;
actions?: ReactNode;
className?: string;
};
export function ProfileEditForm({ onSaved, actions, className }: ProfileEditFormProps) {
const { user } = useMockUser({ refreshOnMount: true });
const { profile, saveProfile } = useMockProfile();
const [nicknameDraft, setNicknameDraft] = useState<string | null>(null);
const [bioDraft, setBioDraft] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null);
const nickname = nicknameDraft ?? user?.name ?? profile.nickname;
const bio = bioDraft ?? profile.bio;
const avatar = user?.avatar ?? profile.avatar ?? fallbackAvatar;
const submit = async (event: FormEvent) => {
event.preventDefault();
const trimmed = nickname.trim();
if (!trimmed) {
setMessage({ text: "请输入昵称", error: true });
return;
}
const nickChanged = trimmed !== (user?.name || "");
const bioChanged = bio.trim() !== (profile.bio || "").trim();
if (nickChanged && !user?.accountNo) {
setMessage({ text: "请先注册账号后再修改资料", error: true });
return;
}
if (!nickChanged && !bioChanged) {
setMessage({ text: "暂无需要保存的修改", error: true });
return;
}
setSaving(true);
setMessage(null);
try {
if (nickChanged) {
await updateUserProfile({ nickName: trimmed });
}
saveProfile({
nickname: trimmed,
bio: bio.trim(),
avatar: null,
});
setNicknameDraft(null);
setBioDraft(null);
setMessage({ text: "已保存", error: false });
onSaved?.();
} catch (error) {
setMessage({
text: error instanceof Error ? error.message : "保存失败",
error: true,
});
} finally {
setSaving(false);
}
};
return (
<form
data-component="profile-edit-form"
onSubmit={submit}
className={className ?? "flex flex-col gap-3.5 p-4"}
>
<div className="flex h-28 items-center justify-between rounded-2xl bg-[#13111C] px-4">
<div className="flex flex-col gap-1.5">
<span className="text-base leading-[22px] font-bold text-[#FAF7FF]">个人头像</span>
<span className="text-xs leading-[18px] text-[#9E94AD]">当前头像</span>
</div>
<DecryptedImage
src={avatar}
fallback={fallbackAvatar}
priority
alt="头像"
className="size-16 shrink-0 rounded-full object-cover outline outline-1 -outline-offset-1 outline-white/15"
/>
</div>
<label className="flex h-[76px] flex-col gap-2 rounded-2xl bg-[#13111C] px-3.5 py-3">
<span className="text-xs leading-[18px] font-bold text-[#9E94AD]">昵称</span>
<input
value={nickname}
onChange={(event) => setNicknameDraft(event.target.value)}
maxLength={20}
placeholder="请输入昵称"
className="h-5 bg-transparent text-sm leading-5 text-[#FAF7FF] outline-none placeholder:text-[#5F586E]"
/>
</label>
<label className="flex h-24 flex-col gap-2 rounded-2xl bg-[#13111C] px-3.5 py-3">
<span className="text-xs leading-[18px] font-bold text-[#9E94AD]">个人简介</span>
<textarea
value={bio}
onChange={(event) => setBioDraft(event.target.value)}
maxLength={60}
rows={2}
placeholder="分享你的追剧偏好"
className="min-h-0 flex-1 resize-none bg-transparent text-sm leading-5 text-[#FAF7FF] outline-none placeholder:text-[#5F586E]"
/>
</label>
{message ? (
<p className={`text-xs leading-[18px] ${message.error ? "text-[#FF5A7A]" : "text-[#4ADE80]"}`}>
{message.text}
</p>
) : null}
{actions}
<button
type="submit"
disabled={saving}
className="cursor-pointer h-[50px] rounded-[14px] bg-[#F12B9E] text-base leading-[22px] font-bold text-[#FAF7FF] active:opacity-90 disabled:opacity-60"
>
{saving ? "保存中…" : "保存修改"}
</button>
</form>
);
}
export function EditProfilePage() {
const router = useRouter();
useEffect(() => {
const media = window.matchMedia("(min-width: 1024px)");
const redirect = () => media.matches && router.replace("/settings");
redirect();
media.addEventListener("change", redirect);
return () => media.removeEventListener("change", redirect);
}, [router]);
return (
<main data-component="edit-profile-page" className="flex-1 bg-[#07060D] lg:hidden">
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: "个人中心", href: "/profile" },
{ name: "账号设置", href: "/settings" },
{ name: "编辑个人资料" },
]}
/>
<div className="relative flex h-12 items-center justify-center px-4">
<BackButton fallback="/settings" className="absolute left-4" />
<h1 className="text-lg leading-6 font-bold text-[#FAF7FF]">编辑个人资料</h1>
</div>
<ProfileEditForm
onSaved={() =>
window.setTimeout(() => (window.history.length > 1 ? router.back() : router.replace("/settings")), 600)
}
/>
</main>
);
}
-110
View File
@@ -1,110 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { openLoginDialog } from "@/components/auth/login-dialog";
import { useMockUser } from "@/components/profile/use-mock-user";
import { SeoBreadcrumb } from "@/components/seo/seo-breadcrumb";
import { ChangePasswordDialog } from "@/components/settings/change-password-dialog";
import { ProfileEditForm } from "@/components/settings/edit-profile-page";
const panelClass =
"flex flex-col gap-3.5 rounded-[20px] bg-white/5 p-6 outline outline-1 -outline-offset-1 outline-white/10";
export function SettingsPage() {
const router = useRouter();
const { logout } = useMockUser({ refreshOnMount: true });
const [passwordOpen, setPasswordOpen] = useState(false);
const closePassword = useCallback(() => setPasswordOpen(false), []);
const switchAccount = () => {
logout();
openLoginDialog();
};
const goBack = () => {
if (window.history.length > 1) router.back();
else router.push("/profile");
};
return (
<main data-component="settings-page" className="flex-1 bg-[#07060D] lg:bg-[#070611]">
<div className="lg:hidden">
<div className="relative flex h-12 items-center justify-center px-4">
<button type="button" aria-label="返回" onClick={goBack} className="cursor-pointer absolute left-4 size-6">
<svg viewBox="0 0 24 24" className="size-full" aria-hidden="true">
<path
d="M15 5l-7 7 7 7"
fill="none"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<h1 className="text-lg leading-6 font-bold text-[#FAF7FF]">账号设置</h1>
</div>
<ProfileEditForm
actions={
<button
type="button"
onClick={() => setPasswordOpen(true)}
className="cursor-pointer h-12 rounded-[14px] bg-[#13111C] text-sm leading-5 font-bold text-[#F12B9E] outline-1 -outline-offset-1 outline-[#F12B9E]"
>
修改密码
</button>
}
/>
</div>
<div className="mx-auto hidden w-full flex-col gap-[18px] lg:flex lg:w-[calc(100%-64px)] lg:max-w-[1700px] lg:py-[30px]">
<SeoBreadcrumb
items={[
{ name: "首页", href: "/" },
{ name: "个人中心", href: "/profile" },
{ name: "账号设置" },
]}
/>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<p className="text-2xl leading-8 font-semibold text-white lg:text-[30px] lg:leading-9">账号设置</p>
<p className="text-sm leading-5 text-[#9790A6]">管理个人资料、账号安全和通知偏好</p>
</div>
<div className="grid gap-[18px] lg:grid-cols-[minmax(0,1fr)_490px] lg:items-start">
<section className={panelClass}>
<h2 className="text-xl leading-[26px] font-semibold text-white">个人资料</h2>
<ProfileEditForm className="flex flex-col gap-3.5 p-0" />
</section>
<section className={`${panelClass} lg:min-h-[510px]`}>
<h2 className="text-xl leading-[26px] font-semibold text-white">账号安全</h2>
<div className="flex flex-col justify-center gap-4 rounded-2xl bg-[#222033]/41 p-4 lg:h-[110px]">
<div className="flex items-center justify-between">
<span className="text-xl leading-7 font-medium text-[#DDD9E5]">登录密码</span>
<button
type="button"
onClick={() => setPasswordOpen(true)}
className="cursor-pointer text-xl leading-7 font-medium text-[#F12B9E] hover:opacity-80"
>
修改密码
</button>
</div>
<p className="text-base leading-[22px] text-[#A49AB6]">建议定期更新登录密码</p>
</div>
<button
type="button"
onClick={switchAccount}
className="cursor-pointer h-10 w-[130px] rounded-[20px] bg-white/6 text-sm leading-5 font-semibold text-[#F160B1] outline outline-1 -outline-offset-1 outline-[#F160B1]/35 transition-colors hover:bg-white/10"
>
切换账号
</button>
</section>
</div>
</div>
<ChangePasswordDialog open={passwordOpen} onClose={closePassword} />
</main>
);
}
@@ -1,53 +0,0 @@
"use client";
import { useCallback, useMemo, useSyncExternalStore } from "react";
export type MockProfile = {
nickname: string;
bio: string;
avatar: string | null;
};
const STORAGE_KEY = "hjsq-mock-profile";
const CHANGE_EVENT = "hjsq-mock-profile-change";
export const defaultProfile: MockProfile = {
nickname: "爽剧网用户",
bio: "热爱短剧,记录每一次心动",
avatar: null,
};
function subscribe(callback: () => void) {
window.addEventListener("storage", callback);
window.addEventListener(CHANGE_EVENT, callback);
return () => {
window.removeEventListener("storage", callback);
window.removeEventListener(CHANGE_EVENT, callback);
};
}
const getSnapshot = () => localStorage.getItem(STORAGE_KEY);
const getServerSnapshot = () => null;
export function useMockProfile() {
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const profile = useMemo<MockProfile>(() => {
if (!raw) return defaultProfile;
try {
return { ...defaultProfile, ...(JSON.parse(raw) as Partial<MockProfile>) };
} catch {
return defaultProfile;
}
}, [raw]);
const saveProfile = useCallback((next: MockProfile) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...next, avatar: null }));
}
window.dispatchEvent(new Event(CHANGE_EVENT));
}, []);
return { profile, saveProfile };
}
@@ -1,46 +0,0 @@
import Link from "next/link";
import { DecryptedImage } from "@/components/common/decrypted-image";
import type { ShortDramaItem } from "@/components/short-drama/data";
import { videoHref } from "@/components/video/video-card";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
export function VideoRelatedAside({ items }: { items: ShortDramaItem[] }) {
const list = items.filter((item) => item.kind === "drama");
if (!list.length) return null;
return (
<aside data-component="video-related-aside" className="flex min-w-0 flex-1 flex-col gap-4 rounded-2xl bg-[rgba(24,23,46,0.88)] p-6 outline outline-1 -outline-offset-1 outline-[#383657]">
<div className="flex h-9 items-center justify-between">
<h2 className="text-[22px] font-bold text-white">热门推荐</h2>
<Link href="/video/hot" className="flex items-center gap-1 text-xs leading-[18px] text-[#B8B2C2] hover:text-white">
更多
<img src="/images/more_arrow.svg" alt="" className="size-3.5" />
</Link>
</div>
{list.map((item) => (
<Link
key={item.id}
href={item.href || videoHref(item.id, item.slug)}
className="group flex h-24 items-center gap-4"
>
<DecryptedImage
src={item.cover}
fallback={IMG_PLACEHOLDER}
alt=""
className="h-24 w-[128px] shrink-0 rounded-lg object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<h3 className="line-clamp-2 text-base leading-[23px] text-[#F5F0FA] group-hover:text-[#F12B9E]">
{item.title}
</h3>
<div className="flex items-center justify-between text-xs leading-[18px]">
<span className="truncate text-[#A89EB5]">{item.tags[0]?.name}</span>
<span className="shrink-0 text-[#ABA1B8]">{item.heat}</span>
</div>
</div>
</Link>
))}
</aside>
);
}
@@ -1,46 +0,0 @@
"use client";
import { useCallback, useRef } from "react";
import { reportVideoHistory } from "@/lib/api/video-actions";
import { ensureGuestSession, getAuthToken } from "@/lib/auth/session";
async function ensureToken() {
let token = getAuthToken();
if (!token) {
try {
await ensureGuestSession();
} catch {
/* ignore */
}
token = getAuthToken();
}
return token;
}
/** 播放成功后上报观看历史;同一视频 id 只报一次 */
export function useReportVideoHistory(videoId: number) {
const reportedIdRef = useRef<number | null>(null);
return useCallback(async () => {
if (!Number.isInteger(videoId) || videoId < 1) return;
if (reportedIdRef.current === videoId) return;
reportedIdRef.current = videoId;
const token = await ensureToken();
if (!token) {
reportedIdRef.current = null;
return;
}
try {
await reportVideoHistory({ id: videoId }, token);
} catch (error) {
reportedIdRef.current = null;
if (process.env.NODE_ENV === "development") {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[video/history] report failed: ${message}`);
}
}
}, [videoId]);
}
-56
View File
@@ -1,56 +0,0 @@
import { apiRequest } from "./client";
import type {
CommentLikeSetReq,
CommentLikeSetResp,
CommentListQuery,
CommentListResp,
CommentSendReq,
} from "./types";
/** 客户端:评论列表 GET /api/app/comment/list */
export function getCommentList(
query: CommentListQuery,
options?: { token?: string | null; signal?: AbortSignal },
) {
const params = new URLSearchParams({
objectId: String(query.objectId),
objectType: String(query.objectType),
pageNum: String(query.pageNum ?? 1),
pageSize: String(query.pageSize ?? 6),
sortOrder: String(query.sortOrder ?? 2),
});
return apiRequest<CommentListResp>(`/api/app/comment/list?${params}`, {
method: "GET",
token: options?.token,
signal: options?.signal,
});
}
/** 客户端:发布评论 POST /api/app/comment/send */
export function sendComment(payload: CommentSendReq, token: string, signal?: AbortSignal) {
return apiRequest<string>("/api/app/comment/send", {
method: "POST",
body: {
objectId: payload.objectId,
objectType: payload.objectType,
text: payload.text,
parentsId: payload.parentsId ?? 0,
},
token,
signal,
});
}
/** 客户端:评论点赞/取消点赞 POST /api/app/comment/like/set */
export function setCommentLike(payload: CommentLikeSetReq, token: string, signal?: AbortSignal) {
return apiRequest<CommentLikeSetResp>("/api/app/comment/like/set", {
method: "POST",
body: {
commentId: payload.commentId,
isLike: payload.isLike,
},
token,
signal,
});
}