mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-ui.git
synced 2026-11-06 17:09:08 +08:00
Compare commits
318 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2563f122fc | ||
|
|
8739811b04 | ||
|
|
5973d43b9d | ||
|
|
d113f5c553 | ||
|
|
5c7b109be6 | ||
|
|
97e611c072 | ||
|
|
68dfae9a62 | ||
|
|
bb8e9a0d3d | ||
|
|
f4fd1c1acd | ||
|
|
21abd26b1f | ||
|
|
a55c8356c8 | ||
|
|
b025ab804e | ||
|
|
700c709afd | ||
|
|
8d0788e09b | ||
|
|
55a431ddd5 | ||
|
|
b72c792ade | ||
|
|
1f872254cc | ||
|
|
99e7f6ed47 | ||
|
|
06e02998aa | ||
|
|
1395f2ad24 | ||
|
|
67e8c86b5b | ||
|
|
9fcd325df9 | ||
|
|
f21ce6e252 | ||
|
|
da95f17885 | ||
|
|
0f9593d5ed | ||
|
|
088a2bb308 | ||
|
|
d29ad9c99b | ||
|
|
d87e0c5f6b | ||
|
|
5e74e356e0 | ||
|
|
cba624f480 | ||
|
|
ba964ff478 | ||
|
|
45338c9d70 | ||
|
|
6b6dc2c4f3 | ||
|
|
8fd353a766 | ||
|
|
3cb60139d5 | ||
|
|
7d9bf776db | ||
|
|
0c814ec956 | ||
|
|
322e357398 | ||
|
|
cc4a94da40 | ||
|
|
36648f065b | ||
|
|
9893494cf1 | ||
|
|
906cda0385 | ||
|
|
92dd179525 | ||
|
|
fefb748864 | ||
|
|
0b644a1722 | ||
|
|
2a2c3f8174 | ||
|
|
88fc1b0b2a | ||
|
|
b45910a35f | ||
|
|
758f09a291 | ||
|
|
fce063642d | ||
|
|
c832350dd3 | ||
|
|
5169d4ac43 | ||
|
|
d80ead4d94 | ||
|
|
62700162d5 | ||
|
|
a568131b7c | ||
|
|
27ca53068b | ||
|
|
1d946a9616 | ||
|
|
f31669cba6 | ||
|
|
005006adf7 | ||
|
|
2f097ace49 | ||
|
|
654839b247 | ||
|
|
8ed67847dd | ||
|
|
47974dddab | ||
|
|
76cc10c6af | ||
|
|
ae08a568bd | ||
|
|
2bb3a71460 | ||
|
|
ce03b13718 | ||
|
|
0ad79157d3 | ||
|
|
bdae07e585 | ||
|
|
7fa6c2d84c | ||
|
|
59bd1b8c78 | ||
|
|
18eb79054f | ||
|
|
b5047a7a7a | ||
|
|
45b83d0fe4 | ||
|
|
a804120fa2 | ||
|
|
ebbf42cb76 | ||
|
|
1e7e78a841 | ||
|
|
350e79895d | ||
|
|
2bee689198 | ||
|
|
de333c2d34 | ||
|
|
0c7ecbe039 | ||
|
|
7a1ae5d5da | ||
|
|
2bbec5b9e5 | ||
|
|
b4d67859da | ||
|
|
15c229fa62 | ||
|
|
3278726a25 | ||
|
|
5df1ab6993 | ||
|
|
870bacdd8c | ||
|
|
bbe336d902 | ||
|
|
1bd50cee45 | ||
|
|
b639bc36eb | ||
|
|
89711af0e6 | ||
|
|
3ec3f11d5b | ||
|
|
5fcc26a866 | ||
|
|
8484936e3d | ||
|
|
1b275cf592 | ||
|
|
4c232284f8 | ||
|
|
326ccea1b4 | ||
|
|
8410179885 | ||
|
|
4777eb2dd6 | ||
|
|
867b03ebfc | ||
|
|
a6063874c7 | ||
|
|
037fd0caa0 | ||
|
|
ea897b154b | ||
|
|
54c72239d4 | ||
|
|
1bad9a14db | ||
|
|
50ebc7d6df | ||
|
|
ff38cf52b4 | ||
|
|
2fdab71d14 | ||
|
|
b51148f520 | ||
|
|
87db9dbb12 | ||
|
|
fb979ea514 | ||
|
|
8d18f05085 | ||
|
|
dfb790e713 | ||
|
|
62d55e2b92 | ||
|
|
f4a50cd854 | ||
|
|
47a2c40ffd | ||
|
|
fddb82e680 | ||
|
|
2d80a795a7 | ||
|
|
ffdae4a332 | ||
|
|
3ca9aad9be | ||
|
|
085674dfa7 | ||
|
|
9540a1b98b | ||
|
|
b9d48fab05 | ||
|
|
6552767823 | ||
|
|
f42f5903d3 | ||
|
|
0c94b67ad9 | ||
|
|
3b665f6d8c | ||
|
|
420e8a84e5 | ||
|
|
05b14ada18 | ||
|
|
aed9d86ada | ||
|
|
6933fb79ec | ||
|
|
4be36d61c7 | ||
|
|
d4cd6e0936 | ||
|
|
c0b00999c5 | ||
|
|
1accdf5826 | ||
|
|
1ae7ab3036 | ||
|
|
36878e6f7f | ||
|
|
09b735e82a | ||
|
|
24f829033f | ||
|
|
a72ff640cc | ||
|
|
68a791a439 | ||
|
|
c0ca9672a1 | ||
|
|
6ee49ce9ac | ||
|
|
c83d715234 | ||
|
|
4bcaf14bad | ||
|
|
e91d783e22 | ||
|
|
4bb3d33f89 | ||
|
|
12291ad4d5 | ||
|
|
829d3e65e7 | ||
|
|
3be9aa5c14 | ||
|
|
6c9a3820e8 | ||
|
|
d172a1c9b8 | ||
|
|
59c98d1289 | ||
|
|
b7235f5644 | ||
|
|
9dc4829f1f | ||
|
|
843134c82a | ||
|
|
7e87214b6d | ||
|
|
192a9ca53f | ||
|
|
1df821aa0c | ||
|
|
3e3644f4a4 | ||
|
|
b1f62d599b | ||
|
|
4e0ee8e899 | ||
|
|
7804d4eba6 | ||
|
|
1fa3a91979 | ||
|
|
29d5ba98ff | ||
|
|
c25c16adf3 | ||
|
|
de9bf1902a | ||
|
|
f2ec4530ed | ||
|
|
7bbcab7679 | ||
|
|
f646022926 | ||
|
|
f6037dc18b | ||
|
|
29acc7f0ee | ||
|
|
5ef5adf9be | ||
|
|
9647e7614b | ||
|
|
fb7c0c9ca2 | ||
|
|
0002a39fc4 | ||
|
|
e67ea56651 | ||
|
|
4cd96980ba | ||
|
|
d648855533 | ||
|
|
9bed373ec1 | ||
|
|
c355194aba | ||
|
|
15fa1a5e52 | ||
|
|
ecf11ae2d2 | ||
|
|
fb2a9e7a9f | ||
|
|
106e059d1a | ||
|
|
f6ddcf4f99 | ||
|
|
a4659ed1a2 | ||
|
|
c90559981e | ||
|
|
b299507df9 | ||
|
|
cf92d9d939 | ||
|
|
6b61b86585 | ||
|
|
ae896ff42c | ||
|
|
709d114986 | ||
|
|
cc86430ea4 | ||
|
|
295aaf1a01 | ||
|
|
548335b658 | ||
|
|
df35a26f9e | ||
|
|
c277cf1121 | ||
|
|
297bdd95dd | ||
|
|
e13ca6281d | ||
|
|
235362d5b0 | ||
|
|
fc5fb3832d | ||
|
|
bd9a5f75b0 | ||
|
|
99de0d4722 | ||
|
|
726d563e1a | ||
|
|
7ce1ea8fa4 | ||
|
|
2e5be29a43 | ||
|
|
ac77acd888 | ||
|
|
f4b17254ec | ||
|
|
28a857249a | ||
|
|
22f7b1a343 | ||
|
|
ec948b5496 | ||
|
|
1b2e980fb6 | ||
|
|
8a60e23214 | ||
|
|
ff55a0fade | ||
|
|
c1447b4376 | ||
|
|
734825ba9b | ||
|
|
0da6ef99af | ||
|
|
832fda813b | ||
|
|
615ee91511 | ||
|
|
e7350899bf | ||
|
|
ad0522335e | ||
|
|
38da0d9f3a | ||
|
|
020b413e20 | ||
|
|
1750f04d0b | ||
|
|
14d53fa48d | ||
|
|
aa4bc81da4 | ||
|
|
fe4e0ce75c | ||
|
|
052c6e153d | ||
|
|
60479b44b1 | ||
|
|
8e0d9148b1 | ||
|
|
fc471a17a0 | ||
|
|
b6a2dbc23a | ||
|
|
b854f40cc7 | ||
|
|
a2cd9aebe8 | ||
|
|
2e8d257140 | ||
|
|
379fed15b3 | ||
|
|
afa3290f11 | ||
|
|
5593ebe574 | ||
|
|
b18999900d | ||
|
|
03ccdf2d43 | ||
|
|
40dc2b5d2f | ||
|
|
50960ed899 | ||
|
|
ede1de664a | ||
|
|
6884a89fe5 | ||
|
|
676410a108 | ||
|
|
b37e12b7a7 | ||
|
|
8a0f5ff120 | ||
|
|
c5fecda212 | ||
|
|
e5d98d022e | ||
|
|
799184fd21 | ||
|
|
756cb28daa | ||
|
|
5f6eb6a26d | ||
|
|
4efbec5741 | ||
|
|
86dbc0d86e | ||
|
|
c32d011851 | ||
|
|
311ca35afc | ||
|
|
1a3fae6501 | ||
|
|
3432530de7 | ||
|
|
af85493863 | ||
|
|
ae85f0d612 | ||
|
|
16c4a78e29 | ||
|
|
8fb87aa64d | ||
|
|
c5d675b6d2 | ||
|
|
f4d06b2d7b | ||
|
|
bb610a7cd8 | ||
|
|
bb864e72b3 | ||
|
|
c6c4797d87 | ||
|
|
25fed42395 | ||
|
|
66c4676493 | ||
|
|
f8747b5d5d | ||
|
|
0eb58d4b8a | ||
|
|
7b30ea343f | ||
|
|
7db8484a7c | ||
|
|
78b058009b | ||
|
|
05abad3905 | ||
|
|
ad178b1806 | ||
|
|
075c20ae41 | ||
|
|
56420bd4d0 | ||
|
|
a18fc729c0 | ||
|
|
099941a54e | ||
|
|
3867d51d92 | ||
|
|
f1be18b7e3 | ||
|
|
ef46380f9e | ||
|
|
d711273cf0 | ||
|
|
5e7cf2d679 | ||
|
|
11319ba21f | ||
|
|
9c51523352 | ||
|
|
b4a706820e | ||
|
|
376a3223dc | ||
|
|
a85ec69549 | ||
|
|
ebf4d6d3eb | ||
|
|
2ccd8265b9 | ||
|
|
fb1671d062 | ||
|
|
95e37df2ec | ||
|
|
cfde72f1ab | ||
|
|
efc778acbc | ||
|
|
37384ebadd | ||
|
|
9a7aee7e97 | ||
|
|
1a56534d28 | ||
|
|
79e7e5f087 | ||
|
|
aea03cf975 | ||
|
|
8ef6f944dc | ||
|
|
e18d243961 | ||
|
|
765b633cea | ||
|
|
9c402a3878 | ||
|
|
86426b087a | ||
|
|
c5aaa2f3e2 | ||
|
|
cc59bb6094 | ||
|
|
5662da1210 | ||
|
|
863725d3ba | ||
|
|
b1acfbdd4b | ||
|
|
001f9c3f30 | ||
|
|
8d4df4b66e | ||
|
|
604447508a | ||
|
|
6f30949620 | ||
|
|
10e8e82fe4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@
|
||||
node_modules/
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
!seller/yarn.lock
|
||||
|
||||
682
LICENSE
682
LICENSE
@@ -1,661 +1,21 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2026 Beijing Hongye Huicheng Technology Co., Ltd. (北京宏业汇成科技有限公司)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
201
MIGRATION-VUE3.md
Normal file
201
MIGRATION-VUE3.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Lilishop UI — Vue 3 迁移与上线前检查清单
|
||||
|
||||
四个子项目(buyer / seller / manager / im)已完成 **Vue 3 + Element Plus** 迁移,并完成依赖小版本对齐。本文档供上线前统一回归使用。
|
||||
|
||||
## 技术栈(四端一致)
|
||||
|
||||
| 类别 | 包 | 版本(package.json) |
|
||||
|------|-----|---------------------|
|
||||
| 框架 | vue | ^3.5.35 |
|
||||
| 编译 | @vue/compiler-sfc | ^3.5.35 |
|
||||
| 路由 | vue-router | ^4.6.4 |
|
||||
| 状态 | pinia | ^4.0.3(四端已全部从 vuex 迁移完成) |
|
||||
| 组件写法 | `<script setup>` | 四端 597 个组件已全部完成 Options API → 组合式 API 迁移;各端 eslint 已启用 `vue/component-api-style: ['warn', ['script-setup']]` 防止回退 |
|
||||
| UI | element-plus | ^2.14.1 |
|
||||
| 图标 | @element-plus/icons-vue | ^2.3.2 |
|
||||
| 请求 | axios | ^1.7.9 |
|
||||
| 兼容 | core-js | ^3.49.0 |
|
||||
| 构建 | @vue/cli-service | ^5.0.9 |
|
||||
|
||||
> 使用 `^` 范围,实际安装版本以各目录 `yarn.lock` 为准。
|
||||
> 要求 **Node >= 16**,包管理统一 **yarn**。
|
||||
|
||||
## 本地启动
|
||||
|
||||
```bash
|
||||
# 买家端
|
||||
cd buyer && yarn install && yarn dev # 端口 10000
|
||||
|
||||
# 商家端
|
||||
cd seller && yarn install && yarn dev # 端口 10002
|
||||
|
||||
# 管理端
|
||||
cd manager && yarn install && yarn dev # 端口 10003
|
||||
|
||||
# IM 客服
|
||||
cd im && yarn install && yarn dev # 端口 8000
|
||||
```
|
||||
|
||||
生产构建(上线前必跑):
|
||||
|
||||
```bash
|
||||
cd buyer && yarn build
|
||||
cd seller && yarn build
|
||||
cd manager && yarn build
|
||||
cd im && yarn build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一、通用检查(四端都要过)
|
||||
|
||||
### 1. 认证与请求(axios 1.x 重点)
|
||||
|
||||
各端请求封装位置:
|
||||
|
||||
| 端 | 文件 |
|
||||
|----|------|
|
||||
| buyer | `buyer/src/plugins/request.js` |
|
||||
| seller | `seller/src/libs/axios.js` |
|
||||
| manager | `manager/src/libs/axios.js` |
|
||||
| im | `im/src/utils/request.js` |
|
||||
|
||||
- [ ] **登录**:账号密码登录成功,token 写入正常
|
||||
- [ ] **登出**:清除 token,跳转登录页
|
||||
- [ ] **401**:token 过期后跳转登录 / 刷新 token(buyer/manager/seller 有刷新逻辑)
|
||||
- [ ] **403**:无权限时提示文案正确、不白屏
|
||||
- [ ] **400 / 5xx**:接口报错能弹出 `Message` / `ElMessage` / `Notification`
|
||||
- [ ] **网络超时**:断网或超时后有错误提示,页面不卡死
|
||||
- [ ] **GET 带参**:列表筛选、分页参数序列化正常
|
||||
- [ ] **POST JSON**:`Content-Type: application/json` 请求体正确
|
||||
- [ ] **POST 表单**:`application/x-www-form-urlencoded` 提交正常(im 等)
|
||||
|
||||
### 2. Element Plus UI(2.6 → 2.14 重点)
|
||||
|
||||
- [ ] **表格** `el-table`:列展示、排序、空数据、加载态
|
||||
- [ ] **分页** `el-pagination`:翻页、每页条数切换
|
||||
- [ ] **表单** `el-form`:必填校验、错误提示位置
|
||||
- [ ] **弹窗** `el-dialog` / `ElMessageBox`:打开、确认、取消、关闭
|
||||
- [ ] **下拉** `el-select` / `el-dropdown`:选项、点击外部关闭
|
||||
- [ ] **消息** `ElMessage` / `$Message` 兼容层:success / error / warning
|
||||
- [ ] **加载** `ElLoading` / `v-loading`:请求期间遮罩正常消失
|
||||
- [ ] **日期** `el-date-picker`:选择、清空、范围选择
|
||||
- [ ] **上传** `el-upload`:选文件、进度、成功 / 失败回调
|
||||
|
||||
### 3. 路由与页面
|
||||
|
||||
- [ ] 浏览器 **刷新** 当前路由不 404(history 模式 + 服务端配置)
|
||||
- [ ] **动态路由** 加载正常(seller / manager 菜单权限路由)
|
||||
- [ ] 浏览器 **前进 / 后退** 正常
|
||||
- [ ] 各端 **404 页** 可回到首页
|
||||
|
||||
### 4. 构建与部署
|
||||
|
||||
- [ ] 四端 `yarn build` 无 error(warning 可记录,不阻断)
|
||||
- [ ] 生产环境 API 地址配置正确(`config` / 环境变量)
|
||||
- [ ] 静态资源 `publicPath` 与 Nginx / CDN 路径一致
|
||||
- [ ] gzip 压缩包(如有)可正常访问
|
||||
|
||||
---
|
||||
|
||||
## 二、分端专项检查
|
||||
|
||||
### Buyer 买家端(10000)
|
||||
|
||||
详见 [buyer/MIGRATION-VUE3.md](./buyer/MIGRATION-VUE3.md)
|
||||
|
||||
- [ ] 首页装修模块渲染(轮播、商品列表、秒杀区等)
|
||||
- [ ] 商品详情:规格选择、加购、收藏、`el-image` 预览
|
||||
- [ ] 购物车 → 结算 → 支付流程
|
||||
- [ ] 用户中心:订单列表、地址、优惠券、售后
|
||||
- [ ] 登录 / 注册 / 忘记密码
|
||||
- [ ] 店铺入驻、商户页
|
||||
- [ ] 地图(高德 `@amap/amap-jsapi-loader`)定位与选点
|
||||
- [ ] 侧边栏 `el-menu`、头部 `el-dropdown`、动态图标 `iconMap.js`
|
||||
|
||||
### Seller 商家端(10002)
|
||||
|
||||
详见 [seller/MIGRATION-VUE3.md](./seller/MIGRATION-VUE3.md)
|
||||
|
||||
- [ ] 商家登录、店铺信息
|
||||
- [ ] 商品发布三步流程(`goodsOperation` 步骤条)
|
||||
- [ ] 订单管理:发货、退款、详情
|
||||
- [ ] 促销 / 分销 / 统计图表(`@antv/g2`)
|
||||
- [ ] 页面装修(wap / modelList,从 manager 同步部分)
|
||||
- [ ] 图片懒加载 `vue-lazyload`
|
||||
- [ ] Excel 导出 `vue-json-excel`
|
||||
|
||||
### Manager 管理端(10003)
|
||||
|
||||
详见 [manager/MIGRATION-VUE3.md](./manager/MIGRATION-VUE3.md)
|
||||
|
||||
- [ ] 管理员登录、权限菜单、动态路由
|
||||
- [ ] 系统设置(`sys/setting-manage` 各 Tab)
|
||||
- [ ] 菜单 / 部门管理
|
||||
- [ ] 页面装修(PC + H5)
|
||||
- [ ] 文章管理
|
||||
- [ ] 商品 / 订单 / 会员 / 促销等核心列表 CRUD
|
||||
- [ ] 富文本 TinyMCE 编辑、图片上传
|
||||
- [ ] 多语言 `vue-i18n` 切换(如有启用)
|
||||
|
||||
### IM 客服端(8000)
|
||||
|
||||
详见 [im/MIGRATION-VUE3.md](./im/MIGRATION-VUE3.md)
|
||||
|
||||
- [ ] 带 token 参数进入会话页
|
||||
- [ ] 对话列表加载、切换会话、未读数
|
||||
- [ ] 发送文字、表情、图片消息
|
||||
- [ ] 商品 / 订单卡片链接跳转(`linkToGoods` / `linkToOrders`)
|
||||
- [ ] 右键菜单(复制、撤回、删除等)
|
||||
- [ ] WebSocket 连接 / 断线重连 / 新消息通知
|
||||
- [ ] 最近浏览、订单列表侧栏(商家场景)
|
||||
- [ ] 用户名片弹层 `$user()`
|
||||
|
||||
---
|
||||
|
||||
## 三、已知可忽略项
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| `vue-awesome-swiper` peer warning | 历史依赖,Vue 3 下仍有 peer 告警,功能正常可暂忽略 |
|
||||
| im 内 `<i class="el-icon-*">` | Element Plus 无字体图标,部分旧图标可能不显示,不影响核心功能 |
|
||||
| `vue-prism-editor@2.0.0-alpha.2` | im 代码块编辑器刻意锁定 alpha 版 |
|
||||
| 打包体积 warning | chunk-vendors 偏大,属 Element Plus 全量引入,可后续做按需优化 |
|
||||
|
||||
---
|
||||
|
||||
## 四、问题记录模板
|
||||
|
||||
上线测试时建议按端记录:
|
||||
|
||||
```markdown
|
||||
### [端名] 问题标题
|
||||
- 页面/路由:
|
||||
- 操作步骤:
|
||||
- 预期:
|
||||
- 实际:
|
||||
- 控制台报错:
|
||||
- 优先级:P0 / P1 / P2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、子项目文档索引
|
||||
|
||||
| 端 | 迁移说明 |
|
||||
|----|----------|
|
||||
| buyer | [buyer/MIGRATION-VUE3.md](./buyer/MIGRATION-VUE3.md) |
|
||||
| seller | [seller/MIGRATION-VUE3.md](./seller/MIGRATION-VUE3.md) |
|
||||
| manager | [manager/MIGRATION-VUE3.md](./manager/MIGRATION-VUE3.md) |
|
||||
| im | [im/MIGRATION-VUE3.md](./im/MIGRATION-VUE3.md) |
|
||||
|
||||
---
|
||||
|
||||
## 六、建议上线顺序
|
||||
|
||||
1. **manager**(后台配置源头)
|
||||
2. **seller**(商家经营)
|
||||
3. **buyer**(C 端流量最大)
|
||||
4. **im**(客服,依赖前后端 WebSocket)
|
||||
|
||||
每上一端,至少完成本文 **第一节通用检查** + 对应 **第二节分端检查** 后再切下一端。
|
||||
144
README.en.md
Normal file
144
README.en.md
Normal file
@@ -0,0 +1,144 @@
|
||||
<div align="center">
|
||||
<img src="./manager/src/assets/logo.png" width="180" alt="Lilishop Logo" />
|
||||
<h1>Lilishop(PickMall) - Web Frontend (Vue 3)</h1>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
|
||||
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
|
||||
<a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue-3.5-brightgreen.svg" alt="Vue 3" /></a>
|
||||
<a href="https://element-plus.org/"><img src="https://img.shields.io/badge/Element%20Plus-2.x-409EFF.svg" alt="Element Plus" /></a>
|
||||
<a href="https://pinia.vuejs.org/"><img src="https://img.shields.io/badge/Pinia-4.x-yellow.svg" alt="Pinia" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://pickmall.cn">🏠 Official Website</a> ·
|
||||
<a href="https://bdwx2tfwwt.feishu.cn/wiki/space/7675179368632617952?ccm_open_type=lark_wiki_spaceLink&open_tab_from=wiki_home">📖 Product Documentation</a> ·
|
||||
<a href="#2-online-demo">💻 Online Demo</a> ·
|
||||
<a href="https://github.com/lilishop/lilishop">💻 GitHub</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="./README.md">简体中文</a> | <b>English</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
> ⭐ **If you like this project, please click `Star` in the top-right corner to support us! Your Star is our greatest motivation for continuous open-source iteration!**
|
||||
|
||||
**LILISHOP** is an open-source e-commerce system built with Spring Boot, Vue 3, and uni-app. It supports B2B2C multi-vendor marketplaces, WeChat Mini Programs, H5, Android/iOS Apps, live-streaming e-commerce, affiliate distribution, and flash sales.
|
||||
|
||||
### ✨ Why Choose Lilishop
|
||||
- 🟢 **True MIT License**: Free for commercial use and private secondary development without licensing concerns.
|
||||
- 🚀 **Modern Vue 3 Stack**: Built on `Vue 3.5 + Pinia 4 + Vue Router 4 + Element Plus`, leaving legacy Vue 2 templates behind.
|
||||
- 🧩 **4-in-1 Production Suite**: Covers **PC Buyer Mall (`buyer`)**, **Seller Portal (`seller`)**, **Platform Admin Panel (`manager`)**, and **IM Customer Service Web (`im`)**, complete with drag-and-drop visual storefront decoration and rich analytics.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This repository (`lilishop-ui`) is the **Web Frontend Suite** of Lilishop, containing four standalone **Vue 3** web applications: **PC Buyer Mall, Seller Panel, Platform Admin Panel, and IM Customer Service Web**.
|
||||
|
||||
- **Official Website**: <https://pickmall.cn>
|
||||
- **Documentation**: [Product Documentation](https://bdwx2tfwwt.feishu.cn/wiki/space/7675179368632617952?ccm_open_type=lark_wiki_spaceLink&open_tab_from=wiki_home)
|
||||
- **Gitee Repository**: <https://gitee.com/beijing_hongye_huicheng>
|
||||
- **GitHub Main Repository**: <https://github.com/lilishop/lilishop>
|
||||
|
||||
---
|
||||
|
||||
## 2. Online Demo
|
||||
|
||||
> **Note**: The verification code for the demo environment is `111111`.
|
||||
|
||||
| Portal | URL | Demo Credentials |
|
||||
| :--- | :--- | :--- |
|
||||
| **Platform Admin Panel (`manager`)** | <https://admin-b2b2c.pickmall.cn> | `admin` / `123456` |
|
||||
| **Seller / Store Panel (`seller`)** | <https://store-b2b2c.pickmall.cn> | `13011111111` / `111111` |
|
||||
| **PC Buyer Mall (`buyer`)** | <https://pc-b2b2c.pickmall.cn> | SMS code: `111111` |
|
||||
| **Mobile H5 (`uni-app`)** | <https://m-b2b2c.pickmall.cn> | SMS code: `111111` |
|
||||
|
||||
| WeChat Mini Program | Mobile H5 ([Visit Directly](https://m-b2b2c.pickmall.cn)) |
|
||||
| :---: | :---: |
|
||||
| <img src="https://static.pickmall.cn/images/h5-qrcode.png" width="180" alt="WeChat Mini Program QR Code" /> | <img src="https://lilishop-wechat.oss-cn-beijing.aliyuncs.com/h5-qrcode.png" width="180" alt="Mobile H5 QR Code" /> |
|
||||
| Scan with WeChat | Scan or visit <https://m-b2b2c.pickmall.cn> |
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Features & Tech Stack
|
||||
|
||||
### Core Features
|
||||
- **Four-Portal Synergy**: Includes Platform Admin, Seller Panel, PC Buyer Mall, and IM Web Workbench with clear role boundaries.
|
||||
- **Drag-and-Drop Visual Decoration**: Built-in visual floor editor (`vuedraggable`) for customizing both PC and mobile storefronts.
|
||||
- **Multi-Vendor B2B2C Operations**: Merchant onboarding, product auditing, split-order fulfillment, promotions, and financial settlement ledgers.
|
||||
- **Rich Business Analytics**: Powered by `@antv/g2` for sales overview, YoY/MoM product reports, member analytics, and distribution metrics.
|
||||
- **IM Customer Service**: Integrated with Tencent Cloud IM / WebSocket customer service for real-time communication.
|
||||
|
||||
### Web Tech Stack
|
||||
| Category | Technology | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| Core Framework | `Vue 3.5` + `Vue Router 4` | Modern reactive frontend framework |
|
||||
| State Management | `Pinia 4` | Official Vue 3 state management library |
|
||||
| UI Library | `Element Plus 2.x` | Enterprise desktop UI component library |
|
||||
| HTTP Client | `Axios 1.x` | Unified request/response interceptors & auth handling |
|
||||
| Data Visualization | `@antv/g2` | Interactive charts and business analytics |
|
||||
| Visual Decoration | `vuedraggable 4` | Drag-and-drop page floor builder |
|
||||
| Messaging & IM | `@tencentcloud/chat` / `sockjs-client` | Real-time customer service messaging |
|
||||
| Video Player | `dplayer` + `hls.js` | Product video and live streaming playback |
|
||||
| Build & Styling | `Vue CLI 5` + `Webpack 5` + `Sass / Less` | Modular build pipeline and theme customization |
|
||||
|
||||
---
|
||||
|
||||
## 4. Directory Structure
|
||||
|
||||
```text
|
||||
lilishop-ui
|
||||
├── buyer/ # [PC Buyer Mall]
|
||||
│ ├── public/ # Static assets & global config (config.js)
|
||||
│ └── src/
|
||||
│ ├── api/ # Buyer API modules (goods, cart, order, member, promotion)
|
||||
│ ├── components/ # Shared components (floor renderer, navbar, goods card)
|
||||
│ ├── pages/ # PC Mall pages (Home, Product Detail, Checkout, User Center)
|
||||
│ ├── router/ # Route definitions & guards
|
||||
│ ├── stores/ # Pinia stores
|
||||
│ └── utils/ # Request interceptors & helpers
|
||||
├── seller/ # [Seller / Store Management Portal]
|
||||
│ ├── public/ # Static assets & global config
|
||||
│ └── src/
|
||||
│ ├── api/ # Seller API modules
|
||||
│ ├── components/ # Seller shared & decoration components
|
||||
│ ├── views/ # Seller views (goods, orders, after-sales, promotions, store settings)
|
||||
│ ├── router/ # Dynamic permission routes
|
||||
│ └── stores/ # Pinia stores
|
||||
├── manager/ # [Platform Admin Portal]
|
||||
│ ├── public/ # Static assets & global config
|
||||
│ └── src/
|
||||
│ ├── api/ # Admin API modules
|
||||
│ ├── components/ # Admin components, rich-text editor, visual floor builder
|
||||
│ ├── views/ # Admin views (member, goods, order, seller, marketing, finance, stats, system)
|
||||
│ ├── router/ # Menu & permission routes
|
||||
│ └── stores/ # Pinia stores
|
||||
├── im/ # [IM Customer Service Web Client]
|
||||
│ └── src/
|
||||
│ ├── api/ # Chat session & message APIs
|
||||
│ ├── components/ # Chat window, message list, product/order cards
|
||||
│ ├── im-server/ # WebSocket / IM connection manager
|
||||
│ └── views/ # Customer service workbench
|
||||
├── build.sh # Unified build script
|
||||
└── docker-build.sh # Docker image build script
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Related Repositories
|
||||
|
||||
| Repository | Description | Links |
|
||||
| :--- | :--- | :--- |
|
||||
| **`lilishop`** | Backend Services (Spring Boot 4 / Java 21) | [Gitee](https://gitee.com/beijing_hongye_huicheng/lilishop) · [GitHub](https://github.com/lilishop/lilishop) |
|
||||
| **`lilishop-ui`** | PC Mall / Seller / Admin / IM Web Frontend | [Gitee](https://gitee.com/beijing_hongye_huicheng/lilishop-ui) |
|
||||
| **`lilishop-uniapp`** | Mobile Buyer Client (H5 / Mini Program / App) | [Gitee](https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp) |
|
||||
| **`docker`** | Docker Compose, Images & SQL Init Scripts | [Gitee](https://gitee.com/beijing_hongye_huicheng/docker) · [MySQL Scripts](https://gitee.com/beijing_hongye_huicheng/docker/tree/master/init/mysql) |
|
||||
|
||||
---
|
||||
|
||||
## 6. License
|
||||
|
||||
Lilishop is licensed under the [MIT](./LICENSE) License. Copyright © 2020-2026 **Beijing Hongye Huicheng Technology Co., Ltd.**
|
||||
354
README.md
354
README.md
@@ -1,241 +1,181 @@
|
||||
## Lilishop B2B2C商城系统
|
||||
<div align="center">
|
||||
<img src="./manager/src/assets/logo.png" width="180" alt="Lilishop Logo" />
|
||||
<h1>Lilishop(PickMall) 开源商城系统 - Web 前端 (Vue 3)</h1>
|
||||
|
||||
#### 欢迎交流需求,交流业务,交流技术(基础问题自行解决,其他问题先看文档后提问)
|
||||
<p>
|
||||
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
|
||||
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
|
||||
<a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue-3.5-brightgreen.svg" alt="Vue 3" /></a>
|
||||
<a href="https://element-plus.org/"><img src="https://img.shields.io/badge/Element%20Plus-2.x-409EFF.svg" alt="Element Plus" /></a>
|
||||
<a href="https://pinia.vuejs.org/"><img src="https://img.shields.io/badge/Pinia-4.x-yellow.svg" alt="Pinia" /></a>
|
||||
</p>
|
||||
|
||||
#### 不用削尖脑袋往老群里加,老群活跃度较低,很多潜水党,新群相对而言活跃一些 :tw-1f606: :tw-1f606: :tw-1f606: :tw-1f606: :tw-1f606: :tw-1f606:
|
||||
<p>
|
||||
<a href="https://pickmall.cn">🏠 官方网站</a> ·
|
||||
<a href="https://bdwx2tfwwt.feishu.cn/wiki/space/7675179368632617952?ccm_open_type=lark_wiki_spaceLink&open_tab_from=wiki_home">📖 产品文档</a> ·
|
||||
<a href="#2-在线演示">💻 在线演示</a> ·
|
||||
<a href="#7-社区与支持">💬 微信交流群</a>
|
||||
</p>
|
||||
|
||||
#### PS: **演示站点所有环境均部署master分支。如果有演示站点问题,可以反馈,如果演示站点没问题本地运行有问题,需自行处理**
|
||||
<p>
|
||||
<b>简体中文</b> | <a href="./README.en.md">English</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
##### 交流 qq 1群 961316482(已满)
|
||||
##### 交流 qq 2群 875294241(已满)
|
||||
##### 交流 qq 3群 263785057(已满)
|
||||
##### 交流 qq 4群 674617534 (已满)
|
||||
##### 交流 qq 5群 594675235
|
||||
> ⭐ **如果觉得项目不错,请点击右上角 `Star` 收藏支持!您的 Star 是我们持续开源迭代的最大动力!**
|
||||
|
||||
##### 体验 公众号/小程序/APP 体验,扫描二维码
|
||||
**LILISHOP** 是基于 Spring Boot / Vue 3 / uni-app 开发的开源商城系统,支持 B2B2C 多商户商城、微信小程序商城、H5 商城、APP 商城、直播电商、分销返佣、秒杀活动。
|
||||
|
||||

|
||||
### ✨ 为什么选择 Lilishop
|
||||
- 🟢 **真 MIT 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
|
||||
- 🚀 **Vue 3 全新栈**:全面基于 `Vue 3.5 + Pinia 4 + Vue Router 4 + Element Plus` 构建,告别老旧 Vue 2 架构。
|
||||
- 🧩 **四端合一生产级闭环**:完整覆盖 **PC 买家端 (`buyer`)**、**商家端 (`seller`)**、**平台运营管理端 (`manager`)** 与 **在线客服端 (`im`)**,支持拖拽式可视化楼层装修与多维数据统计报表。
|
||||
|
||||
[](https://gitee.com/beijing_hongye_huicheng/lilishop/stargazers)
|
||||

|
||||
---
|
||||
|
||||
## 如何在本地环境运行lilishop-ui部署视频
|
||||
https://www.bilibili.com/video/BV1B28EeJEnP/
|
||||
## 1. 项目简介
|
||||
|
||||
## 如何在服务器上部署lilishop-ui
|
||||
https://www.bilibili.com/video/BV1WD87eoE9F/
|
||||
**Lilishop** 是一款功能完善的 B2B2C 多商户商城系统,采用前后端分离架构,全端代码开源。
|
||||
后端基于 **Spring Boot 4**(Java 21)构建,具备高内聚、低耦合的特性,支持分布式部署;前端覆盖 PC、管理后台、H5、小程序和 APP,基于 **Vue 3** 和 **uni-app** 开发。
|
||||
|
||||
本仓库(`lilishop-ui`)为 **Web 前端源码仓库**,包含 **PC 商城端、商家后台、平台管理后台、IM 在线客服** 四个独立子工程。
|
||||
|
||||
## 开发项目
|
||||
- **官方网站**: <https://pickmall.cn>
|
||||
- **官方文档**: [产品文档](https://bdwx2tfwwt.feishu.cn/wiki/space/7675179368632617952?ccm_open_type=lark_wiki_spaceLink&open_tab_from=wiki_home)
|
||||
- **Gitee 仓库**: <https://gitee.com/beijing_hongye_huicheng>
|
||||
- **GitHub 主仓库**: <https://github.com/lilishop/lilishop>
|
||||
|
||||
#### 安装Node.js
|
||||
保证`node`版本`14`,推荐 14.17.0
|
||||
---
|
||||
|
||||
可以使用 `yarn` 或者 `npm` 进行安装
|
||||
## 2. 在线演示
|
||||
|
||||
#### yarn 安装/启动
|
||||
```
|
||||
// 如果没有 yarn 安装yarn
|
||||
npm install yarn -g
|
||||
> **注意**: 演示站手机验证码统一为 `111111`。演示环境部署于 `master` 分支。
|
||||
|
||||
// 切换源
|
||||
yarn config set registry https://registry.npmmirror.com
|
||||
| 演示站点 | 访问地址 | 演示账号 |
|
||||
| :--- | :--- | :--- |
|
||||
| **平台运营管理端 (`manager`)** | <https://admin-b2b2c.pickmall.cn> | 账号:`admin` / 密码:`123456` |
|
||||
| **商家店铺管理端 (`seller`)** | <https://store-b2b2c.pickmall.cn> | 账号:`13011111111` / 密码:`111111` |
|
||||
| **商城 PC 买家端 (`buyer`)** | <https://pc-b2b2c.pickmall.cn> | 可使用手机号验证码 `111111` 登录 |
|
||||
| **移动端 H5 (`uni-app`)** | <https://m-b2b2c.pickmall.cn> | 可使用手机号验证码 `111111` 登录 |
|
||||
|
||||
// 以buyer项目为例
|
||||
cd buyer
|
||||
| 微信小程序 | 移动端 H5([点击直接访问](https://m-b2b2c.pickmall.cn)) |
|
||||
| :---: | :---: |
|
||||
| <img src="https://static.pickmall.cn/images/h5-qrcode.png" width="180" alt="微信小程序体验码" /> | <img src="https://lilishop-wechat.oss-cn-beijing.aliyuncs.com/h5-qrcode.png" width="180" alt="移动端 H5 体验二维码" /> |
|
||||
| 微信扫码体验小程序 | 手机扫码或访问 <https://m-b2b2c.pickmall.cn> |
|
||||
|
||||
yarn install
|
||||
---
|
||||
|
||||
yarn dev
|
||||
## 3. 核心特性与技术栈
|
||||
|
||||
### 核心特性
|
||||
- **四端协同**: 涵盖平台管理端、商家管理端、PC 买家商城端、IM 客服端,角色边界清晰,开箱即用。
|
||||
- **可视化拖拽装修**: 管理端与商家端内置可视化楼层编辑器(基于 `vuedraggable`),可同步配置 PC 端与移动端首页/专题页。
|
||||
- **多商户入驻与结算**: 完整的商家入驻审核、商品审核、拆单发货、促销报名、财务对账与结算台账体系。
|
||||
- **多维经营分析**: 集成 `@antv/g2` 图表引擎,提供营业概况、商品同比环比、会员分析、营销与分销数据统计。
|
||||
- **即时通讯客服**: 集成腾讯云 IM / WebSocket 在线客服系统,支持买家、商家与平台实时沟通。
|
||||
|
||||
### Web 前端技术栈
|
||||
| 类别 | 技术选型 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| 核心框架 | `Vue 3.5` + `Vue Router 4` | 基于 Composition API 的现代响应式前端框架 |
|
||||
| 状态管理 | `Pinia 4` | 轻量、类型友好的 Vue 3 官方状态管理库 |
|
||||
| UI 组件库 | `Element Plus 2.x` | 企业级中后台与桌面端 UI 组件库 |
|
||||
| 网络请求 | `Axios 1.x` | 统一请求/响应拦截、Token 刷新与异常提示 |
|
||||
| 数据可视化 | `@antv/g2` | 运营端与商家端数据统计图表渲染 |
|
||||
| 可视化拖拽 | `vuedraggable 4` | 商城首页及专题楼层可视化拖拽装修 |
|
||||
| 即时通讯 | `@tencentcloud/chat` / `sockjs-client` | 在线客服消息收发与实时通知 |
|
||||
| 音视频播放 | `dplayer` + `hls.js` | 商品视频与直播流播放支持 |
|
||||
| 构建与样式 | `Vue CLI 5` + `Webpack 5` + `Sass / Less` | 模块化工程构建与全局主题样式定制 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 目录结构
|
||||
|
||||
```text
|
||||
lilishop-ui
|
||||
├── buyer/ # 【PC 买家商城端】源码工程
|
||||
│ ├── public/ # 静态资源与全局配置文件 (config.js)
|
||||
│ └── src/
|
||||
│ ├── api/ # 买家端接口定义(商品、购物车、订单、会员、促销等)
|
||||
│ ├── components/ # 公共业务组件(楼层渲染、导航栏、商品卡片等)
|
||||
│ ├── pages/ # PC 商城各业务页面(首页、商品详情、结算、个人中心等)
|
||||
│ ├── router/ # 路由配置与守卫
|
||||
│ ├── stores/ # Pinia 状态管理
|
||||
│ └── utils/ # 请求封装与通用工具函数
|
||||
├── seller/ # 【商家店铺管理端】源码工程
|
||||
│ ├── public/ # 静态资源与全局配置
|
||||
│ └── src/
|
||||
│ ├── api/ # 商家端接口定义
|
||||
│ ├── components/ # 商家端公共组件与装修组件
|
||||
│ ├── views/ # 商家端业务视图(商品管理、订单发货、售后、促销、店铺设置、统计等)
|
||||
│ ├── router/ # 动态权限路由
|
||||
│ └── stores/ # Pinia 状态管理
|
||||
├── manager/ # 【平台运营管理端】源码工程
|
||||
│ ├── public/ # 静态资源与全局配置
|
||||
│ └── src/
|
||||
│ ├── api/ # 运营端接口定义
|
||||
│ ├── components/ # 平台公共组件、富文本、可视化楼层装修编辑器
|
||||
│ ├── views/ # 运营端业务视图(会员、商品、订单、商家、营销、分销、财务、统计、系统设置)
|
||||
│ ├── router/ # 菜单与权限路由
|
||||
│ └── stores/ # Pinia 状态管理
|
||||
├── im/ # 【IM 在线客服 Web 端】源码工程
|
||||
│ └── src/
|
||||
│ ├── api/ # 客服会话与消息接口
|
||||
│ ├── components/ # 聊天窗口、消息列表、商品/订单卡片组件
|
||||
│ ├── im-server/ # WebSocket / IM 连接服务管理
|
||||
│ └── views/ # 客服工作台页面
|
||||
├── build.sh # 多端统一构建脚本
|
||||
└── docker-build.sh # Docker 镜像构建脚本
|
||||
```
|
||||
|
||||
#### npm 安装/启动
|
||||
```
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
---
|
||||
|
||||
// 以buyer项目为例
|
||||
cd buyer
|
||||
## 5. 配套源码仓库
|
||||
|
||||
npm run install
|
||||
本仓库专注于 **PC / 商家端 / 运营端 / IM Web**(`lilishop-ui`)。完整系统由以下四个核心仓库组成:
|
||||
|
||||
npm run dev
|
||||
```
|
||||
| 仓库名称 | 职责说明 | 仓库地址 |
|
||||
| :--- | :--- | :--- |
|
||||
| **`lilishop`** | 后端服务源码(Spring Boot 4 / Java 21) | [Gitee](https://gitee.com/beijing_hongye_huicheng/lilishop) · [GitHub](https://github.com/lilishop/lilishop) |
|
||||
| **`lilishop-ui`** | PC 商城 / 商家端 / 运营管理端 / IM Web 前端 | [Gitee](https://gitee.com/beijing_hongye_huicheng/lilishop-ui) |
|
||||
| **`lilishop-uniapp`** | 买家端 H5 / 微信小程序 / Android & iOS APP | [Gitee](https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp) |
|
||||
| **`docker`** | 容器化部署编排、镜像与数据库脚本 | [Gitee](https://gitee.com/beijing_hongye_huicheng/docker) · [MySQL 脚本](https://gitee.com/beijing_hongye_huicheng/docker/tree/master/init/mysql) |
|
||||
|
||||
#### FAQ
|
||||
---
|
||||
|
||||
##### npmmirror镜像源报错 451 Unavailable For Legal Reasons
|
||||
```
|
||||
//切换其他的镜像源
|
||||
## 6. 开源与授权
|
||||
|
||||
// npm
|
||||
npm config set registry http://mirrors.cloud.tencent.com/npm/
|
||||
本项目遵循 [MIT](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
|
||||
|
||||
// yarn
|
||||
yarn config set registry http://mirrors.cloud.tencent.com/npm/
|
||||
```
|
||||
---
|
||||
|
||||
##### 安装不上去
|
||||
## 7. 社区与支持
|
||||
|
||||
可以按照这个思路排查一下
|
||||
我们欢迎任何形式的交流与贡献。在提问前,请先查阅 [产品文档](https://bdwx2tfwwt.feishu.cn/wiki/space/7675179368632617952?ccm_open_type=lark_wiki_spaceLink&open_tab_from=wiki_home),并参考 [《提问的智慧》](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/master/README-zh_CN.md) 以便高效沟通。
|
||||
|
||||
```
|
||||
1. 看下Node.js 是否是14.17.0
|
||||
2. 项目目录下是否有 package-lock.json 或者 .npmrc 或者 .yarnrc 设置了镜像源, 如果有可以删除重新install
|
||||
3. 切换当前 npm 或者 yarn 镜像源重试
|
||||
```
|
||||
- **[在线客服](https://work.weixin.qq.com/kfid/kfc4d8dc24a73c15f44)**
|
||||
- **微信交流群**:
|
||||
<img src="https://lilishop-wechat.oss-cn-beijing.aliyuncs.com/wechat.jpg" width="200" alt="微信群" />
|
||||
|
||||
---
|
||||
|
||||
### 商城介绍
|
||||
**官网**:https://pickmall.cn
|
||||
## 8. 升级计划
|
||||
|
||||
Lilishop商城系统支持商家入驻,后端基于SpringBoot 研发,前端使用 Vue、uniapp开发, **系统全端全部代码开源**
|
||||
### ✅ 已完成特性
|
||||
| 功能模块 | 功能模块 |
|
||||
| :--- | :--- |
|
||||
| ✅ 商品定时上下架 | ✅ 商品分组 / 会员分组 |
|
||||
| ✅ 商品虚拟销量 | ✅ 专用发票(发票上传) |
|
||||
| ✅ 置顶评论 | ✅ 客户等级体系 |
|
||||
| ✅ 购物卡 | ✅ 腾讯云-云直播 |
|
||||
| ✅ 卡密商品 | ✅ 二级分销 |
|
||||
| ✅ 定时任务管理 | ✅ 预约服务商品 |
|
||||
| ✅ 主题色管理 | ✅ 商家发货改造(整单/拆单/无需配送) |
|
||||
| ✅ 多维统计(营业/商品/会员/积分/储值/营销/分销) | ✅ 经营报表(店铺业绩/商品同比环比/订单明细/销售汇总) |
|
||||
|
||||
前后端分离,支持分布式部署,支持Docker,各个API独立,并且有独立的消费者。
|
||||
|
||||
### 商城 API/消费者 聚合版
|
||||
api不需要单独部署,只需启动一个jar包就可以正常运转 如有需要,可以点击跳转
|
||||
https://gitee.com/beijing_hongye_huicheng/lilishop-simplify
|
||||
|
||||
### 开发/使用/常见问题 帮助文档
|
||||
|
||||
https://docs.pickmall.cn
|
||||
|
||||
### 项目地址
|
||||
|
||||
gitee : https://gitee.com/beijing_hongye_huicheng
|
||||
|
||||
github 镜像: https://github.com/lilishop?tab=repositories
|
||||
|
||||
商城UI 项目下3个文件夹
|
||||
buyer:买家PC端,seller:商家端,manager:后台管理端
|
||||
|
||||
|
||||
### 演示地址
|
||||
PS:手机验证码为 ‘111111’
|
||||
|
||||
**平台管理端**:https://admin-b2b2c.pickmall.cn 账号:admin/123456
|
||||
|
||||
**店铺管理端**:https://store-b2b2c.pickmall.cn 账号:13011111111/111111
|
||||
|
||||
**商城PC页面**:https://pc-b2b2c.pickmall.cn
|
||||
|
||||
**商城 小程序/公众号/APP**:扫描二维码
|
||||
|
||||

|
||||
|
||||
### 快速本地部署
|
||||
|
||||
[点击跳转](https://docs.pickmall.cn/deploy/%E8%BF%90%E8%A1%8C%E7%8E%AF%E5%A2%83%E5%87%86%E5%A4%87.html)
|
||||
|
||||
**商城数据库**
|
||||
使用docker-compose部署数据库,自动初始化数据库,不需要手动下载等操作
|
||||
|
||||
如果手动部署,才需要获取sql [点击跳转](https://gitee.com/beijing_hongye_huicheng/docker/tree/master/init/mysql) (PS:这里有与tag版本一致的sql,如果是历史版本,则docker项目也切换至历史版本获取sql即可,历史版本升级则根据java相聚的根目录DB目录下的升级sql,按需执行)。
|
||||
|
||||
|
||||
|
||||
### 功能列表
|
||||
|
||||
|
||||
|
||||
#### 平台管理端功能
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
#### 卖家功能
|
||||
|
||||

|
||||
|
||||
|
||||
### 商城前端功能展示
|
||||
|
||||
#### 商城移动端
|
||||
|
||||
<img src="https://static.pickmall.cn/images/other/app.gif" alt="移动端功能展示" style="zoom:50%;" />
|
||||
|
||||
#### 平台管理端
|
||||
|
||||

|
||||
|
||||
|
||||
### 技术选型
|
||||
|
||||
#### 架构图
|
||||
|
||||

|
||||
|
||||
##### 后台技术选型
|
||||
|
||||
| 说明 | 框架 | 说明 | |
|
||||
| -------------- | --------------- | -------------- | ------------- |
|
||||
| 基础框架 | Spring Boot | MVC框架 | Spring MVC |
|
||||
| 持久框架 | Mybatis-Plus | 程序构建 | Maven |
|
||||
| 关系型数据库 | MySQL | 消息中间件AMQP | RocketMQ |
|
||||
| 缓存 | Redis +MongoDB | 搜索引擎 | Elasticsearch |
|
||||
| 安全框架 | Spring Security | 数据库连接池 | Druid |
|
||||
| 数据库分库分表 | sharding | 定时任务 | xxl-job |
|
||||
| 负载均衡 | Nginx | 静态资源 | 阿里云OSS |
|
||||
| 短信 | 阿里云短信 | 认证 | JWT |
|
||||
| 日志处理 | Log4j | 接口规范 | RESTful |
|
||||
|
||||
##### 前端-运营后台、店铺后台
|
||||
|
||||
| 说明 | 框架 | 说明 | 框架 |
|
||||
| ---------- | ---------- | ---------- | ------- |
|
||||
| 构建工具 | webpack | JS版本 | ES6 |
|
||||
| 基础JS框架 | Vue.js | 视频播放器 | Dplayer |
|
||||
| 路由管理 | Vue Router | 状态管理 | Vuex |
|
||||
| 基础UI库 | iView | UI界面基于 | iView |
|
||||
| 网络请求 | axios | | |
|
||||
|
||||
##### 前端-移动端
|
||||
|
||||
| 说明 | 架构 | 说明 | 架构 |
|
||||
| --------- | ------- | -------- | ------- |
|
||||
| 基础UI库 | uViewui | 基础框架 | uni-app |
|
||||
| CSS预处理 | scss | 地图引擎 | amap |
|
||||
|
||||
### 版本升级
|
||||
|
||||
```
|
||||
系统后续会提供多场景解决方案。
|
||||
更多架构:微服务、Saas、中台等,都会支持。 支持差价升级商业授权
|
||||
```
|
||||
|
||||
### 商业授权
|
||||
商业版本与开源版本代码一致,没有区分
|
||||
|
||||
商业使用需要授权,授权方式可选择联系官网客服,或者qq群联系群主。
|
||||
|
||||
商业授权模式为永久授权,支持永久升级。
|
||||
|
||||
商业案例由于涉及部分多层二开关系,如需了解可以咨询销售。
|
||||
|
||||
|
||||
### 开源须知
|
||||
1.仅允许用于个人学习研究使用.
|
||||
|
||||
2.禁止将本开源的代码和资源进行任何形式任何名义的出售.
|
||||
|
||||
3.软件受国家计算机软件著作权保护(登记号:2021SR0805085)。
|
||||
|
||||
4.限制商用,如果需要商业使用请联系我们。QQ3409056806.或者加入qq群联系群主。
|
||||
|
||||
|
||||
|
||||
### 交流群
|
||||
|
||||
##### 交流 qq 1群 961316482(已满)
|
||||
##### 交流 qq 2群 875294241(已满)
|
||||
##### 交流 qq 3群 263785057(已满)
|
||||
##### 交流 qq 4群 674617534(已满)
|
||||
##### 交流 qq 5群 594675235
|
||||
|
||||
### 附录
|
||||
有人有自己的学习视频、学习记录文档、希望宣传关联开源项目等均可以私聊仓库所有者。
|
||||
|
||||
类似:
|
||||
|
||||
清晨敲代码同学的分析: https://blog.csdn.net/vaevaevae233/category_12103567.html
|
||||
### 🔜 近期规划中
|
||||
- 🔜 店铺结算汇总 & 结算台账
|
||||
- 🔜 支付方式汇总 & 钱包流水 & 财务汇总
|
||||
- 🔜 微信小店对接
|
||||
- 🔜 抖店对接
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
["env", {
|
||||
"modules": false,
|
||||
"targets": {
|
||||
"browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
|
||||
}
|
||||
}],
|
||||
"stage-2"
|
||||
],
|
||||
"plugins": ["transform-vue-jsx", "transform-runtime"]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/build/
|
||||
/config/
|
||||
/dist/
|
||||
/*.js
|
||||
@@ -1,33 +0,0 @@
|
||||
// https://eslint.org/docs/user-guide/configuring
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint'
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
extends: [
|
||||
// https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
|
||||
// consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
|
||||
'plugin:vue/essential',
|
||||
// https://github.com/standard/standard/blob/master/docs/RULES-en.md
|
||||
'standard'
|
||||
],
|
||||
// required to lint *.vue files
|
||||
plugins: [
|
||||
'vue'
|
||||
],
|
||||
// add your custom rules here
|
||||
rules: {
|
||||
"semi": [0, "never"],
|
||||
// allow async-await
|
||||
'generator-star-spacing': 'off',
|
||||
// allow debugger during development
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
"vue/no-parsing-error": [2, {
|
||||
"x-invalid-end-tag": false
|
||||
}]
|
||||
}
|
||||
}
|
||||
1
buyer/.npmrc
Normal file
1
buyer/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=false
|
||||
1
buyer/.yarnrc
Normal file
1
buyer/.yarnrc
Normal file
@@ -0,0 +1 @@
|
||||
--ignore-engines true
|
||||
55
buyer/MIGRATION-VUE3.md
Normal file
55
buyer/MIGRATION-VUE3.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Buyer 买家端 Vue 3 迁移说明
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3 + Vue Router 4 + Pinia(vuex 已移除,见下文"组合式 API 迁移")
|
||||
- Element Plus(替代 view-design / iView)
|
||||
- 保留 `$Message` / `$Modal` / `$Notice` 兼容层(`src/utils/message.js`)
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
cd buyer
|
||||
yarn install
|
||||
yarn dev # 默认端口 10000
|
||||
yarn build
|
||||
```
|
||||
|
||||
## 迁移要点
|
||||
|
||||
1. **入口** `src/main.js` 使用 `createApp`,全局过滤器改为 `$filters`
|
||||
2. **路由** `src/router/index.js` 使用 `createRouter` + 动态 `import()`
|
||||
3. **请求** `src/plugins/request.js` 使用 Element Plus `ElLoading`
|
||||
4. **iView 组件已全部替换**:轮播 `el-carousel`、下拉 `el-dropdown`、侧栏 `el-menu`、头像 `el-avatar` 等
|
||||
5. **图标映射** `src/utils/iconMap.js` — 动态图标(如侧边栏菜单)通过 `resolveIcon()` 使用
|
||||
6. **自定义字体图标** `icomoon` 保留为 `<i class="icomoon icon-*">`(购物车步骤条、钱包等)
|
||||
7. **首页装修** `indexDecorate/modelList` 保留买家端展示逻辑(非 manager 编辑版)
|
||||
8. **特殊替换**
|
||||
- `vue-piczoom` → `el-image` 预览
|
||||
- `mv-count-down` → 内置倒计时
|
||||
- 秒杀轮播 → 横向滚动列表
|
||||
|
||||
## 组合式 API 迁移(2026-10 完成)
|
||||
|
||||
全部 95 个 `.vue` 组件已从 Options API 转换为 `<script setup>`,同时完成 vuex → pinia:
|
||||
|
||||
- **状态管理**:`src/stores/app.js`(`useAppStore`:navList / cartNum / unreadMsg / logoImg / siteName / hotWordsList / category + 对应 setXxx actions)。原 `src/vuex/` 已删除,package.json 不再依赖 vuex。
|
||||
- **路由守卫**:`beforeRouteEnter` 无组合式等价物,5 个页面(`GoodsList` / `GoodsDetail` / `PointGoodsDetail` / `Cart` / `ShoppingCart`)保留普通 `<script>` 块存放 `export default { name, beforeRouteEnter }`——这是官方支持的合并写法,统计 Options API 残留时需排除。
|
||||
- **defineExpose**:以下组件的方法/状态被父组件通过模板 ref 调用,已显式暴露:`verify`(init / close)、`multiple-map`(open)、`addressManage`(show / hide)、`invoiceModal`(invoiceAvailable)。新增此类父子调用时必须加 `defineExpose`。
|
||||
- **模板 ref 与响应式变量同名会互相覆盖**:同名时模板 ref 统一改名 `xxxRef`(如 `formRef` / `verifyRef`)。
|
||||
- **mixin 已消除**:原 `components/mixes/talkIm.vue` 的 IM 客服逻辑已内联进 `Merchant.vue` 和 `goodsDetail/StoreSide.vue`,文件已删除。
|
||||
- **全局属性照旧**:模板中的 `$filters.*` / `linkTo` / `connectCs` / `$inputMaxLength` 是 `main.js` 注册的全局属性,`<script setup>` 下模板可直接使用;script 内使用 `$filters` 函数需从 `@/plugins/filters.js` 具名导入,`this.Cookies` 等同 `storage`(`@/plugins/storage`)。
|
||||
- **其余三端(seller / manager / im)仍为 Options API + vuex**,迁移时参照本端模式。
|
||||
|
||||
## 批量脚本
|
||||
|
||||
- `scripts/migrate-iview-to-element.js` — iView → Element 模板替换
|
||||
- `scripts/fix-filters.js` — Vue 2 过滤器语法修复
|
||||
- `scripts/replace-page.js` — `<Page>` → `el-pagination`
|
||||
- `scripts/replace-icon.js` — 静态 `<Icon type>` → `el-icon`
|
||||
- `scripts/fix-el-icon-click.js` — 修复 `@click` 绑定
|
||||
- `scripts/fix-broken-tags.js` — 闭合标签修复
|
||||
|
||||
## 上线前检查
|
||||
|
||||
四端统一清单(认证、axios、Element Plus、分端专项):[../MIGRATION-VUE3.md](../MIGRATION-VUE3.md)
|
||||
@@ -1,19 +0,0 @@
|
||||
# new
|
||||
|
||||
## Project setup
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
|
||||
36
buyer/eslint.config.js
Normal file
36
buyer/eslint.config.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const globals = require('globals')
|
||||
const pluginVue = require('eslint-plugin-vue')
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'build/**',
|
||||
'config/**',
|
||||
'src/scripts/**', // 一次性迁移脚本,不参与 lint
|
||||
],
|
||||
},
|
||||
...pluginVue.configs['flat/essential'],
|
||||
{
|
||||
files: ['src/**/*.{js,vue}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
'no-empty': ['error', { allowEmptyCatch: false }],
|
||||
'vue/no-parsing-error': ['error', { 'x-invalid-end-tag': false }],
|
||||
// 页面组件通过路由按文件路径引用,组件名不参与解析,存量单词组件名不再强制多词
|
||||
'vue/multi-word-component-names': 'off',
|
||||
// 存量代码大量使用与 HTML 保留名同名的组件(element-plus 图标 Search/View/Camera/Link 及全局注册的 empty/drawer 等),重命名成本高,暂不启用
|
||||
'vue/no-reserved-component-names': 'off',
|
||||
// 全端已完成 Options API → <script setup> 迁移,防止新代码回退(路由守卫的普通 script 块等有意保留的除外,故用 warn)
|
||||
'vue/component-api-style': ['warn', ['script-setup']],
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -3,42 +3,54 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vue-cli-service serve",
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build"
|
||||
"build": "vue-cli-service build",
|
||||
"dev": "vue-cli-service serve",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "0.0.7",
|
||||
"axios": "^0.19.2",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.7.9",
|
||||
"core-js": "^3.49.0",
|
||||
"dplayer": "^1.27.1",
|
||||
"element-plus": "^2.14.1",
|
||||
"js-cookie": "^2.2.1",
|
||||
"less": "^2.7.0",
|
||||
"less-loader": "^5.0.0",
|
||||
"mv-count-down": "^0.1.15",
|
||||
"node-sass": "^4.14.1",
|
||||
"postcss-loader": "^7.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^4.0.3",
|
||||
"psl": "^1.8.0",
|
||||
"qs": "^6.9.4",
|
||||
"sass-loader": "^7.3.1",
|
||||
"sass": "^1.63.6",
|
||||
"sass-loader": "^10.4.1",
|
||||
"uuid": "^8.3.2",
|
||||
"view-design": "^4.3.2",
|
||||
"vue": "^2.6.11",
|
||||
"vue-awesome-swiper": "^3.1.3",
|
||||
"vue-piczoom": "^1.0.6",
|
||||
"vue-qr": "^2.3.0",
|
||||
"vue-router": "^3.0.1",
|
||||
"vuex": "^3.0.1"
|
||||
"vue": "^3.5.35",
|
||||
"vue-qr": "^5.0.3",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"compression-webpack-plugin": "^5.0.0",
|
||||
"sass-loader": "^7.3.1",
|
||||
"uglifyjs-webpack-plugin": "^2.2.0",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
"@vue/cli-plugin-babel": "^5.0.9",
|
||||
"@vue/cli-service": "^5.0.9",
|
||||
"@vue/compiler-sfc": "^3.5.35",
|
||||
"compression-webpack-plugin": "^10.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-vue": "^10.11.1",
|
||||
"globals": "^17.13.0",
|
||||
"less": "^4.2.0",
|
||||
"less-loader": "^11.1.4",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"webpack": "^5.95.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
]
|
||||
],
|
||||
"resolutions": {
|
||||
"minimatch": "^3.1.2",
|
||||
"@achrinza/node-ipc": "9.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
8672
buyer/pnpm-lock.yaml
generated
Normal file
8672
buyer/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,9 @@ var BASE = {
|
||||
* @description api请求基础路径
|
||||
*/
|
||||
API_DEV: {
|
||||
common: "https://common-api.pickmall.cn",
|
||||
buyer: "https://buyer-api.pickmall.cn",
|
||||
seller: "https://store-api.pickmall.cn",
|
||||
manager: "https://admin-api.pickmall.cn"
|
||||
},
|
||||
API_PROD: {
|
||||
common: "https://common-api.pickmall.cn",
|
||||
buyer: "https://buyer-api.pickmall.cn",
|
||||
seller: "https://store-api.pickmall.cn",
|
||||
manager: "https://admin-api.pickmall.cn"
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,20 +7,12 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>logo.ico">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
<% for(var css of htmlWebpackPlugin.options.cdn.css) { %>
|
||||
<link rel="stylesheet" href="<%=css%>" />
|
||||
<% } %>
|
||||
</head>
|
||||
<body>
|
||||
<% for(var js of htmlWebpackPlugin.options.cdn.js) { %>
|
||||
<script src="<%=js%>"></script>
|
||||
<% } %>
|
||||
<script src="/config.js"></script>
|
||||
<script src="<%= BASE_URL %>config.js"></script>
|
||||
<noscript>
|
||||
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
54
buyer/scripts/fix-broken-tags.js
Normal file
54
buyer/scripts/fix-broken-tags.js
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
|
||||
function walkDir(dir, files = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkDir(full, files);
|
||||
else if (entry.name.endsWith(".vue")) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function fix(content) {
|
||||
let c = content;
|
||||
c = c.replace(/<\/Button>/g, "</el-button>");
|
||||
c = c.replace(/<\/Button\b/g, "</el-button");
|
||||
c = c.replace(/<\/Input>/g, "</el-input>");
|
||||
c = c.replace(/<\/Input\b/g, "</el-input");
|
||||
c = c.replace(/<\/Option>/g, "</el-option>");
|
||||
c = c.replace(/<\/Option\b/g, "</el-option");
|
||||
c = c.replace(/<\/Checkbox>/g, "</el-checkbox>");
|
||||
c = c.replace(/<\/Checkbox\b/g, "</el-checkbox");
|
||||
c = c.replace(/<\/Select>/g, "</el-select>");
|
||||
c = c.replace(/<\/Select\b/g, "</el-select");
|
||||
c = c.replace(/<\/Form>/g, "</el-form>");
|
||||
c = c.replace(/<\/Modal>/g, "</el-dialog>");
|
||||
c = c.replace(/@click\.native/g, "@click");
|
||||
c = c.replace(/@on-search/g, "@keyup.enter");
|
||||
c = c.replace(/\bsearch\b(?=\s+enter-button)/g, "");
|
||||
c = c.replace(/\benter-button\b/g, "");
|
||||
c = c.replace(/<BreadcrumbItem/g, "<el-breadcrumb-item");
|
||||
c = c.replace(/<\/BreadcrumbItem>/g, "</el-breadcrumb-item>");
|
||||
c = c.replace(/<Breadcrumb/g, "<el-breadcrumb");
|
||||
c = c.replace(/<\/Breadcrumb>/g, "</el-breadcrumb>");
|
||||
c = c.replace(/type="error"/g, 'type="danger"');
|
||||
c = c.replace(/<i-col/g, "<div");
|
||||
c = c.replace(/<\/i-col>/g, "</div>");
|
||||
c = c.replace(/\{\{([^}|]+)\s*\|\s*secrecyMobile\s*\}\}/g, "{{ $filters.secrecyMobile($1) }}");
|
||||
return c;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const file of walkDir(path.join(ROOT, "src"))) {
|
||||
const original = fs.readFileSync(file, "utf8");
|
||||
const fixed = fix(original);
|
||||
if (fixed !== original) {
|
||||
fs.writeFileSync(file, fixed);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log(`Fixed tags in ${count} files`);
|
||||
52
buyer/scripts/fix-dialog-slots.js
Normal file
52
buyer/scripts/fix-dialog-slots.js
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
|
||||
function walkDir(dir, files = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkDir(full, files);
|
||||
else if (entry.name.endsWith(".vue")) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function fix(content) {
|
||||
let c = content;
|
||||
|
||||
c = c.replace(/<p #header>/g, "<template #header><p>");
|
||||
c = c.replace(
|
||||
/(<template #header><p>[\s\S]*?<\/p>)(\s*<div)/g,
|
||||
"$1</template>$2"
|
||||
);
|
||||
|
||||
c = c.replace(
|
||||
/<template #footer><div([^>]*)>([\s\S]*?)<\/div><\/template><\/el-dialog>/g,
|
||||
"<template #footer><div$1>$2</div></template>\n </el-dialog>"
|
||||
);
|
||||
|
||||
c = c.replace(
|
||||
/<template #default\s*>([\s\S]*?)<\/template\s*>/g,
|
||||
(match, inner) => {
|
||||
if (match.includes("el-alert")) {
|
||||
return `<template #title>${inner}</template>`;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
);
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const file of walkDir(path.join(ROOT, "src"))) {
|
||||
const original = fs.readFileSync(file, "utf8");
|
||||
const fixed = fix(original);
|
||||
if (fixed !== original) {
|
||||
fs.writeFileSync(file, fixed);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log(`Fixed dialog slots in ${count} files`);
|
||||
26
buyer/scripts/fix-duplicate-size.js
Normal file
26
buyer/scripts/fix-duplicate-size.js
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "src");
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const f = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(f, files);
|
||||
else if (e.name.endsWith(".vue")) files.push(f);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
let n = 0;
|
||||
for (const file of walk(ROOT)) {
|
||||
let c = fs.readFileSync(file, "utf8");
|
||||
const next = c.replace(/:size="(\d+)"\s+:size="\1"/g, ':size="$1"');
|
||||
if (next !== c) {
|
||||
fs.writeFileSync(file, next);
|
||||
n++;
|
||||
console.log(path.relative(ROOT, file));
|
||||
}
|
||||
}
|
||||
console.log(`Fixed duplicate :size in ${n} files`);
|
||||
28
buyer/scripts/fix-el-icon-click.js
Normal file
28
buyer/scripts/fix-el-icon-click.js
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "src");
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const f = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(f, files);
|
||||
else if (e.name.endsWith(".vue")) files.push(f);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
let n = 0;
|
||||
for (const file of walk(ROOT)) {
|
||||
let c = fs.readFileSync(file, "utf8");
|
||||
const next = c
|
||||
.replace(/<el-icon([^>]*?):click=/g, "<el-icon$1@click=")
|
||||
.replace(/:class="refresh"/g, 'class="refresh"')
|
||||
.replace(/:size="20" @click="show = false" :size="20"/g, ':size="20" @click="show = false"');
|
||||
if (next !== c) {
|
||||
fs.writeFileSync(file, next);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
console.log(`Fixed el-icon click in ${n} files`);
|
||||
77
buyer/scripts/fix-filters.js
Normal file
77
buyer/scripts/fix-filters.js
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
|
||||
function walkDir(dir, files = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkDir(full, files);
|
||||
else if (entry.name.endsWith(".vue")) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function fixFilters(content) {
|
||||
let c = content;
|
||||
|
||||
// Fix broken: {{ expr {{ $filters.unitPrice(args) }}
|
||||
c = c.replace(
|
||||
/\{\{\s*([^}{]+?)\s*\{\{\s*\$filters\.unitPrice\(([^)]*)\)\s*\}\}/g,
|
||||
"{{ $filters.unitPrice($1, $2) }}"
|
||||
);
|
||||
|
||||
// Fix: {{ expr | unitPrice }} or {{ expr | unitPrice('¥') }}
|
||||
c = c.replace(
|
||||
/\{\{\s*([^}|]+?)\s*\|\s*unitPrice(?:\(([^)]*)\))?\s*\}\}/g,
|
||||
(_, expr, args) => {
|
||||
const e = expr.trim();
|
||||
return args !== undefined
|
||||
? `{{ $filters.unitPrice(${e}, ${args}) }}`
|
||||
: `{{ $filters.unitPrice(${e}) }}`;
|
||||
}
|
||||
);
|
||||
|
||||
// Fix inline without braces: expr | unitPrice("¥")
|
||||
c = c.replace(
|
||||
/(?<!\{\{)\s*([a-zA-Z0-9_.?()[\]'"\s]+?)\s*\|\s*unitPrice\(([^)]*)\)/g,
|
||||
(match, expr, args) => {
|
||||
if (match.includes("{{")) return match;
|
||||
return `{{ $filters.unitPrice(${expr.trim()}, ${args}) }}`;
|
||||
}
|
||||
);
|
||||
|
||||
// Fix inline without args: expr | unitPrice
|
||||
c = c.replace(
|
||||
/(?<!\{\{)\s*([a-zA-Z0-9_.?()[\]'"\s]+?)\s*\|\s*unitPrice(?!\()/g,
|
||||
(match, expr) => {
|
||||
if (match.includes("{{") || match.includes("$filters")) return match;
|
||||
return `{{ $filters.unitPrice(${expr.trim()}) }}`;
|
||||
}
|
||||
);
|
||||
|
||||
// Fix unixToDate filter
|
||||
c = c.replace(
|
||||
/\{\{\s*([^}|]+?)\s*\|\s*unixToDate(?:\(([^)]*)\))?\s*\}\}/g,
|
||||
(_, expr, args) => {
|
||||
const e = expr.trim();
|
||||
return args !== undefined
|
||||
? `{{ $filters.unixToDate(${e}, ${args}) }}`
|
||||
: `{{ $filters.unixToDate(${e}) }}`;
|
||||
}
|
||||
);
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const file of walkDir(path.join(ROOT, "src"))) {
|
||||
const original = fs.readFileSync(file, "utf8");
|
||||
const fixed = fixFilters(original);
|
||||
if (fixed !== original) {
|
||||
fs.writeFileSync(file, fixed);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log(`Fixed filters in ${count} files`);
|
||||
55
buyer/scripts/fix-runtime-compat.js
Normal file
55
buyer/scripts/fix-runtime-compat.js
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
|
||||
function walkDir(dir, files = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkDir(full, files);
|
||||
else if (entry.name.endsWith(".vue")) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function fix(content) {
|
||||
let c = content;
|
||||
|
||||
c = c.replace(/<el-pagination\b/g, "<Page");
|
||||
c = c.replace(/<\/el-pagination>/g, "</Page>");
|
||||
|
||||
c = c.replace(
|
||||
/<Icon([^>]*)\s+#prepend\s*>\s*<\/Icon>/g,
|
||||
"<template #prepend><Icon$1></Icon></template>"
|
||||
);
|
||||
c = c.replace(
|
||||
/<Icon([^>]*)\s+#prepend\s*>\s*<\/Icon>/g,
|
||||
"<template #prepend><Icon$1></Icon></template>"
|
||||
);
|
||||
c = c.replace(
|
||||
/<template #prepend><Icon([^>]*)\s*\/>/g,
|
||||
"<template #prepend><Icon$1></Icon></template>"
|
||||
);
|
||||
|
||||
c = c.replace(/@keyup\.enter\.native/g, "@keyup.enter");
|
||||
c = c.replace(/slot="append"/g, "#append");
|
||||
c = c.replace(/(<div[^>]*)\s+#append/g, "<template #append>$1");
|
||||
|
||||
c = c.replace(/(<el-button[^>]*)\slong/g, '$1 style="width:100%"');
|
||||
c = c.replace(/@on-select/g, "@select");
|
||||
c = c.replace(/@on-click/g, "@click");
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const file of walkDir(path.join(ROOT, "src"))) {
|
||||
const original = fs.readFileSync(file, "utf8");
|
||||
const fixed = fix(original);
|
||||
if (fixed !== original) {
|
||||
fs.writeFileSync(file, fixed);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log(`Runtime compat fixes in ${count} files`);
|
||||
54
buyer/scripts/fix-slots.js
Normal file
54
buyer/scripts/fix-slots.js
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
|
||||
function walkDir(dir, files = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkDir(full, files);
|
||||
else if (entry.name.endsWith(".vue")) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function fix(content) {
|
||||
let c = content;
|
||||
|
||||
c = c.replace(
|
||||
/<template #default="(\{[^"]+\})" #([a-zA-Z0-9_-]+)>/g,
|
||||
'<template #$2="$1">'
|
||||
);
|
||||
|
||||
c = c.replace(
|
||||
/<div([^>]*) #footer([^>]*)>/g,
|
||||
'<template #footer><div$1$2>'
|
||||
);
|
||||
c = c.replace(/<\/div>\s*(?=<\/el-dialog>)/g, "</div></template>");
|
||||
|
||||
c = c.replace(/<span #append>/g, "<template #append><span>");
|
||||
c = c.replace(/<\/span><\/el-input/g, "</span></template></el-input");
|
||||
c = c.replace(/><span #append>/g, "><template #append><span>");
|
||||
|
||||
c = c.replace(/<el-button #append/g, "<template #append><el-button");
|
||||
c = c.replace(
|
||||
/<\/el-button>(\s*<\/el-input>)/g,
|
||||
"</el-button></template>$1"
|
||||
);
|
||||
|
||||
c = c.replace(/<template #desc\b/g, "<template #default");
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const file of walkDir(path.join(ROOT, "src"))) {
|
||||
const original = fs.readFileSync(file, "utf8");
|
||||
const fixed = fix(original);
|
||||
if (fixed !== original) {
|
||||
fs.writeFileSync(file, fixed);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log(`Fixed slots in ${count} files`);
|
||||
46
buyer/scripts/fix-span-tags.js
Normal file
46
buyer/scripts/fix-span-tags.js
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
|
||||
function walkDir(dir, files = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkDir(full, files);
|
||||
else if (entry.name.endsWith(".vue")) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function fix(content) {
|
||||
let c = content;
|
||||
c = c.replace(/<\/span\s*\n\s*>/g, "</span>");
|
||||
c = c.replace(/<\/span\s+>/g, "</span>");
|
||||
c = c.replace(/<i-input/g, "<el-input");
|
||||
c = c.replace(/<\/i-input>/g, "</el-input>");
|
||||
c = c.replace(
|
||||
/<Icon([^>]*)\s+#prepend\s*\/>/g,
|
||||
"<template #prepend><Icon$1 /></template>"
|
||||
);
|
||||
c = c.replace(
|
||||
/<template #append><el-button([^>]*)>([\s\S]*?)<\/el-button>\s*<\/i-input>/g,
|
||||
"<template #append><el-button$1>$2</el-button></template></el-input>"
|
||||
);
|
||||
c = c.replace(
|
||||
/<template #append><el-button([^>]*)>([\s\S]*?)<\/el-button>\s*<\/el-input>/g,
|
||||
"<template #append><el-button$1>$2</el-button></template></el-input>"
|
||||
);
|
||||
return c;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const file of walkDir(path.join(ROOT, "src"))) {
|
||||
const original = fs.readFileSync(file, "utf8");
|
||||
const fixed = fix(original);
|
||||
if (fixed !== original) {
|
||||
fs.writeFileSync(file, fixed);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log(`Fixed span/input tags in ${count} files`);
|
||||
55
buyer/scripts/fix-stray-templates.js
Normal file
55
buyer/scripts/fix-stray-templates.js
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
|
||||
function walkDir(dir, files = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkDir(full, files);
|
||||
else if (entry.name.endsWith(".vue")) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function fix(content) {
|
||||
let c = content;
|
||||
|
||||
c = c.replace(
|
||||
/(<div[^>]*)\s+#content>/g,
|
||||
"<template #content><div$1>"
|
||||
);
|
||||
c = c.replace(
|
||||
/(<\/el-tooltip>)/g,
|
||||
(match, _m, offset, str) => {
|
||||
return match;
|
||||
}
|
||||
);
|
||||
|
||||
c = c.replace(
|
||||
/(<div style="white-space:[^"]*"[^>]*>[\s\S]*?<\/div>)(\s*<\/el-tooltip>)/g,
|
||||
"$1</template>$2"
|
||||
);
|
||||
|
||||
c = c.replace(/<\/div><\/template><\/el-dialog>/g, (m, offset, str) => {
|
||||
const before = str.slice(Math.max(0, offset - 200), offset);
|
||||
if (before.includes("#footer")) return m;
|
||||
return "</div></el-dialog>";
|
||||
});
|
||||
|
||||
c = c.replace(/<i(\s[^>]*)\/>/g, "<i$1></i>");
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const file of walkDir(path.join(ROOT, "src"))) {
|
||||
const original = fs.readFileSync(file, "utf8");
|
||||
const fixed = fix(original);
|
||||
if (fixed !== original) {
|
||||
fs.writeFileSync(file, fixed);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log(`Fixed stray templates in ${count} files`);
|
||||
227
buyer/scripts/migrate-iview-to-element.js
Normal file
227
buyer/scripts/migrate-iview-to-element.js
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Migrate buyer Vue files from iView to Element Plus.
|
||||
* Usage: node scripts/migrate-iview-to-element.js
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const BUYER_ROOT = path.join(__dirname, "..");
|
||||
const MANAGER_ROOT = path.join(__dirname, "../../manager");
|
||||
|
||||
const MODEL_LIST = [
|
||||
["Carousel.vue", "carousel.vue"],
|
||||
["Carousel1.vue", "carousel1.vue"],
|
||||
["Carousel2.vue", "carousel2.vue"],
|
||||
["FirstPageAdvert.vue", "firstPageAdvert.vue"],
|
||||
["NewGoodsSort.vue", "newGoodsSort.vue"],
|
||||
["NotEnough.vue", "notEnough.vue"],
|
||||
["Recommend.vue", "recommend.vue"],
|
||||
["Seckill.vue", "seckill.vue"],
|
||||
["forYour.vue", "forYour.vue"],
|
||||
["goodsAndType.vue", "goodsAndType.vue"],
|
||||
["mixModel.vue", "mixModel.vue"],
|
||||
["oneRowThreeColumns.vue", "oneRowThreeColumns.vue"],
|
||||
["onlyGoodsModel.vue", "onlyGoodsModel.vue"],
|
||||
["mixs/mix-brand.vue", "mixs/mix-brand.vue"],
|
||||
["mixs/mix-goods.vue", "mixs/mix-goods.vue"],
|
||||
];
|
||||
|
||||
const DECORATE_COPY_MAP = [
|
||||
[
|
||||
"src/components/indexDecorate/ModelForm.vue",
|
||||
"src/views/page-decoration/modelForm.vue",
|
||||
],
|
||||
[
|
||||
"src/components/indexDecorate/ModelFormItem.vue",
|
||||
"src/views/page-decoration/modelFormItem.vue",
|
||||
],
|
||||
];
|
||||
|
||||
MODEL_LIST.forEach(([buyerName, managerName]) => {
|
||||
DECORATE_COPY_MAP.push([
|
||||
`src/components/indexDecorate/modelList/${buyerName}`,
|
||||
`src/views/page-decoration/modelList/${managerName}`,
|
||||
]);
|
||||
});
|
||||
|
||||
const TARGET_DIRS = ["src/pages", "src/components"];
|
||||
|
||||
function adaptBuyerContent(content) {
|
||||
return content
|
||||
.replace(/@\/views\/page-decoration\//g, "@/components/indexDecorate/")
|
||||
.replace(/admin-setting/g, "buyer-setting")
|
||||
.replace(/adminPCPageCache/g, "buyerPCPageCache")
|
||||
.replace(/getStore\('adminPCPageCache'\)/g, "getStore('buyerPCPageCache')")
|
||||
.replace(/setStore\('adminPCPageCache'/g, "setStore('buyerPCPageCache'");
|
||||
}
|
||||
|
||||
function applyScriptTransforms(content) {
|
||||
let c = content;
|
||||
c = c.replace(/\$options\.filters\./g, "$filters.");
|
||||
c = c.replace(/\|\s*unitPrice\(/g, "{{ $filters.unitPrice(");
|
||||
c = c.replace(/\|\s*unixToDate\(/g, "{{ $filters.unixToDate(");
|
||||
c = c.replace(/\bbeforeDestroy\b/g, "beforeUnmount");
|
||||
c = c.replace(/this\.\$set\(([^,]+),\s*([^,]+),\s*([^)]+)\)/g, "$1[$2] = $3");
|
||||
c = c.replace(/::v-deep/g, ":deep");
|
||||
c = c.replace(/\/deep\//g, ":deep");
|
||||
c = c.replace(/@on-change/g, "@change");
|
||||
c = c.replace(/@on-click/g, "@click");
|
||||
c = c.replace(/@on-ok/g, "@ok");
|
||||
c = c.replace(/@on-cancel/g, "@cancel");
|
||||
c = c.replace(/@on-clear/g, "@clear");
|
||||
c = c.replace(/@on-open-change/g, "@visible-change");
|
||||
c = c.replace(/@on-page-size-change/g, "@size-change");
|
||||
c = c.replace(/@on-select/g, "@select");
|
||||
c = c.replace(/@keydown\.enter\.native/g, "@keyup.enter");
|
||||
c = c.replace(/slot-scope="([^"]+)"/g, '#default="$1"');
|
||||
c = c.replace(/slot="([^"]+)"/g, '#$1');
|
||||
c = c.replace(/from ['"]view-design['"]/g, 'from "@/utils/message"');
|
||||
c = c.replace(/import\s*\{([^}]*)\}\s*from\s*['"]view-design['"]/g, 'import {$1} from "@/utils/message"');
|
||||
return c;
|
||||
}
|
||||
|
||||
function applyTemplateTransforms(content) {
|
||||
let c = content;
|
||||
const replacements = [
|
||||
[/<\/?Card>/g, (m) => m.replace("Card", "el-card")],
|
||||
[/<Form-item/g, "<el-form-item"],
|
||||
[/<\/Form-item>/g, "</el-form-item>"],
|
||||
[/<FormItem/g, "<el-form-item"],
|
||||
[/<\/FormItem>/g, "</el-form-item>"],
|
||||
[/<Form(\s|>)/g, "<el-form$1"],
|
||||
[/<\/Form>/g, "</el-form>"],
|
||||
[/<InputNumber/g, "<el-input-number"],
|
||||
[/<\/InputNumber>/g, "</el-input-number>"],
|
||||
[/<Input(\s|>)/g, "<el-input$1"],
|
||||
[/<\/Input>/g, "</el-input>"],
|
||||
[/<Button(\s|>)/g, "<el-button$1"],
|
||||
[/<\/Button>/g, "</el-button>"],
|
||||
[/<Select(\s|>)/g, "<el-select$1"],
|
||||
[/<\/Select>/g, "</el-select>"],
|
||||
[/<Option(\s)/g, "<el-option "],
|
||||
[/<\/Option>/g, "</el-option>"],
|
||||
[/<DatePicker/g, "<el-date-picker"],
|
||||
[/<\/DatePicker>/g, "</el-date-picker>"],
|
||||
[/<TimePicker/g, "<el-time-picker"],
|
||||
[/<\/TimePicker>/g, "</el-time-picker>"],
|
||||
[/<Modal/g, "<el-dialog"],
|
||||
[/<\/Modal>/g, "</el-dialog>"],
|
||||
[/<Page(\s)/g, "<el-pagination "],
|
||||
[/<\/Page>/g, "</el-pagination>"],
|
||||
[/<Tabs(\s|>)/g, "<el-tabs$1"],
|
||||
[/<\/Tabs>/g, "</el-tabs>"],
|
||||
[/<TabPane/g, "<el-tab-pane"],
|
||||
[/<\/TabPane>/g, "</el-tab-pane>"],
|
||||
[/<Row(\s|>)/g, "<div$1"],
|
||||
[/<\/Row>/g, "</div>"],
|
||||
[/<Col(\s|>)/g, "<div$1"],
|
||||
[/<\/Col>/g, "</div>"],
|
||||
[/<Tag(\s|>)/g, "<el-tag$1"],
|
||||
[/<\/Tag>/g, "</el-tag>"],
|
||||
[/<Switch(\s|>)/g, "<el-switch$1"],
|
||||
[/<\/Switch>/g, "</el-switch>"],
|
||||
[/<RadioGroup/g, "<el-radio-group"],
|
||||
[/<\/RadioGroup>/g, "</el-radio-group>"],
|
||||
[/<Radio(\s)/g, "<el-radio "],
|
||||
[/<\/Radio>/g, "</el-radio>"],
|
||||
[/<CheckboxGroup/g, "<el-checkbox-group"],
|
||||
[/<\/CheckboxGroup>/g, "</el-checkbox-group>"],
|
||||
[/<Checkbox/g, "<el-checkbox"],
|
||||
[/<\/Checkbox>/g, "</el-checkbox>"],
|
||||
[/<Poptip/g, "<el-popover"],
|
||||
[/<\/Poptip>/g, "</el-popover>"],
|
||||
[/<Tooltip/g, "<el-tooltip"],
|
||||
[/<\/Tooltip>/g, "</el-tooltip>"],
|
||||
[/<Drawer/g, "<el-drawer"],
|
||||
[/<\/Drawer>/g, "</el-drawer>"],
|
||||
[/<Alert/g, "<el-alert"],
|
||||
[/<\/Alert>/g, "</el-alert>"],
|
||||
[/<Badge/g, "<el-badge"],
|
||||
[/<\/Badge>/g, "</el-badge>"],
|
||||
[/<Divider/g, "<el-divider"],
|
||||
[/<\/Divider>/g, "</el-divider>"],
|
||||
[/<Rate/g, "<el-rate"],
|
||||
[/<\/Rate>/g, "</el-rate>"],
|
||||
[/<Steps/g, "<el-steps"],
|
||||
[/<\/Steps>/g, "</el-steps>"],
|
||||
[/<Step(\s)/g, "<el-step "],
|
||||
[/<\/Step>/g, "</el-step>"],
|
||||
[/<Upload/g, "<el-upload"],
|
||||
[/<\/Upload>/g, "</el-upload>"],
|
||||
[/<Cascader/g, "<el-cascader"],
|
||||
[/<\/Cascader>/g, "</el-cascader>"],
|
||||
[/<Affix/g, "<el-affix"],
|
||||
[/<\/Affix>/g, "</el-affix>"],
|
||||
[/<Spin/g, "<el-skeleton"],
|
||||
[/<\/Spin>/g, "</el-skeleton>"],
|
||||
[/:label-width="(\d+)"/g, 'label-width="$1px"'],
|
||||
[/:mask-closable="false"/g, ':close-on-click-modal="false"'],
|
||||
[/:width="(\d+)"/g, 'width="$1"'],
|
||||
[/type="primary"/g, 'type="primary"'],
|
||||
];
|
||||
for (const [pattern, replacement] of replacements) {
|
||||
c = c.replace(pattern, replacement);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function walkDir(dir, files = []) {
|
||||
if (!fs.existsSync(dir)) return files;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(full, files);
|
||||
} else if (entry.name.endsWith(".vue")) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const copied = [];
|
||||
const transformed = [];
|
||||
const skipped = [];
|
||||
|
||||
for (const [buyerRel, managerRel] of DECORATE_COPY_MAP) {
|
||||
const managerPath = path.join(MANAGER_ROOT, managerRel);
|
||||
const buyerPath = path.join(BUYER_ROOT, buyerRel);
|
||||
if (!fs.existsSync(managerPath)) {
|
||||
skipped.push({ file: buyerRel, reason: "manager counterpart missing" });
|
||||
continue;
|
||||
}
|
||||
let content = fs.readFileSync(managerPath, "utf8");
|
||||
content = adaptBuyerContent(content);
|
||||
fs.mkdirSync(path.dirname(buyerPath), { recursive: true });
|
||||
fs.writeFileSync(buyerPath, content);
|
||||
copied.push(buyerRel);
|
||||
}
|
||||
|
||||
const copiedSet = new Set(copied);
|
||||
for (const dir of TARGET_DIRS) {
|
||||
const absDir = path.join(BUYER_ROOT, dir);
|
||||
for (const filePath of walkDir(absDir)) {
|
||||
const rel = path.relative(BUYER_ROOT, filePath).replace(/\\/g, "/");
|
||||
if (copiedSet.has(rel)) continue;
|
||||
|
||||
let content = fs.readFileSync(filePath, "utf8");
|
||||
const hasIView =
|
||||
/<(Card|Form|Form-item|FormItem|Table|Button|Modal|Page|Tabs|TabPane|Input|Select|Option|DatePicker|Row|Poptip|Drawer|Upload|Checkbox|Radio|Switch|Tag|Alert|Rate|Steps|Step|Spin|Affix|TimePicker|Cascader|Divider|Badge|InputNumber)\b/.test(
|
||||
content
|
||||
);
|
||||
if (!hasIView) {
|
||||
skipped.push({ file: rel, reason: "no iView components" });
|
||||
continue;
|
||||
}
|
||||
|
||||
content = applyTemplateTransforms(content);
|
||||
content = applyScriptTransforms(content);
|
||||
fs.writeFileSync(filePath, content);
|
||||
transformed.push(rel);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("=== Buyer Migration Summary ===");
|
||||
console.log(`Copied from manager: ${copied.length}`);
|
||||
console.log(`Script-transformed: ${transformed.length}`);
|
||||
console.log(`Skipped: ${skipped.length}`);
|
||||
149
buyer/scripts/replace-icon.js
Normal file
149
buyer/scripts/replace-icon.js
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 将静态 <Icon type="..." /> 替换为 <el-icon><Xxx /></el-icon>
|
||||
* 动态 :type / custom 属性保留,由人工或 resolveIcon 处理。
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "src");
|
||||
|
||||
const TYPE_TO_COMPONENT = {
|
||||
"md-checkmark": "Check",
|
||||
"md-close": "Close",
|
||||
"md-trash": "Delete",
|
||||
"md-add": "Plus",
|
||||
"md-arrow-dropdown": "ArrowDown",
|
||||
"md-arrow-dropup": "ArrowUp",
|
||||
"md-person": "User",
|
||||
"md-lock": "Lock",
|
||||
"md-phone-portrait": "Iphone",
|
||||
"md-key": "Key",
|
||||
"md-link": "Link",
|
||||
"md-refresh": "Refresh",
|
||||
"md-qr-scanner": "Iphone",
|
||||
"ios-arrow-down": "ArrowDown",
|
||||
"ios-arrow-up": "ArrowUp",
|
||||
"ios-arrow-forward": "ArrowRight",
|
||||
"ios-arrow-back": "ArrowLeft",
|
||||
"ios-arrow-round-down": "ArrowDown",
|
||||
"ios-arrow-round-forward": "ArrowRight",
|
||||
"ios-cart-outline": "ShoppingCart",
|
||||
"ios-camera": "Camera",
|
||||
"ios-eye-outline": "View",
|
||||
"ios-trash-outline": "Delete",
|
||||
"ios-alert-outline": "Warning",
|
||||
"ios-help-circle-outline": "Help",
|
||||
"ios-add-circle-outline": "Plus",
|
||||
"ios-information-circle": "InfoFilled",
|
||||
"ios-search": "Search",
|
||||
"ios-text-outline": "Document",
|
||||
"ios-alarm-outline": "AlarmClock",
|
||||
"ios-heart": "StarFilled",
|
||||
"md-close-circle": "CircleClose",
|
||||
edit: "Edit",
|
||||
"trash-a": "Delete",
|
||||
location: "Location",
|
||||
};
|
||||
|
||||
function walkDir(dir, files = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory() && entry.name !== "compat") walkDir(full, files);
|
||||
else if (entry.name.endsWith(".vue")) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function parseAttrs(attrStr) {
|
||||
const attrs = {};
|
||||
const re = /(?::([\w-]+)|@([\w-]+)|([\w-]+))=(?:"([^"]*)"|'([^']*)')|(?::([\w-]+)|@([\w-]+)|([\w-]+))(?=\s|\/|>)/g;
|
||||
let m;
|
||||
while ((m = re.exec(attrStr)) !== null) {
|
||||
const name = m[1] || m[2] || m[3] || m[6] || m[7] || m[8];
|
||||
const value = m[4] ?? m[5] ?? true;
|
||||
attrs[name] = value;
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function attrsToString(attrs, exclude = []) {
|
||||
return Object.entries(attrs)
|
||||
.filter(([k]) => !exclude.includes(k))
|
||||
.map(([k, v]) => {
|
||||
if (v === true) return k;
|
||||
if (k.startsWith("@")) return `${k}="${v}"`;
|
||||
return `:${k}="${v}"`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function transform(content, filePath) {
|
||||
if (filePath.includes("/compat/")) return content;
|
||||
let c = content;
|
||||
const used = new Set();
|
||||
|
||||
c = c.replace(/<Icon\b([^>]*?)\/>/g, (match, attrStr) => {
|
||||
if (/\bcustom=/.test(attrStr) || /:type=/.test(attrStr)) return match;
|
||||
const attrs = parseAttrs(attrStr);
|
||||
const type = attrs.type;
|
||||
if (!type || !TYPE_TO_COMPONENT[type]) return match;
|
||||
const comp = TYPE_TO_COMPONENT[type];
|
||||
used.add(comp);
|
||||
const rest = attrsToString(attrs, ["type"]);
|
||||
const size = attrs.size ? ` :size="${attrs.size}"` : "";
|
||||
const open = rest ? `<el-icon ${rest}${size}>` : `<el-icon${size}>`;
|
||||
return `${open}<${comp} /></el-icon>`;
|
||||
});
|
||||
|
||||
c = c.replace(/<Icon\b([^>]*?)><\/Icon>/g, (match, attrStr) => {
|
||||
if (/\bcustom=/.test(attrStr) || /:type=/.test(attrStr)) return match;
|
||||
const attrs = parseAttrs(attrStr);
|
||||
const type = attrs.type;
|
||||
if (!type || !TYPE_TO_COMPONENT[type]) return match;
|
||||
const comp = TYPE_TO_COMPONENT[type];
|
||||
used.add(comp);
|
||||
const rest = attrsToString(attrs, ["type"]);
|
||||
const size = attrs.size ? ` :size="${attrs.size}"` : "";
|
||||
const open = rest ? `<el-icon ${rest}${size}>` : `<el-icon${size}>`;
|
||||
return `${open}<${comp} /></el-icon>`;
|
||||
});
|
||||
|
||||
if (used.size && !c.includes("@element-plus/icons-vue")) {
|
||||
const imports = [...used].sort().join(", ");
|
||||
c = c.replace(
|
||||
/(<script[^>]*>\s*\n)/,
|
||||
`$1import { ${imports} } from '@element-plus/icons-vue';\n`
|
||||
);
|
||||
c = c.replace(/export default \{/, (m) => {
|
||||
if (/components:\s*\{/.test(c)) {
|
||||
return m;
|
||||
}
|
||||
return `${m}\n components: { ${imports} },`;
|
||||
});
|
||||
if (/components:\s*\{/.test(c) && !c.includes(`${[...used][0]},`)) {
|
||||
c = c.replace(/components:\s*\{([^}]*)\}/, (m, inner) => {
|
||||
const existing = inner.trim();
|
||||
const add = [...used].filter((n) => !inner.includes(n)).join(", ");
|
||||
if (!add) return m;
|
||||
const merged = existing ? `${existing}, ${add}` : add;
|
||||
return `components: { ${merged} }`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const file of walkDir(ROOT)) {
|
||||
const original = fs.readFileSync(file, "utf8");
|
||||
if (!/<Icon\b/.test(original)) continue;
|
||||
const fixed = transform(original, file);
|
||||
if (fixed !== original) {
|
||||
fs.writeFileSync(file, fixed);
|
||||
count++;
|
||||
console.log(path.relative(ROOT, file));
|
||||
}
|
||||
}
|
||||
console.log(`Icon replacements in ${count} files`);
|
||||
63
buyer/scripts/replace-page.js
Normal file
63
buyer/scripts/replace-page.js
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 将全局 <Page> 封装替换为原生 el-pagination
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "src");
|
||||
|
||||
function walkDir(dir, files = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkDir(full, files);
|
||||
else if (entry.name.endsWith(".vue")) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function transformPage(content) {
|
||||
return content.replace(
|
||||
/<Page\b([^>]*?)(\/>|>(\s*)<\/Page>)/gs,
|
||||
(match, attrs, close) => {
|
||||
let a = attrs;
|
||||
const hasTotal = /\bshow-total\b/.test(a);
|
||||
const hasSizer = /\bshow-sizer\b/.test(a);
|
||||
const hasElevator = /\bshow-elevator\b/.test(a);
|
||||
|
||||
a = a.replace(/\s+show-total\b/g, "");
|
||||
a = a.replace(/\s+show-sizer\b/g, "");
|
||||
a = a.replace(/\s+show-elevator\b/g, "");
|
||||
a = a.replace(/:current=/g, ":current-page=");
|
||||
a = a.replace(/:page-size-opts=/g, ":page-sizes=");
|
||||
a = a.replace(/@change=/g, "@current-change=");
|
||||
a = a.replace(/\ssize="small"/g, " small");
|
||||
|
||||
const layoutParts = [];
|
||||
if (hasTotal) layoutParts.push("total");
|
||||
if (hasSizer) layoutParts.push("sizes");
|
||||
layoutParts.push("prev", "pager", "next");
|
||||
if (hasElevator) layoutParts.push("jumper");
|
||||
|
||||
const layoutAttr = ` layout="${layoutParts.join(", ")}"`;
|
||||
|
||||
if (close.startsWith("/>")) {
|
||||
return `<el-pagination${a}${layoutAttr} />`;
|
||||
}
|
||||
return `<el-pagination${a}${layoutAttr}></el-pagination>`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const file of walkDir(ROOT)) {
|
||||
const original = fs.readFileSync(file, "utf8");
|
||||
if (!/<Page\b/.test(original)) continue;
|
||||
const fixed = transformPage(original);
|
||||
if (fixed !== original) {
|
||||
fs.writeFileSync(file, fixed);
|
||||
count++;
|
||||
console.log(path.relative(ROOT, file));
|
||||
}
|
||||
}
|
||||
console.log(`Page → el-pagination in ${count} files`);
|
||||
@@ -4,60 +4,78 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import storage from "@/plugins/storage";
|
||||
import { getBaseSite } from "@/api/common.js";
|
||||
export default {
|
||||
name: "App",
|
||||
mounted() {
|
||||
this.init();
|
||||
},
|
||||
methods:{
|
||||
init(){
|
||||
if(!storage.getItem("siteName")||!storage.getItem("logoImg")||!storage.getItem("sitelogo_expiration_time")) {
|
||||
this.getSite();
|
||||
}else{
|
||||
// 如果缓存过期,则获取最新的信息
|
||||
if (new Date() > storage.getItem("sitelogo_expiration_time")) {
|
||||
this.getSite();
|
||||
return;
|
||||
}else{
|
||||
window.document.title = storage.getItem("siteName");
|
||||
//动态获取icon
|
||||
let link =document.querySelector("link[rel*='icon']") ||document.createElement("link");
|
||||
link.type = "image/x-icon";
|
||||
link.href = storage.getItem("siteIcon");
|
||||
link.rel = "shortcut icon";
|
||||
document.getElementsByTagName("head")[0].appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
getSite(){
|
||||
//获取基本站点信息
|
||||
getBaseSite().then((res) => {
|
||||
if (res.success && res.result.settingValue) {
|
||||
let data = JSON.parse(res.result.settingValue);
|
||||
// 过期时间
|
||||
var expirationTime = new Date().setHours(new Date().getHours() + 1);
|
||||
// 存放过期时间
|
||||
storage.setItem("sitelogo_expiration_time", expirationTime);
|
||||
// 存放信息
|
||||
storage.setItem('siteName', data.siteName);
|
||||
storage.setItem('logoImg', data.buyerSideLogo);
|
||||
storage.setItem("siteIcon",data.buyerSideIcon);
|
||||
window.document.title = data.siteName;
|
||||
//动态获取icon
|
||||
let link =document.querySelector("link[rel*='icon']") ||document.createElement("link");
|
||||
link.type = "image/x-icon";
|
||||
link.href = data.buyerSideIcon;
|
||||
link.rel = "shortcut icon";
|
||||
document.getElementsByTagName("head")[0].appendChild(link);
|
||||
}
|
||||
});
|
||||
}
|
||||
import { getBaseSite, getThemeSetting } from "@/api/common.js";
|
||||
import { fetchAndApplyTheme } from "@/utils/theme";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
defineOptions({ name: "App" });
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
|
||||
function applySiteInfo(data) {
|
||||
const expirationTime = new Date().setHours(new Date().getHours() + 1);
|
||||
storage.setItem("sitelogo_expiration_time", expirationTime);
|
||||
storage.setItem("siteName", data.siteName);
|
||||
storage.setItem("logoImg", data.buyerSideLogo);
|
||||
storage.setItem("siteIcon", data.buyerSideIcon);
|
||||
appStore.setSiteName(data.siteName);
|
||||
appStore.setLogoImg(data.buyerSideLogo);
|
||||
if (route.meta.title === undefined) {
|
||||
window.document.title = data.siteName;
|
||||
}
|
||||
};
|
||||
let link =
|
||||
document.querySelector("link[rel*='icon']") ||
|
||||
document.createElement("link");
|
||||
link.type = "image/x-icon";
|
||||
link.href = data.buyerSideIcon;
|
||||
link.rel = "shortcut icon";
|
||||
document.getElementsByTagName("head")[0].appendChild(link);
|
||||
}
|
||||
|
||||
function init() {
|
||||
const expirationTime = storage.getItem("sitelogo_expiration_time");
|
||||
const cacheValid =
|
||||
expirationTime && new Date() <= new Date(Number(expirationTime));
|
||||
if (cacheValid) {
|
||||
applySiteInfo({
|
||||
siteName: storage.getItem("siteName"),
|
||||
buyerSideLogo: storage.getItem("logoImg"),
|
||||
buyerSideIcon: storage.getItem("siteIcon"),
|
||||
});
|
||||
}
|
||||
getSite();
|
||||
getTheme();
|
||||
}
|
||||
|
||||
function getSite() {
|
||||
getBaseSite().then((res) => {
|
||||
if (res.success && res.result.settingValue) {
|
||||
try {
|
||||
const data = JSON.parse(res.result.settingValue);
|
||||
applySiteInfo(data);
|
||||
} catch {
|
||||
// 站点配置解析失败时使用缓存
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('获取站点配置失败', err);
|
||||
});
|
||||
}
|
||||
|
||||
function getTheme() {
|
||||
fetchAndApplyTheme(getThemeSetting).catch((err) => {
|
||||
console.warn('获取主题配置失败', err);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
#app {
|
||||
|
||||
@@ -44,7 +44,8 @@ export function editMemberInfo (params) {
|
||||
url: '/buyer/passport/member/editOwn',
|
||||
method: Method.PUT,
|
||||
needToken: true,
|
||||
data: params
|
||||
data: params,
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,7 +55,9 @@ export function editPwd (params) {
|
||||
url: `/buyer/passport/member/modifyPass`,
|
||||
method: Method.PUT,
|
||||
needToken: true,
|
||||
data: params
|
||||
data: params,
|
||||
loading: false,
|
||||
skipMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import request, {
|
||||
Method, commonUrl
|
||||
Method, buyerUrl
|
||||
} from '@/plugins/request.js';
|
||||
|
||||
// 会员收货地址列表
|
||||
@@ -17,7 +17,8 @@ export function newMemberAddress (params) {
|
||||
url: '/buyer/member/address',
|
||||
needToken: true,
|
||||
method: Method.POST,
|
||||
data: params
|
||||
data: params,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,7 +28,8 @@ export function editMemberAddress (params) {
|
||||
url: '/buyer/member/address',
|
||||
needToken: true,
|
||||
method: Method.PUT,
|
||||
params
|
||||
params,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,7 +38,8 @@ export function delMemberAddress (id) {
|
||||
return request({
|
||||
url: `/buyer/member/address/delById/${id}`,
|
||||
needToken: true,
|
||||
method: Method.DELETE
|
||||
method: Method.DELETE,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,7 +55,7 @@ export function getAddrDetail (id) {
|
||||
// 传给后台citycode 获取城市街道等id
|
||||
export function handleRegion (params) {
|
||||
return request({
|
||||
url: `${commonUrl}/common/common/region/region`,
|
||||
url: `${buyerUrl}/buyer/common/region/region`,
|
||||
needToken: true,
|
||||
method: Method.GET,
|
||||
params
|
||||
|
||||
@@ -20,7 +20,8 @@ export function cartGoodsAll () {
|
||||
return request({
|
||||
url: '/buyer/trade/carts/all',
|
||||
method: Method.GET,
|
||||
needToken: true
|
||||
needToken: true,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ export function addCartGoods (params) {
|
||||
/**
|
||||
* 创建交易
|
||||
* @param client 客户端:H5/移动端 PC/PC端,WECHAT_MP/小程序端,APP/移动应用端
|
||||
* @param way 购物车购买:CART/立即购买:BUY_NOW / 积分购买:POINT
|
||||
* @param way 购物车:CART / 立即购买:BUY_NOW / 积分:POINTS / 拼团:PINTUAN / 砍价:KANJIA
|
||||
* @param remark 备注 非必填
|
||||
*/
|
||||
export function createTrade (data) {
|
||||
@@ -80,7 +81,7 @@ export function createTrade (data) {
|
||||
/**
|
||||
* 选择优惠券
|
||||
* @param memberCouponId 优惠券id
|
||||
* @param way 购物车购买:CART/立即购买:BUY_NOW/ 积分购买:POINT
|
||||
* @param way 购物车:CART / 立即购买:BUY_NOW / 积分:POINTS
|
||||
* @param used 使用true 弃用 false
|
||||
*/
|
||||
export function selectCoupon (params) {
|
||||
@@ -92,6 +93,21 @@ export function selectCoupon (params) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择礼品卡
|
||||
* @param way 购物车:CART / 立即购买:BUY_NOW / 拼团:PINTUAN / 积分:POINTS / 砍价:KANJIA
|
||||
* @param credentialId 礼品卡凭证 id;used=false 且不传则清空全部
|
||||
* @param used 使用 true / 弃用 false
|
||||
*/
|
||||
export function selectGiftCard (params) {
|
||||
return request({
|
||||
url: '/buyer/trade/carts/select/giftCard',
|
||||
method: Method.GET,
|
||||
needToken: true,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 可用优惠券数量
|
||||
*/
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import request, {Method, commonUrl} from '@/plugins/request.js';
|
||||
import request, {Method, buyerUrl} from '@/plugins/request.js';
|
||||
|
||||
/**
|
||||
* 获取拼图验证
|
||||
*/
|
||||
export function getVerifyImg (verificationEnums) {
|
||||
return request({
|
||||
url: `${commonUrl}/common/common/slider/${verificationEnums}`,
|
||||
url: `${buyerUrl}/buyer/common/slider/${verificationEnums}`,
|
||||
method: Method.GET,
|
||||
needToken: false
|
||||
});
|
||||
@@ -15,7 +15,7 @@ export function getVerifyImg (verificationEnums) {
|
||||
*/
|
||||
export function postVerifyImg (params) {
|
||||
return request({
|
||||
url: `${commonUrl}/common/common/slider/${params.verificationEnums}`,
|
||||
url: `${buyerUrl}/buyer/common/slider/${params.verificationEnums}`,
|
||||
method: Method.POST,
|
||||
needToken: false,
|
||||
params
|
||||
@@ -26,7 +26,7 @@ export function postVerifyImg (params) {
|
||||
*/
|
||||
export function sendSms (params) {
|
||||
return request({
|
||||
url: `${commonUrl}/common/common/sms/${params.verificationEnums}/${params.mobile}`,
|
||||
url: `${buyerUrl}/buyer/common/sms/${params.verificationEnums}/${params.mobile}`,
|
||||
method: Method.GET,
|
||||
needToken: false,
|
||||
params
|
||||
@@ -36,9 +36,10 @@ export function sendSms (params) {
|
||||
// 地区数据,用于三级联动
|
||||
export function getRegion (id) {
|
||||
return request({
|
||||
url: `${commonUrl}/common/common/region/item/${id}`,
|
||||
url: `${buyerUrl}/buyer/common/region/item/${id}`,
|
||||
needToken: true,
|
||||
method: Method.GET
|
||||
method: Method.GET,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,7 +80,7 @@ export function articleDetail (id) {
|
||||
// 获取IM接口前缀
|
||||
export function getIMDetail () {
|
||||
return request({
|
||||
url: `${commonUrl}/common/common/IM`,
|
||||
url: `${buyerUrl}/buyer/common/IM`,
|
||||
method: Method.GET
|
||||
});
|
||||
}
|
||||
@@ -88,8 +89,17 @@ export function getIMDetail () {
|
||||
//获取图片logo
|
||||
export function getBaseSite(){
|
||||
return request ({
|
||||
url:`${commonUrl}/common/common/site`,
|
||||
url:`${buyerUrl}/buyer/common/site`,
|
||||
method: Method.GET,
|
||||
needToken: false
|
||||
})
|
||||
}
|
||||
|
||||
// 获取主题色配置
|
||||
export function getThemeSetting() {
|
||||
return request({
|
||||
url: `${buyerUrl}/buyer/common/site/theme`,
|
||||
method: Method.GET,
|
||||
needToken: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ import request, {
|
||||
// 商品列表
|
||||
export function goodsList (params) {
|
||||
return request({
|
||||
url: '/buyer/goods/goods/es',
|
||||
url: '/buyer/goods/goods/search',
|
||||
method: Method.GET,
|
||||
needToken: false,
|
||||
optionalToken: true,
|
||||
params
|
||||
});
|
||||
}
|
||||
@@ -15,9 +16,10 @@ export function goodsList (params) {
|
||||
// 商品分类 筛选条件 品牌, 尺寸等
|
||||
export function filterList (params) {
|
||||
return request({
|
||||
url: '/buyer/goods/goods/es/related',
|
||||
url: '/buyer/goods/goods/search/related',
|
||||
method: Method.GET,
|
||||
needToken: false,
|
||||
optionalToken: true,
|
||||
params
|
||||
});
|
||||
}
|
||||
@@ -31,12 +33,13 @@ export function goodsDetail (goodsId) {
|
||||
});
|
||||
}
|
||||
|
||||
// id获取商品Sku详情
|
||||
// id获取商品Sku详情(支持未登录浏览,登录后可选携带 token)
|
||||
export function goodsSkuDetail (params) {
|
||||
return request({
|
||||
url: `/buyer/goods/goods/sku/${params.goodsId}/${params.skuId}`,
|
||||
method: Method.GET,
|
||||
needToken: true,
|
||||
needToken: false,
|
||||
optionalToken: true,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ export function handleRefreshToken(token) {
|
||||
url: `/buyer/passport/member/refresh/${token}`,
|
||||
method: Method.GET,
|
||||
needToken: false,
|
||||
// 刷新令牌请求自身不再进入 401/403 刷新分支,避免拦截器递归死锁;失败也不弹全局错误提示
|
||||
skipAuthHandler: true,
|
||||
skipMessage: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,32 @@ export function loginCallback (uuid) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getThirdAccountBindList () {
|
||||
return request({
|
||||
url: '/buyer/passport/connect/bind/list',
|
||||
method: Method.GET,
|
||||
needToken: true
|
||||
});
|
||||
}
|
||||
|
||||
export function bindThirdAccount (data) {
|
||||
return request({
|
||||
url: '/buyer/passport/connect/bind',
|
||||
method: Method.POST,
|
||||
needToken: true,
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function unbindThirdAccount (data) {
|
||||
return request({
|
||||
url: '/buyer/passport/connect/bind/unbind',
|
||||
method: Method.POST,
|
||||
needToken: true,
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 忘记密码 验证手机验证码
|
||||
*/
|
||||
@@ -98,6 +124,7 @@ export function getSCLoginCode(params) {
|
||||
url: `/buyer/passport/member/pc_session`,
|
||||
method: Method.POST,
|
||||
needToken: false,
|
||||
loading: false,
|
||||
params
|
||||
});
|
||||
}
|
||||
@@ -106,6 +133,19 @@ export function sCLogin(token,params) {
|
||||
url: `/buyer/passport/member/session_login/`+token,
|
||||
method: Method.POST,
|
||||
needToken: false,
|
||||
loading: false,
|
||||
skipMessage: true,
|
||||
// 长轮询后端最长挂起 20s,需大于该值,避免 axios 10s 默认超时把二维码请求掐断
|
||||
timeout: 25000,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
export function getWechatH5QrCode (params) {
|
||||
return request({
|
||||
url: '/buyer/other/wechat/h5/qrcode',
|
||||
method: Method.GET,
|
||||
needToken: true,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,21 +95,25 @@ export function cancelStoreCollect (type, id) {
|
||||
});
|
||||
}
|
||||
|
||||
// 查看是否收藏商品
|
||||
// 查看是否收藏商品(后台查询,token 失效时静默失败,不弹全局登录框)
|
||||
export function isCollection (type, goodsId) {
|
||||
return request({
|
||||
url: `/buyer/member/collection/isCollection/${type}/${goodsId}`,
|
||||
method: Method.GET,
|
||||
needToken: true
|
||||
needToken: true,
|
||||
skipAuthHandler: true,
|
||||
skipMessage: true
|
||||
});
|
||||
}
|
||||
|
||||
// 查看是否收藏店铺
|
||||
// 查看是否收藏店铺(后台查询,token 失效时静默失败,不弹全局登录框)
|
||||
export function isStoreCollection (type, goodsId) {
|
||||
return request({
|
||||
url: `/buyer/member/storeCollection/isCollection/${type}/${goodsId}`,
|
||||
method: Method.GET,
|
||||
needToken: true
|
||||
needToken: true,
|
||||
skipAuthHandler: true,
|
||||
skipMessage: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,22 +137,26 @@ export function storeCollectList (params) {
|
||||
});
|
||||
}
|
||||
|
||||
// 单个商品评价
|
||||
// 单个商品评价(需登录;token 失效时静默失败,不弹全局登录框)
|
||||
export function goodsComment (params) {
|
||||
return request({
|
||||
url: `/buyer/member/evaluation/${params.goodsId}/goodsEvaluation`,
|
||||
method: Method.GET,
|
||||
needToken: false,
|
||||
needToken: true,
|
||||
skipAuthHandler: true,
|
||||
skipMessage: true,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
// 商品各评价类别数量
|
||||
// 商品各评价类别数量(需登录;token 失效时静默失败,不弹全局登录框)
|
||||
export function goodsCommentNum (goodsId) {
|
||||
return request({
|
||||
url: `/buyer/member/evaluation/${goodsId}/evaluationNumber`,
|
||||
method: Method.GET,
|
||||
needToken: false
|
||||
needToken: true,
|
||||
skipAuthHandler: true,
|
||||
skipMessage: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,7 +166,7 @@ export function addEvaluation (params) {
|
||||
url: `/buyer/member/evaluation`,
|
||||
method: Method.POST,
|
||||
needToken: true,
|
||||
params
|
||||
data: params
|
||||
});
|
||||
}
|
||||
|
||||
@@ -180,6 +188,15 @@ export function receiptList () {
|
||||
});
|
||||
}
|
||||
|
||||
// 发票详情
|
||||
export function receiptDetail (id) {
|
||||
return request({
|
||||
url: `/buyer/trade/receipt/${id}`,
|
||||
method: Method.GET,
|
||||
needToken: true
|
||||
});
|
||||
}
|
||||
|
||||
// 保存发票信息
|
||||
export function saveReceipt (params) {
|
||||
return request({
|
||||
@@ -196,6 +213,7 @@ export function couponList (params) {
|
||||
url: `/buyer/promotion/coupon`,
|
||||
method: Method.GET,
|
||||
needToken: true,
|
||||
loading: false,
|
||||
params
|
||||
});
|
||||
}
|
||||
@@ -288,7 +306,8 @@ export function cancelAfterSale (afterSaleSn) {
|
||||
return request({
|
||||
url: `/buyer/order/afterSale/cancel/${afterSaleSn}`,
|
||||
method: Method.POST,
|
||||
needToken: true
|
||||
needToken: true,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
|
||||
@@ -333,93 +352,6 @@ export function clearComplain (id) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会员分销信息
|
||||
*/
|
||||
export function distribution () {
|
||||
return request({
|
||||
url: `/buyer/distribution/distribution`,
|
||||
method: Method.GET,
|
||||
needToken: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请成为分销商
|
||||
* @param idNumber 身份证号
|
||||
* @param name 名字
|
||||
*/
|
||||
export function applyDistribution (params) {
|
||||
return request({
|
||||
url: `/buyer/distribution/distribution`,
|
||||
method: Method.POST,
|
||||
needToken: true,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分销商订单列表
|
||||
*/
|
||||
export function getDistOrderList (params) {
|
||||
return request({
|
||||
url: `/buyer/distribution/order`,
|
||||
method: Method.GET,
|
||||
needToken: true,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分销商商品列表
|
||||
*/
|
||||
export function getDistGoodsList (params) {
|
||||
return request({
|
||||
url: `/buyer/distribution/goods`,
|
||||
method: Method.GET,
|
||||
needToken: true,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定、解绑分销商品
|
||||
* @param distributionGoodsId 分销商品id
|
||||
* @param checked 分销商品id,true为绑定,false为解绑
|
||||
*/
|
||||
export function selectDistGoods (params) {
|
||||
return request({
|
||||
url: `/buyer/distribution/goods/checked/${params.distributionGoodsId}`,
|
||||
method: Method.GET,
|
||||
needToken: true,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分销商提现历史
|
||||
*/
|
||||
export function distCashHistory (params) {
|
||||
return request({
|
||||
url: `/buyer/distribution/cash`,
|
||||
method: Method.GET,
|
||||
needToken: true,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分销商提现
|
||||
*/
|
||||
export function distCash (params) {
|
||||
return request({
|
||||
url: `/buyer/distribution/cash`,
|
||||
method: Method.POST,
|
||||
needToken: true,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的足迹
|
||||
* @param {Number} pageNumber 页码
|
||||
@@ -480,6 +412,31 @@ export function memberPointHistory (params) {
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
export function memberGradeCurrent (params) {
|
||||
return request({
|
||||
url: `/buyer/member/memberGrade/current`,
|
||||
method: Method.GET,
|
||||
needToken: true,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function memberGradeRules () {
|
||||
return request({
|
||||
url: `/buyer/member/memberGrade/rules`,
|
||||
method: Method.GET,
|
||||
needToken: true
|
||||
})
|
||||
}
|
||||
|
||||
export function memberGradeList () {
|
||||
return request({
|
||||
url: `/buyer/member/memberGrade/list`,
|
||||
method: Method.GET,
|
||||
needToken: true
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 分页获取会员站内信
|
||||
* @param {Object} params 请求参数,包括pageNumber、pageSize、status
|
||||
@@ -497,6 +454,15 @@ export function memberMsgList (params) {
|
||||
* @param {String} messageId 消息id
|
||||
*/
|
||||
|
||||
export function getMemberMessage (id) {
|
||||
return request({
|
||||
url: `/buyer/message/member/${id}`,
|
||||
method: Method.GET,
|
||||
needToken: true,
|
||||
skipMessage: true
|
||||
});
|
||||
}
|
||||
|
||||
export function readMemberMsg (id) {
|
||||
return request({
|
||||
url: `/buyer/message/member/${id}`,
|
||||
|
||||
@@ -24,12 +24,13 @@ export function tradeDetail (params) {
|
||||
* @param payClient 调起方式 可用值:APP,NATIVE,JSAPI,H5
|
||||
* @param sn 订单编号
|
||||
*/
|
||||
export function pay (params) {
|
||||
export function pay (params, config = {}) {
|
||||
return request({
|
||||
url: `/buyer/payment/cashier/pay/${params.paymentMethod}/${params.paymentClient}`,
|
||||
needToken: true,
|
||||
method: Method.GET,
|
||||
params
|
||||
params,
|
||||
skipMessage: !!config.skipMessage,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -57,3 +57,50 @@ export function pointGoodsDetail (id) {
|
||||
id
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询当前客户礼品卡
|
||||
*/
|
||||
export function getGiftCardCashMemberCardPage (params) {
|
||||
return request({
|
||||
url: '/buyer/promotion/giftCardCash/memberCard',
|
||||
method: Method.GET,
|
||||
needToken: true,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定礼品卡(卡号 + 兑换码/卡密)
|
||||
*/
|
||||
export function bindGiftCardCashMemberCard (data) {
|
||||
return request({
|
||||
url: '/buyer/promotion/giftCardCash/memberCard/bind',
|
||||
method: Method.POST,
|
||||
needToken: true,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活礼品卡
|
||||
*/
|
||||
export function activateGiftCardCashMemberCard (memberCardId) {
|
||||
return request({
|
||||
url: `/buyer/promotion/giftCardCash/memberCard/${memberCardId}/activate`,
|
||||
method: Method.POST,
|
||||
needToken: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 下单页可用礼品卡列表
|
||||
*/
|
||||
export function getAvailableGiftCardCashMemberCards () {
|
||||
return request({
|
||||
url: '/buyer/promotion/giftCardCash/memberCard/available',
|
||||
method: Method.GET,
|
||||
needToken: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import request, {
|
||||
export function shopList (params) {
|
||||
return request({
|
||||
url: '/buyer/store/store',
|
||||
needToken: true,
|
||||
needToken: false,
|
||||
optionalToken: true,
|
||||
method: Method.GET,
|
||||
params
|
||||
})
|
||||
@@ -42,11 +43,31 @@ export function applyThird (params) {
|
||||
})
|
||||
}
|
||||
|
||||
// 店铺信息(含评分)
|
||||
export function getStoreById (id) {
|
||||
return request({
|
||||
url: `/buyer/store/store/store/${id}`,
|
||||
needToken: false,
|
||||
method: Method.GET
|
||||
})
|
||||
}
|
||||
|
||||
// 店铺详情
|
||||
// 获取店铺工商/营业执照信息
|
||||
export function getLicencePhoto (id) {
|
||||
return request({
|
||||
url: `/buyer/store/store/get/licencePhoto/${id}`,
|
||||
needToken: false,
|
||||
optionalToken: true,
|
||||
method: Method.GET
|
||||
})
|
||||
}
|
||||
|
||||
export function getDetailById (id) {
|
||||
return request({
|
||||
url: `/buyer/store/store/get/detail/${id}`,
|
||||
needToken: true,
|
||||
needToken: false,
|
||||
optionalToken: true,
|
||||
method: Method.GET
|
||||
})
|
||||
}
|
||||
@@ -72,7 +93,8 @@ export function applyStatus () {
|
||||
return request({
|
||||
url: `/buyer/store/store/apply`,
|
||||
needToken: true,
|
||||
method: Method.GET
|
||||
method: Method.GET,
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
29
buyer/src/assets/iconfont/iconMap.js
Normal file
29
buyer/src/assets/iconfont/iconMap.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import back from "./back.png";
|
||||
import carts from "./carts.png";
|
||||
import collage from "./collage.png";
|
||||
import feedback from "./feedback.png";
|
||||
import notice from "./notice.png";
|
||||
import notification from "./notification.png";
|
||||
import shop from "./shop.png";
|
||||
import story from "./story.png";
|
||||
import support from "./support.png";
|
||||
import user from "./user.png";
|
||||
|
||||
const iconMap = {
|
||||
back,
|
||||
carts,
|
||||
collage,
|
||||
feedback,
|
||||
notice,
|
||||
notification,
|
||||
shop,
|
||||
story,
|
||||
support,
|
||||
user,
|
||||
};
|
||||
|
||||
export function getIconUrl(name) {
|
||||
return iconMap[name] || "";
|
||||
}
|
||||
|
||||
export default iconMap;
|
||||
40
buyer/src/assets/styles/element.scss
Normal file
40
buyer/src/assets/styles/element.scss
Normal file
@@ -0,0 +1,40 @@
|
||||
:root {
|
||||
--el-color-primary: var(--theme-color, #ff3c2a);
|
||||
--el-color-danger: var(--theme-color, #ff3c2a);
|
||||
--el-font-size-base: 14px;
|
||||
}
|
||||
|
||||
.el-table--border {
|
||||
.el-table__cell {
|
||||
border-right: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.link-text {
|
||||
color: var(--theme-color, #ff3c2a);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.op-split {
|
||||
margin: 0 8px;
|
||||
color: #dcdfe6;
|
||||
}
|
||||
|
||||
/* 次要强调按钮,使用高亮主题色(如「立即购买」) */
|
||||
.confirm-receipt-btn {
|
||||
background-color: var(--theme-light, #ff6b35) !important;
|
||||
border-color: var(--theme-light, #ff6b35) !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: color-mix(in srgb, var(--theme-light, #ff6b35) 88%, black) !important;
|
||||
border-color: color-mix(in srgb, var(--theme-light, #ff6b35) 88%, black) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
25
buyer/src/assets/styles/global-layout.scss
Normal file
25
buyer/src/assets/styles/global-layout.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
// 全局布局工具类(非 scoped,避免子组件继承 class 时宽度失效)
|
||||
.width_1200_auto {
|
||||
width: 1200px;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 首页主体:分类导航 + 楼层装修共用同一 1200px 容器,保证列对齐
|
||||
.index-main {
|
||||
width: 1200px;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// 与 CateNav 商品分类栏、Carousel 左占位一致的列宽
|
||||
$index-main-width: 1200px;
|
||||
$index-side-col-width: 263.2px;
|
||||
$index-col-gap: 10px;
|
||||
// 1200 - 263.2*2 - 10*2 = 653.6px
|
||||
$index-center-col-width: $index-main-width - ($index-side-col-width * 2) - ($index-col-gap * 2);
|
||||
@@ -10,7 +10,7 @@ $warning_color: #ff9900;
|
||||
$error_color: #ed3f14;
|
||||
$handle-btn-color: #438cde;
|
||||
|
||||
$theme_color: #F31947;
|
||||
$theme_color: var(--theme-color, #ff3c2a);
|
||||
|
||||
$border_color: #dddee1;
|
||||
$title_color: #8c8c8c;
|
||||
@@ -30,6 +30,7 @@ $dark_content_color: #d5d5d5;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
/***** 封装一些方法可用于 黑暗主题 ,明亮主题 *****/
|
||||
@@ -305,9 +306,11 @@ li {
|
||||
}
|
||||
|
||||
.width_1200_auto {
|
||||
width: 1184px;
|
||||
width: 1200px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
@@ -325,18 +328,24 @@ li {
|
||||
.goods-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
column-gap: 10px;
|
||||
}
|
||||
.goods-show-info {
|
||||
width: 235px;
|
||||
width: calc(20% - 8px);
|
||||
padding: 6px;
|
||||
margin: 10px 0px;
|
||||
margin-left: 5px;
|
||||
margin: 10px 0;
|
||||
position: relative;
|
||||
border: 1px solid #fff;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.goods-show-img img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
.goods-show-info:hover {
|
||||
border: 1px solid #ccc;
|
||||
@@ -346,16 +355,17 @@ li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.goods-show-detail {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 21px;
|
||||
margin: 6px 0px;
|
||||
}
|
||||
.goods-show-num {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
color: #666;
|
||||
}
|
||||
.goods-show-num {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
color: #009688;
|
||||
}
|
||||
@@ -364,11 +374,16 @@ li {
|
||||
font-weight: bold;
|
||||
}
|
||||
.goods-show-seller {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
color: $theme_color;
|
||||
}
|
||||
.goods-page {
|
||||
margin:10px auto ;
|
||||
text-align: right;
|
||||
width: 1200px;
|
||||
margin: 10px 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
:deep(.el-pagination) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,24 @@
|
||||
.goods-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
column-gap: 10px;
|
||||
}
|
||||
.goods-show-info {
|
||||
width: 235px;
|
||||
width: calc(20% - 8px);
|
||||
padding: 6px;
|
||||
margin: 10px 0px;
|
||||
margin-left: 5px;
|
||||
margin: 10px 0;
|
||||
position: relative;
|
||||
border: 1px solid #fff;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.goods-show-img img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
.goods-show-info:hover {
|
||||
border: 1px solid #ccc;
|
||||
@@ -30,13 +36,15 @@
|
||||
margin-top: 6px;
|
||||
}
|
||||
.goods-show-detail {
|
||||
height: 36px;
|
||||
font-size: 12px;
|
||||
height: 42px;
|
||||
font-size: 14px;
|
||||
line-height: 21px;
|
||||
margin: 6px 0px;
|
||||
overflow:hidden;
|
||||
text-overflow: ellipsis;
|
||||
display:-webkit-box;
|
||||
-webkit-line-clamp:2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient:vertical;
|
||||
color:#808080;
|
||||
}
|
||||
@@ -44,12 +52,12 @@
|
||||
color:#E23A3A;
|
||||
}
|
||||
.goods-show-num {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
color: #666;
|
||||
}
|
||||
.goods-show-num {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
color: #009688;
|
||||
}
|
||||
@@ -58,11 +66,16 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
.goods-show-seller {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
color: $theme_color;
|
||||
}
|
||||
.goods-page {
|
||||
margin:10px auto ;
|
||||
text-align: right;
|
||||
width: 1200px;
|
||||
margin: 10px 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
:deep(.el-pagination) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
@import "~view-design/src/styles/index.less";
|
||||
// iview 自定义样式
|
||||
|
||||
// Element Plus 主题变量(原 view-design less 已移除)
|
||||
@primary-color: #ff5c58;
|
||||
@info-color: #fa6419;
|
||||
@success-color: #68cabe;
|
||||
@@ -11,9 +9,7 @@
|
||||
@table-td-highlight-bg: #ededed;
|
||||
@font-size-base: 12px;
|
||||
|
||||
.ivu-drawer,
|
||||
.drawer,
|
||||
.ivu-drawer-wrap {
|
||||
.el-drawer,
|
||||
.drawer {
|
||||
z-index: 2600 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,247 +1,472 @@
|
||||
<template>
|
||||
<div class="navbar" :class="{'small-search-box':useClass == 'fixed-show'}">
|
||||
<div class="container width_1200_auto flex flex-a-c">
|
||||
<img
|
||||
:src="$store.state.logoImg"
|
||||
v-if="showLogo"
|
||||
class="logo-img"
|
||||
alt=""
|
||||
@click="$router.push('/')"
|
||||
/>
|
||||
<div :class="{'small-search-box':useClass == 'fixed-show'}" class="search-box">
|
||||
<i-input
|
||||
<div class="navbar" ref="rootRef" :class="{'small-search-box': useClass == 'fixed-show'}">
|
||||
<template v-if="useClass == 'fixed-show'">
|
||||
<div class="search search-embedded">
|
||||
<el-input
|
||||
v-model="searchData"
|
||||
size="large"
|
||||
class="search "
|
||||
placeholder="输入你想查找的商品"
|
||||
@keyup.enter.native="search"
|
||||
>
|
||||
|
||||
<div class="search-icon" slot="append" @click="search">
|
||||
<Icon type="ios-search" size="21"/>
|
||||
class="search-input"
|
||||
:placeholder="placeholderText"
|
||||
@focus="openPanel"
|
||||
@input="queueSuggest"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
<div class="search-btn" @click="search">
|
||||
<el-icon :size="21"><SearchIcon /></el-icon>
|
||||
</div>
|
||||
<div v-if="showPanel" class="suggest-panel" @mousedown.prevent>
|
||||
<SearchSuggest
|
||||
:keyword="trimmedKeyword"
|
||||
:history="history"
|
||||
:hot-words="promotionTags"
|
||||
:suggestions="suggestions"
|
||||
:suggesting="suggesting"
|
||||
@pick="selectTags"
|
||||
@clear="clearHistory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="container width_1200_auto flex flex-a-c">
|
||||
<img
|
||||
:src="logoImg"
|
||||
v-if="showLogo"
|
||||
class="logo-img"
|
||||
alt=""
|
||||
@click="$router.push('/')"
|
||||
/>
|
||||
<div class="search-box">
|
||||
<!-- 搜索类型切换:商品 / 店铺(店铺内搜索时不展示) -->
|
||||
<div v-if="showTypeSwitch" class="search-type">
|
||||
<span
|
||||
v-for="item in searchTypes"
|
||||
:key="item.value"
|
||||
class="search-type-item"
|
||||
:class="{ active: searchType === item.value }"
|
||||
@click="switchType(item.value)"
|
||||
>{{ item.label }}</span>
|
||||
</div>
|
||||
</i-input>
|
||||
<template v-if="showTag">
|
||||
<div class="only-store" v-if="storeId" @click="research()">
|
||||
切换为{{!onlyStore ? '店铺内' : '平台'}}搜索
|
||||
|
||||
</div>
|
||||
<div v-if="promotionTags.length === 0"></div>
|
||||
<div v-else class="history-list flex">
|
||||
<div
|
||||
v-for="(item, index) in promotionTags"
|
||||
:key="index"
|
||||
class="mr_10"
|
||||
>
|
||||
<span class="history-item" @click="selectTags(item)">{{ item }}</span>
|
||||
<div class="search">
|
||||
<el-input
|
||||
v-model="searchData"
|
||||
size="large"
|
||||
class="search-input"
|
||||
:placeholder="placeholderText"
|
||||
@focus="openPanel"
|
||||
@input="queueSuggest"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
<div class="search-btn" @click="search">
|
||||
<el-icon :size="21"><SearchIcon /></el-icon>
|
||||
</div>
|
||||
<div v-if="showPanel" class="suggest-panel" @mousedown.prevent>
|
||||
<SearchSuggest
|
||||
:keyword="trimmedKeyword"
|
||||
:history="history"
|
||||
:hot-words="promotionTags"
|
||||
:suggestions="suggestions"
|
||||
:suggesting="suggesting"
|
||||
@pick="selectTags"
|
||||
@clear="clearHistory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showTag">
|
||||
<div v-if="promotionTags.length === 0"></div>
|
||||
<div v-else class="history-list flex">
|
||||
<div
|
||||
v-for="(item, index) in promotionTags"
|
||||
:key="index"
|
||||
class="mr_10"
|
||||
>
|
||||
<span class="history-item" @click="selectTags(item)">{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import storage from '@/plugins/storage.js'
|
||||
import {hotWords} from '@/api/goods.js'
|
||||
import { goodsList, hotWords } from '@/api/goods.js'
|
||||
import { Search as SearchIcon } from '@element-plus/icons-vue'
|
||||
import SearchSuggest from '@/components/SearchSuggest.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
export default {
|
||||
name: 'search',
|
||||
props: {
|
||||
showTag: { // 是否展示搜索栏下方热门搜索
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showLogo: { // 是否展示左侧logo
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
storeId: { // 是否为店铺页面
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
hover: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
useClass:{
|
||||
type:null,
|
||||
default:''
|
||||
}
|
||||
},
|
||||
watch:{
|
||||
storeId(val){
|
||||
this.onlyStore = val ? true : false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchData: '', // 搜索内容
|
||||
onlyStore:false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
selectTags(item) { // 选择热门标签
|
||||
this.searchData = item;
|
||||
this.search();
|
||||
},
|
||||
research(){
|
||||
this.onlyStore = !this.onlyStore
|
||||
},
|
||||
search () { // 全平台搜索商品
|
||||
const url = this.$route.path;
|
||||
if(url == '/goodsList'){
|
||||
this.$emit('search', this.searchData)
|
||||
}else{
|
||||
const pushData = {
|
||||
path:'/goodsList',
|
||||
query: { keyword: this.searchData },
|
||||
}
|
||||
if(this.storeId && this.onlyStore) pushData.query.storeId = this.storeId
|
||||
defineOptions({ name: 'search' })
|
||||
|
||||
|
||||
this.$router.push(pushData);
|
||||
}
|
||||
},
|
||||
searchStore() { // 店铺搜索商品
|
||||
this.$emit('search', this.searchData)
|
||||
}
|
||||
const props = defineProps({
|
||||
showTag: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
computed: {
|
||||
promotionTags() {
|
||||
if (this.$store.state.hotWordsList) {
|
||||
return JSON.parse(this.$store.state.hotWordsList)
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
showLogo: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
created() {
|
||||
this.searchData = this.$route.query.keyword
|
||||
|
||||
if (!this.hover) { // 首页顶部固定搜索栏不调用热词接口
|
||||
// 搜索热词每5分钟请求一次
|
||||
const reloadTime = storage.getItem('hotWordsReloadTime')
|
||||
const time = new Date().getTime() - 5 * 60 * 1000
|
||||
if (!reloadTime) {
|
||||
hotWords({count: 5}).then(res => {
|
||||
if (res.success && res.result) storage.setItem('hotWordsList', res.result)
|
||||
})
|
||||
storage.setItem('hotWordsReloadTime', new Date().getTime())
|
||||
} else if (reloadTime && time > reloadTime) {
|
||||
hotWords({count: 5}).then(res => {
|
||||
if (res.success && res.result) storage.setItem('hotWordsList', res.result)
|
||||
})
|
||||
storage.setItem('hotWordsReloadTime', new Date().getTime())
|
||||
}
|
||||
}
|
||||
storeId: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
hover: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
useClass: {
|
||||
type: null,
|
||||
default: ''
|
||||
}
|
||||
};
|
||||
})
|
||||
|
||||
const emit = defineEmits(['search'])
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const HISTORY_KEY = 'buyerSearchHistory'
|
||||
const HISTORY_LIMIT = 10
|
||||
|
||||
function readHistory() {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY)
|
||||
const list = raw ? JSON.parse(raw) : []
|
||||
return Array.isArray(list) ? list.filter((item) => typeof item === 'string' && item.trim()) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function parseHotWords(raw) {
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw)) return raw.filter(Boolean)
|
||||
if (typeof raw !== 'string') return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return parsed.filter(Boolean)
|
||||
} catch {
|
||||
// 旧数据可能是逗号拼接
|
||||
}
|
||||
return raw.split(',').map((item) => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
const rootRef = ref(null)
|
||||
const searchData = ref('')
|
||||
const history = ref([])
|
||||
const panelVisible = ref(false)
|
||||
const suggestions = ref([])
|
||||
const suggesting = ref(false)
|
||||
let suggestTimer = null
|
||||
let suggestSeq = 0
|
||||
|
||||
const logoImg = computed(() => appStore.logoImg)
|
||||
|
||||
const trimmedKeyword = computed(() => {
|
||||
return (searchData.value || '').trim()
|
||||
})
|
||||
|
||||
const promotionTags = computed(() => {
|
||||
return parseHotWords(appStore.hotWordsList)
|
||||
})
|
||||
|
||||
// 搜索类型:goods 商品 / store 店铺
|
||||
const searchTypes = [
|
||||
{ label: '商品', value: 'goods' },
|
||||
{ label: '店铺', value: 'store' },
|
||||
]
|
||||
const searchType = ref(route.path === '/storeList' ? 'store' : 'goods')
|
||||
// 店铺内搜索(传了 storeId)只能搜本店商品,不展示切换
|
||||
const showTypeSwitch = computed(() => !props.storeId && props.useClass !== 'fixed-show')
|
||||
const isStoreMode = computed(() => searchType.value === 'store' && !props.storeId)
|
||||
const placeholderText = computed(() => (isStoreMode.value ? '输入你想查找的店铺' : '输入你想查找的商品'))
|
||||
|
||||
function switchType(type) {
|
||||
if (searchType.value === type) return
|
||||
searchType.value = type
|
||||
suggestions.value = []
|
||||
suggesting.value = false
|
||||
}
|
||||
|
||||
const showPanel = computed(() => {
|
||||
// 店铺搜索暂无联想/历史,不展示下拉面板
|
||||
if (isStoreMode.value) return false
|
||||
if (!panelVisible.value) return false
|
||||
if (trimmedKeyword.value) return true
|
||||
return history.value.length > 0 || promotionTags.value.length > 0
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.keyword,
|
||||
(value) => {
|
||||
searchData.value = value || ''
|
||||
}
|
||||
)
|
||||
|
||||
function selectTags(item) {
|
||||
// 热词/历史均为商品关键词,点击时按商品搜索
|
||||
searchType.value = 'goods'
|
||||
searchData.value = item
|
||||
search()
|
||||
}
|
||||
|
||||
function remember(keyword) {
|
||||
const next = [keyword, ...history.value.filter((item) => item !== keyword)].slice(0, HISTORY_LIMIT)
|
||||
history.value = next
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
history.value = []
|
||||
localStorage.removeItem(HISTORY_KEY)
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
panelVisible.value = true
|
||||
if (trimmedKeyword.value) queueSuggest()
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelVisible.value = false
|
||||
}
|
||||
|
||||
function onDocumentClick(event) {
|
||||
if (rootRef.value && !rootRef.value.contains(event.target)) closePanel()
|
||||
}
|
||||
|
||||
function queueSuggest() {
|
||||
if (isStoreMode.value) return
|
||||
panelVisible.value = true
|
||||
clearTimeout(suggestTimer)
|
||||
const keyword = trimmedKeyword.value
|
||||
if (!keyword) {
|
||||
suggestions.value = []
|
||||
suggesting.value = false
|
||||
return
|
||||
}
|
||||
suggesting.value = true
|
||||
suggestTimer = setTimeout(() => fetchSuggest(keyword), 300)
|
||||
}
|
||||
|
||||
function fetchSuggest(keyword) {
|
||||
const seq = ++suggestSeq
|
||||
const params = { keyword, pageNumber: 1, pageSize: 8 }
|
||||
if (props.storeId) params.storeId = props.storeId
|
||||
goodsList(params).then((res) => {
|
||||
if (seq !== suggestSeq || keyword !== trimmedKeyword.value) return
|
||||
const records = res && res.success ? (res.result.records || []) : []
|
||||
const seen = new Set()
|
||||
const names = []
|
||||
promotionTags.value.forEach((word) => {
|
||||
if (word && String(word).toLowerCase().includes(keyword.toLowerCase()) && !seen.has(word)) {
|
||||
seen.add(word)
|
||||
names.push(word)
|
||||
}
|
||||
})
|
||||
records.forEach((item) => {
|
||||
const name = item && item.goodsName
|
||||
if (name && !seen.has(name)) {
|
||||
seen.add(name)
|
||||
names.push(name)
|
||||
}
|
||||
})
|
||||
suggestions.value = names.slice(0, 8)
|
||||
}).catch(() => {
|
||||
if (seq === suggestSeq) suggestions.value = []
|
||||
}).finally(() => {
|
||||
if (seq === suggestSeq) suggesting.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function search() {
|
||||
const keyword = trimmedKeyword.value
|
||||
searchData.value = keyword
|
||||
closePanel()
|
||||
if (isStoreMode.value) {
|
||||
if (route.path === '/storeList') {
|
||||
emit('search', keyword)
|
||||
} else {
|
||||
router.push({ path: '/storeList', query: keyword ? { keyword } : {} })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (keyword) remember(keyword)
|
||||
const url = route.path
|
||||
if (url == '/goodsList') {
|
||||
emit('search', keyword)
|
||||
} else {
|
||||
const pushData = {
|
||||
path: '/goodsList',
|
||||
query: keyword ? { keyword } : {},
|
||||
}
|
||||
if (props.storeId) pushData.query.storeId = props.storeId
|
||||
|
||||
router.push(pushData)
|
||||
}
|
||||
}
|
||||
|
||||
function searchStore() {
|
||||
emit('search', trimmedKeyword.value)
|
||||
}
|
||||
|
||||
function applyHotWords(result) {
|
||||
const list = parseHotWords(result)
|
||||
appStore.setHotWords(list)
|
||||
storage.setItem('hotWordsList', JSON.stringify(list))
|
||||
}
|
||||
|
||||
function loadHotWords() {
|
||||
hotWords({ count: 5 }).then((res) => {
|
||||
if (res.success && res.result) applyHotWords(res.result)
|
||||
}).catch(() => {
|
||||
// 热词为可选数据,失败时沿用缓存或默认热词
|
||||
})
|
||||
storage.setItem('hotWordsReloadTime', new Date().getTime())
|
||||
}
|
||||
|
||||
// 初始化(原 created 钩子逻辑)
|
||||
searchData.value = route.query.keyword || ''
|
||||
history.value = readHistory()
|
||||
|
||||
if (!props.hover) {
|
||||
const reloadTime = storage.getItem('hotWordsReloadTime')
|
||||
const time = new Date().getTime() - 5 * 60 * 1000
|
||||
if (!reloadTime || time > reloadTime) loadHotWords()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(suggestTimer)
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.only-store{
|
||||
text-align: right;
|
||||
color:$theme_color;
|
||||
cursor: pointer;
|
||||
}
|
||||
.navbar {
|
||||
height: 113px;
|
||||
background: #fff;
|
||||
}
|
||||
.search-icon{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.small-search-box{
|
||||
height: 60px;
|
||||
|
||||
margin: 0 !important;
|
||||
.navbar.small-search-box {
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-embedded {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search {
|
||||
$btn-gap: 4px;
|
||||
$btn-width: 60px;
|
||||
$input-height: 37.8px;
|
||||
|
||||
position: relative;
|
||||
width: 778.4px;
|
||||
height: $input-height;
|
||||
margin: 10px 0px 5px 0;
|
||||
|
||||
border-radius: 18.9px;
|
||||
.search-input {
|
||||
width: 100%;
|
||||
|
||||
|
||||
/deep/ .ivu-input.ivu-input-large {
|
||||
border: 1.4px solid $theme_color;
|
||||
box-sizing: border-box;
|
||||
border-radius: 19.6px;
|
||||
position: relative;
|
||||
padding-left: 26px;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
height: 37.8px;
|
||||
color: #999;
|
||||
&:focus {
|
||||
:deep(.el-input__wrapper) {
|
||||
border: 2px solid #409EFF;
|
||||
border-radius: 4px;
|
||||
padding-left: 26px;
|
||||
padding-right: calc(#{$btn-gap * 2} + #{$btn-width});
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
height: $input-height;
|
||||
color: #999;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/deep/ .ivu-input-group-append {
|
||||
border-radius: 19.6px !important;
|
||||
.search-btn {
|
||||
position: absolute;
|
||||
right: $btn-gap;
|
||||
top: $btn-gap;
|
||||
bottom: $btn-gap;
|
||||
width: $btn-width;
|
||||
border-radius: 0 3px 3px 0;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
border: 1.4px solid $theme_color;
|
||||
width: 67.2px;
|
||||
height: 37.8px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
background-color: $theme_color;
|
||||
z-index: 1;
|
||||
background-color: #409EFF;
|
||||
color: #ffffff;
|
||||
transition: background 0.2s;
|
||||
|
||||
button {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
&:hover {
|
||||
background-color: #66b1ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-embedded.search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-box{
|
||||
margin-left: 28px;
|
||||
}
|
||||
|
||||
.search-type {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
padding-left: 18px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.search-type-item {
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: $theme_color;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: $theme_color;
|
||||
}
|
||||
}
|
||||
|
||||
.search-type + .search {
|
||||
margin-top: 0;
|
||||
}
|
||||
.logo-img {
|
||||
max-width: 150px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.store-search {
|
||||
width: 55.6px;
|
||||
padding: 0 9px;
|
||||
border-radius: 0;
|
||||
border-radius: 3px;
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 55px;
|
||||
margin-left: -2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-div {
|
||||
position: relative;
|
||||
height: 0px;
|
||||
top: -38px;
|
||||
left: 352px;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
|
||||
margin-left: 28px;
|
||||
}
|
||||
|
||||
@@ -254,4 +479,18 @@ export default {
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.suggest-panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
z-index: 30;
|
||||
padding: 8px 0;
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
150
buyer/src/components/SearchSuggest.vue
Normal file
150
buyer/src/components/SearchSuggest.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="keyword" class="suggest-section">
|
||||
<div v-if="suggesting && !suggestions.length" class="suggest-empty">正在查找...</div>
|
||||
<button
|
||||
v-for="item in suggestions"
|
||||
:key="item"
|
||||
type="button"
|
||||
class="suggest-item"
|
||||
@click="$emit('pick', item)"
|
||||
>
|
||||
<span v-for="(part, index) in parts(item)" :key="index" :class="{ hit: part.hit }">{{ part.text }}</span>
|
||||
</button>
|
||||
<div v-if="!suggesting && !suggestions.length" class="suggest-empty">没有匹配的商品,回车查看全部结果</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="history.length" class="suggest-section">
|
||||
<div class="suggest-head">
|
||||
<span>搜索历史</span>
|
||||
<button type="button" class="suggest-clear" @click="$emit('clear')">清空</button>
|
||||
</div>
|
||||
<div class="suggest-tags">
|
||||
<button
|
||||
v-for="item in history"
|
||||
:key="item"
|
||||
type="button"
|
||||
class="suggest-tag"
|
||||
@click="$emit('pick', item)"
|
||||
>{{ item }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hotWords.length" class="suggest-section">
|
||||
<div class="suggest-head"><span>热门搜索</span></div>
|
||||
<div class="suggest-tags">
|
||||
<button
|
||||
v-for="item in hotWords"
|
||||
:key="item"
|
||||
type="button"
|
||||
class="suggest-tag"
|
||||
@click="$emit('pick', item)"
|
||||
>{{ item }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineOptions({ name: "SearchSuggest" });
|
||||
|
||||
const props = defineProps({
|
||||
keyword: { type: String, default: "" },
|
||||
history: { type: Array, default: () => [] },
|
||||
hotWords: { type: Array, default: () => [] },
|
||||
suggestions: { type: Array, default: () => [] },
|
||||
suggesting: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(["pick", "clear"]);
|
||||
|
||||
function parts(text) {
|
||||
const value = String(text || "");
|
||||
const key = props.keyword;
|
||||
if (!key) return [{ text: value, hit: false }];
|
||||
const index = value.toLowerCase().indexOf(key.toLowerCase());
|
||||
if (index < 0) return [{ text: value, hit: false }];
|
||||
return [
|
||||
{ text: value.slice(0, index), hit: false },
|
||||
{ text: value.slice(index, index + key.length), hit: true },
|
||||
{ text: value.slice(index + key.length), hit: false },
|
||||
].filter((part) => part.text);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.suggest-section + .suggest-section {
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.suggest-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 16px;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.suggest-clear {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.suggest-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 16px 8px;
|
||||
}
|
||||
|
||||
.suggest-tag {
|
||||
max-width: 180px;
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggest-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.hit {
|
||||
color: $theme_color;
|
||||
}
|
||||
}
|
||||
|
||||
.suggest-empty {
|
||||
padding: 10px 16px;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,172 +1,177 @@
|
||||
<template>
|
||||
<div>
|
||||
<Modal v-model="showAddr" width="800" title="收件人地址">
|
||||
<Form
|
||||
<el-dialog v-model="showAddr" width="800" title="收件人地址">
|
||||
<el-form
|
||||
:model="formData"
|
||||
ref="form"
|
||||
ref="formRef"
|
||||
label-position="left"
|
||||
:label-width="100"
|
||||
label-width="100px"
|
||||
:rules="ruleInline"
|
||||
>
|
||||
<FormItem label="收件人" prop="name">
|
||||
<i-input v-model="formData.name" style="width: 600px"></i-input>
|
||||
</FormItem>
|
||||
<FormItem label="收件地区" prop="address">
|
||||
<el-form-item label="收件人" prop="name">
|
||||
<el-input v-model="formData.name" style="width: 600px"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="收件地区" prop="address">
|
||||
{{ formData.address || '暂无地址' }}
|
||||
<Button type="primary" style="margin-left: 10px;" size="small" @click="$refs.map.open()">选择</Button>
|
||||
</FormItem>
|
||||
<FormItem label="详细地址" prop="detail">
|
||||
<i-input v-model="formData.detail" style="width: 600px"></i-input>
|
||||
</FormItem>
|
||||
<FormItem label="手机号码" prop="mobile">
|
||||
<i-input v-model="formData.mobile" style="width: 600px"></i-input>
|
||||
</FormItem>
|
||||
<FormItem label="地址别名">
|
||||
<i-input
|
||||
<el-button type="primary" style="margin-left: 10px;" size="small" @click="mapRef.open()">选择</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="详细地址" prop="detail">
|
||||
<el-input v-model="formData.detail" style="width: 600px"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号码" prop="mobile">
|
||||
<el-input v-model="formData.mobile" style="width: 600px"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="地址别名">
|
||||
<el-input
|
||||
v-model="formData.alias"
|
||||
length
|
||||
:maxlength="4"
|
||||
:maxlength="5"
|
||||
show-word-limit
|
||||
placeholder="请输入地址别名,例如公司"
|
||||
style="width: 600px"
|
||||
></i-input>
|
||||
</FormItem>
|
||||
<FormItem label="默认地址">
|
||||
<i-switch v-model="formData.isDefault" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<div class="mt_20" slot="footer">
|
||||
<Button @click="hide">取消</Button>
|
||||
<Button type="primary" class="mr_10" :loading="loading" @click="save">保存收货地址</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
<multipleMap ref="map" @callback="getAddress"></multipleMap>
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="默认地址">
|
||||
<el-switch v-model="formData.isDefault" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><div class="mt_20">
|
||||
<el-button @click="hide">取消</el-button>
|
||||
<el-button type="primary" class="mr_10" :loading="loading" @click="save">保存收货地址</el-button>
|
||||
</div></template>
|
||||
</el-dialog>
|
||||
<multipleMap ref="mapRef" @callback="getAddress"></multipleMap>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { Message } from "@/utils/message";
|
||||
import multipleMap from "@/components/map/multiple-map";
|
||||
import {
|
||||
newMemberAddress,
|
||||
editMemberAddress,
|
||||
getAddrDetail
|
||||
} from '@/api/address';
|
||||
export default {
|
||||
name: 'addressManage',
|
||||
props: {
|
||||
id: { // 传入的地址id
|
||||
defalut: '',
|
||||
type: String
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
showAddr: false, // 控制模态框显隐
|
||||
formData: { // 表单数据
|
||||
isDefault: false
|
||||
},
|
||||
ruleInline: { // 验证规则
|
||||
name: [{ required: true, message: '请输入收件人姓名', trigger: 'blur' }],
|
||||
address: [{ required: true, message: '请输入地址', trigger: 'change' }],
|
||||
detail: [
|
||||
{ required: true, message: '请输入详细地址', trigger: 'blur' }
|
||||
],
|
||||
mobile: [
|
||||
{ required: true, message: '手机号不能为空', trigger: 'blur' },
|
||||
{
|
||||
type: 'string',
|
||||
pattern: /^1[3|4|5|6|7|8][0-9]{9}$/,
|
||||
message: '手机号格式出错',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
},
|
||||
loading: false, // 提交的加载状态
|
||||
mapMsg: {} // 地图信息
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
save () { // 保存地址
|
||||
this.$refs.form.validate((valid) => {
|
||||
if (valid) {
|
||||
const params = JSON.parse(JSON.stringify(this.formData));
|
||||
params.consigneeAddressPath = params.address.replace(/\s/g, ',');
|
||||
delete params.address;
|
||||
this.loading = true;
|
||||
if (this.id) {
|
||||
editMemberAddress(params).then((res) => {
|
||||
this.loading = false;
|
||||
if (res.success) {
|
||||
this.$Message.success('编辑地址成功');
|
||||
this.$emit('change', true);
|
||||
this.hide();
|
||||
}
|
||||
}).catch(() => { this.loading = false; });
|
||||
} else {
|
||||
newMemberAddress(params).then((res) => {
|
||||
this.loading = false;
|
||||
if (res.success) {
|
||||
this.$Message.success('新增地址成功');
|
||||
this.$emit('change', true);
|
||||
this.hide();
|
||||
}
|
||||
}).catch(() => { this.loading = false; });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
getAddrById (id) {
|
||||
// 获取地址详情
|
||||
getAddrDetail(id).then((res) => {
|
||||
if (res.success) {
|
||||
console.log(res);
|
||||
const data = res.result;
|
||||
data.address = res.result.consigneeAddressPath.replace(/,/g, ' ');
|
||||
this.formData = data;
|
||||
}
|
||||
});
|
||||
},
|
||||
getAddress (val) {
|
||||
// 获取地图选择信息
|
||||
if(val.type === 'select'){
|
||||
const paths = val.data.map(item => item.name).join(',')
|
||||
const ids = val.data.map(item => item.id).join(',')
|
||||
this.$set(this.formData,'address',paths)
|
||||
this.$set(this.formData,'consigneeAddressIdPath',ids)
|
||||
const coord = val.data[val.data.length - 1].center.split(',')
|
||||
this.formData.lat = coord[1]
|
||||
this.formData.lon = coord[0]
|
||||
}else{
|
||||
this.$set(this.formData, "address", val.data.addr);
|
||||
this.$set(this.formData, "consigneeAddressIdPath", val.data.addrId);
|
||||
this.$set(this.formData, "detail", val.data.address);
|
||||
this.formData.lat = val.data.position.lat;
|
||||
this.formData.lon = val.data.position.lng;
|
||||
}
|
||||
|
||||
},
|
||||
show () { // 地址模态框显示
|
||||
this.showAddr = true;
|
||||
},
|
||||
hide () { // 地址模态框隐藏
|
||||
this.showAddr = false;
|
||||
defineOptions({ name: 'addressManage' });
|
||||
|
||||
const props = defineProps({
|
||||
id: { // 传入的地址id
|
||||
defalut: '',
|
||||
type: String
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const showAddr = ref(false); // 控制模态框显隐
|
||||
const formData = ref({ // 表单数据
|
||||
isDefault: false
|
||||
});
|
||||
const ruleInline = { // 验证规则
|
||||
name: [{ required: true, message: '请输入收件人姓名', trigger: 'blur' }],
|
||||
address: [{ required: true, message: '请输入地址', trigger: 'change' }],
|
||||
detail: [
|
||||
{ required: true, message: '请输入详细地址', trigger: 'blur' }
|
||||
],
|
||||
mobile: [
|
||||
{ required: true, message: '手机号不能为空', trigger: 'blur' },
|
||||
{
|
||||
type: 'string',
|
||||
pattern: /^1[3|4|5|6|7|8][0-9]{9}$/,
|
||||
message: '手机号格式出错',
|
||||
trigger: 'blur'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id: { // 传入的地址id
|
||||
handler: function (v) {
|
||||
if (v) {
|
||||
this.getAddrById(v);
|
||||
} else {
|
||||
this.formData = {isDefault: false}
|
||||
this.$refs.form.resetFields();
|
||||
}
|
||||
]
|
||||
};
|
||||
const loading = ref(false); // 提交的加载状态
|
||||
const mapMsg = ref({}); // 地图信息
|
||||
|
||||
const formRef = ref(null);
|
||||
const mapRef = ref(null);
|
||||
|
||||
function save () { // 保存地址
|
||||
formRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
const params = JSON.parse(JSON.stringify(formData.value));
|
||||
params.consigneeAddressPath = params.address.replace(/\s/g, ',');
|
||||
delete params.address;
|
||||
loading.value = true;
|
||||
if (props.id) {
|
||||
editMemberAddress(params).then((res) => {
|
||||
loading.value = false;
|
||||
if (res.success) {
|
||||
Message.success('编辑地址成功');
|
||||
emit('change', true);
|
||||
hide();
|
||||
}
|
||||
}).catch(() => { loading.value = false; });
|
||||
} else {
|
||||
newMemberAddress(params).then((res) => {
|
||||
loading.value = false;
|
||||
if (res.success) {
|
||||
Message.success('新增地址成功');
|
||||
emit('change', true);
|
||||
hide();
|
||||
}
|
||||
}).catch(() => { loading.value = false; });
|
||||
}
|
||||
}
|
||||
},
|
||||
components: {
|
||||
multipleMap
|
||||
});
|
||||
}
|
||||
|
||||
function getAddrById (id) {
|
||||
// 获取地址详情
|
||||
getAddrDetail(id).then((res) => {
|
||||
if (res.success) {
|
||||
const data = res.result;
|
||||
data.address = res.result.consigneeAddressPath.replace(/,/g, ' ');
|
||||
formData.value = data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getAddress (val) {
|
||||
// 获取地图选择信息
|
||||
if(val.type === 'select'){
|
||||
const paths = val.data.map(item => item.name).join(',')
|
||||
const ids = val.data.map(item => item.id).join(',')
|
||||
formData.value['address'] = paths
|
||||
formData.value['consigneeAddressIdPath'] = ids
|
||||
const coord = val.data[val.data.length - 1].center.split(',')
|
||||
formData.value.lat = coord[1]
|
||||
formData.value.lon = coord[0]
|
||||
}else{
|
||||
formData.value["address"] = val.data.addr;
|
||||
formData.value["consigneeAddressIdPath"] = val.data.addrId;
|
||||
formData.value["detail"] = val.data.address;
|
||||
formData.value.lat = val.data.position.lat;
|
||||
formData.value.lon = val.data.position.lng;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
function show () { // 地址模态框显示
|
||||
showAddr.value = true;
|
||||
}
|
||||
|
||||
function hide () { // 地址模态框隐藏
|
||||
showAddr.value = false;
|
||||
}
|
||||
|
||||
defineExpose({ show, hide });
|
||||
|
||||
watch(
|
||||
() => props.id, // 传入的地址id
|
||||
(v) => {
|
||||
if (v) {
|
||||
getAddrById(v);
|
||||
} else {
|
||||
formData.value = {isDefault: false}
|
||||
formRef.value.resetFields();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
<template>
|
||||
<!-- 头部广告 -->
|
||||
<div class="advertising" v-if="show" :style="{'background-color': data.bgColor}">
|
||||
<div class="advertising" v-if="show && data" :style="{'background-color': data.bgColor}">
|
||||
<img :src="data.img" class="hover-pointer" @click="linkTo(data.url)"/>
|
||||
<Icon type="md-close-circle" size="20" @click="show = false" />
|
||||
<el-icon :size="20" @click="show = false"><CircleClose /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
data: { // 传入的广告信息
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
show: true // 是否显示头部广告
|
||||
}
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { CircleClose } from '@element-plus/icons-vue';
|
||||
|
||||
defineProps({
|
||||
data: { // 传入的广告信息
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const show = ref(true); // 是否显示头部广告
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.advertising {
|
||||
|
||||
@@ -1,148 +1,159 @@
|
||||
<template>
|
||||
<Card class="_Card" :bordered="false" :dis-hover="true">
|
||||
<div slot="title" class="cardTitle">
|
||||
<span :style="{fontSize:`${_Size}px`}">{{_Title}}</span>
|
||||
|
||||
<div v-if="_Tabs" class="cardTabs">
|
||||
<div @click="tabsChange(index)" :class="{active:(isActive==index)}" class="cardTabsItem" :style="{fontSize:`${_Size-2}px`}" v-for="(item,index) in _Tabs"
|
||||
:key="index">
|
||||
{{item}}
|
||||
<el-card class="_Card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="cardTitle">
|
||||
<span :style="{ fontSize: `${_Size}px` }">{{ _Title }}</span>
|
||||
<div v-if="_Tabs" class="cardTabs">
|
||||
<div
|
||||
@click="tabsChange(index)"
|
||||
:class="{ active: isActive == index }"
|
||||
class="cardTabsItem"
|
||||
:style="{ fontSize: `${_Size - 2}px` }"
|
||||
v-for="(item, index) in _Tabs"
|
||||
:key="index"
|
||||
>
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardExtra" v-if="_More" @click="callBack()">
|
||||
{{ _More }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div slot="extra" class="cardExtra" v-if="_More" @click="callBack()">
|
||||
{{_More}}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
<slot />
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'index',
|
||||
props:
|
||||
{
|
||||
_Tabs: { // 可点击的tab栏
|
||||
type: null,
|
||||
default: ''
|
||||
},
|
||||
// 头部
|
||||
_Title: { // 标题
|
||||
type: null,
|
||||
default: '卡片头部'
|
||||
},
|
||||
// 右侧更多
|
||||
_More: {
|
||||
type: null,
|
||||
default: false
|
||||
},
|
||||
_Size: { // 文字大小
|
||||
type: Number,
|
||||
default: 16
|
||||
},
|
||||
// 点击更多触发跳转
|
||||
_Src: {
|
||||
type: null,
|
||||
default: function (val) {
|
||||
if (this._More) {
|
||||
return val;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isActive: 0 // 已激活tab栏下标
|
||||
};
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
defineOptions({ name: "index" });
|
||||
|
||||
const props = defineProps({
|
||||
_Tabs: {
|
||||
type: null,
|
||||
default: "",
|
||||
},
|
||||
methods: {
|
||||
// 点击右侧的回调
|
||||
callBack () {
|
||||
let _this = this;
|
||||
if (this._Src !== '' || this._Src != null) {
|
||||
this.$router.push({
|
||||
path: _this._Src
|
||||
});
|
||||
}
|
||||
},
|
||||
// 点击tab的回调
|
||||
tabsChange (index) {
|
||||
// 处理并返回index
|
||||
this.isActive = index;
|
||||
this.$emit('_Change', index);
|
||||
}
|
||||
_Title: {
|
||||
type: null,
|
||||
default: "卡片头部",
|
||||
},
|
||||
_More: {
|
||||
type: null,
|
||||
default: false,
|
||||
},
|
||||
_Size: {
|
||||
type: Number,
|
||||
default: 16,
|
||||
},
|
||||
_Src: {
|
||||
type: null,
|
||||
default: null,
|
||||
},
|
||||
_ActiveTab: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["_Change"]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isActive = ref(props._ActiveTab);
|
||||
|
||||
watch(
|
||||
() => props._ActiveTab,
|
||||
(val) => {
|
||||
isActive.value = val;
|
||||
}
|
||||
};
|
||||
);
|
||||
|
||||
function callBack() {
|
||||
if (props._Src) {
|
||||
router.push({ path: props._Src });
|
||||
}
|
||||
}
|
||||
|
||||
function tabsChange(index) {
|
||||
isActive.value = index;
|
||||
emit("_Change", index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cardTitle {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
.active{
|
||||
color: $theme_color;
|
||||
position: relative;
|
||||
&::before{
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: $theme_color;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.cardTitle {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
.active {
|
||||
color: $theme_color;
|
||||
position: relative;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: $theme_color;
|
||||
}
|
||||
}
|
||||
|
||||
.cardTabs {
|
||||
display: flex;
|
||||
.cardTabs {
|
||||
display: flex;
|
||||
padding: 0 12px;
|
||||
|
||||
> .cardTabsItem {
|
||||
padding: 0 12px;
|
||||
|
||||
> .cardTabsItem {
|
||||
padding: 0 12px;
|
||||
|
||||
}
|
||||
|
||||
> .cardTabsItem:hover {
|
||||
|
||||
color: $theme_color;
|
||||
}
|
||||
}
|
||||
|
||||
/deep/ .ivu-card, .ivu-card-head, ._Card {
|
||||
margin-bottom: 20px;
|
||||
@include white_background_color();
|
||||
}
|
||||
|
||||
/deep/ .ivu-card-head {
|
||||
position: relative;
|
||||
padding: 0 14px;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 50%;
|
||||
top: 25%;
|
||||
background: $theme_color;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cardExtra {
|
||||
> .cardTabsItem:hover {
|
||||
color: $theme_color;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/deep/ .ivu-card-body {
|
||||
padding: 0 !important;
|
||||
display: none;
|
||||
:deep(._Card) {
|
||||
margin-bottom: 20px;
|
||||
@include white_background_color();
|
||||
}
|
||||
|
||||
:deep(._Card .el-card__header) {
|
||||
position: relative;
|
||||
padding: 0 14px;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 3px;
|
||||
height: 50%;
|
||||
top: 25%;
|
||||
background: $theme_color;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cardExtra {
|
||||
color: $theme_color;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(._Card .el-card__body) {
|
||||
padding: 0 !important;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(._Card .el-card__body:empty) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
<ul>
|
||||
<li v-for="(goods,goodsIndex) in cartList" :key="goodsIndex">
|
||||
<div>
|
||||
<img :src="goods.goodsSku.thumbnail" width="90" height="90" alt="">
|
||||
<img :src="goods.goodsSku.thumbnail" loading="lazy" decoding="async" width="90" height="90" alt="">
|
||||
</div>
|
||||
<div>
|
||||
<p class="hover-color" @click="linkTo(`/goodsDetail?skuId=${goods.goodsSku.id}&goodsId=${goods.goodsSku.goodsId}`)">{{goods.goodsSku.goodsName}}</p>
|
||||
<p class="price">{{goods.goodsSku.price | unitPrice('¥')}}<span> x{{goods.num}}</span></p>
|
||||
<p class="price">{{ $filters.unitPrice(goods.goodsSku.price, '¥') }}<span> x{{goods.num}}</span></p>
|
||||
</div>
|
||||
<span class="del hover-color" @click="delGoods(goods.goodsSku.id)">删除</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Button size="large" class="mt_10" type="primary" @click="linkTo('/cart')" long>去购物车结算</Button>
|
||||
<el-button size="large" class="mt_10" type="primary" @click="linkTo('/cart')" style="width:100%">去购物车结算</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="title === '我的订单'" class="order-con">
|
||||
@@ -21,15 +21,15 @@
|
||||
<li v-for="(order,orderIndex) in orderList" :key="orderIndex">
|
||||
<div class="order-status"><span>{{filterOrderStatus(order.orderStatus)}}</span><span>{{order.createTime}}</span></div>
|
||||
<div class="goods-img">
|
||||
<img :src="img.image"
|
||||
<img :src="img.image" loading="lazy" decoding="async"
|
||||
@click="linkTo(`/goodsDetail?skuId=${img.skuId}&goodsId=${img.goodsId}`)"
|
||||
v-for="(img,imgIndex) in order.orderItems"
|
||||
:key="imgIndex" width="40" height="40" alt="">
|
||||
</div>
|
||||
<div class="order-handle"><span>{{ order.flowPrice | unitPrice("¥") }}</span><span class="hover-color" @click="linkTo(`home/OrderDetail?sn=${order.sn}`)">查看订单</span></div>
|
||||
<div class="order-handle"><span>{{ $filters.unitPrice(order.flowPrice, "¥") }}</span><span class="hover-color" @click="linkTo(`/home/OrderDetail?sn=${order.sn}`)">查看订单</span></div>
|
||||
</li>
|
||||
</ul>
|
||||
<Button type="primary" @click="linkTo('/home/MyOrder')" long>查看全部订单</Button>
|
||||
<el-button type="primary" @click="linkTo('/home/MyOrder')" style="width:100%">查看全部订单</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="title === '优惠券'" class="coupon-con">
|
||||
@@ -37,7 +37,7 @@
|
||||
<li v-for="(coupon, index) in couponList" class="coupon-item" :key="index">
|
||||
<div class="c-left">
|
||||
<div>
|
||||
<span v-if="coupon.couponType === 'PRICE'" class="fontsize_12 global_color">¥<span class="price">{{coupon.price | unitPrice}}</span></span>
|
||||
<span v-if="coupon.couponType === 'PRICE'" class="fontsize_12 global_color">¥<span class="price">{{ $filters.unitPrice(coupon.price) }}</span></span>
|
||||
<span v-if="coupon.couponType === 'DISCOUNT'" class="fontsize_12 global_color"><span class="price">{{coupon.couponDiscount}}</span>折</span>
|
||||
<span class="describe">满{{coupon.consumeThreshold}}元可用</span>
|
||||
</div>
|
||||
@@ -54,9 +54,9 @@
|
||||
<div v-else-if="title === '我的足迹'" class="tracks-con">
|
||||
<ul>
|
||||
<li v-for="(track,trackIndex) in tracksList" :key="trackIndex">
|
||||
<img :src="track.thumbnail" :alt="track.thumbnail" @click="linkTo(`/goodsDetail?skuId=${track.id}&goodsId=${track.goodsId}`)" width="100" height="100">
|
||||
<img :src="track.thumbnail" loading="lazy" decoding="async" :alt="track.thumbnail" @click="linkTo(`/goodsDetail?skuId=${track.id}&goodsId=${track.goodsId}`)" width="100" height="100">
|
||||
<div @click="addToCart(track.id)">加入购物车</div>
|
||||
<p class="global_color">{{track.price | unitPrice('¥')}}</p>
|
||||
<p class="global_color">{{ $filters.unitPrice(track.price, '¥') }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="hover-color" style="text-align:center;" @click="linkTo('/home/MyTracks')">查看更多>></div>
|
||||
@@ -64,238 +64,242 @@
|
||||
<div v-else-if="title === '我的收藏'" class="collect-con">
|
||||
<ul>
|
||||
<li v-for="(collect,collectIndex) in collectList" :key="collectIndex">
|
||||
<img :src="collect.image" :alt="collect.image" @click="linkTo(`/goodsDetail?skuId=${collect.skuId}&goodsId=${collect.goodsId}`)" width="100" height="100">
|
||||
<img :src="collect.image" loading="lazy" decoding="async" :alt="collect.image" @click="linkTo(`/goodsDetail?skuId=${collect.skuId}&goodsId=${collect.goodsId}`)" width="100" height="100">
|
||||
<div @click="addToCart(collect.skuId)">加入购物车</div>
|
||||
<span class="del-icon" @click.stop="cancelCollect(collect.skuId)">
|
||||
<Icon type="md-trash" />
|
||||
<el-icon><Delete /></el-icon>
|
||||
</span>
|
||||
<p class="global_color">{{collect.price | unitPrice('¥')}}</p>
|
||||
<p class="global_color">{{ $filters.unitPrice(collect.price, '¥') }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="hover-color" style="text-align:center;" @click="linkTo('/home/Favorites')">查看更多>></div>
|
||||
</div>
|
||||
<Spin v-if="loading" fix></Spin>
|
||||
<el-skeleton v-if="loading" fix></el-skeleton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
import { Delete } from '@element-plus/icons-vue';
|
||||
import {cartGoodsAll, delCartGoods, addCartGoods, cartCount} from '@/api/cart.js'
|
||||
import { getOrderList } from '@/api/order';
|
||||
import {couponList, receiveCoupon, tracksList, collectList, cancelCollect} from '@/api/member.js'
|
||||
export default {
|
||||
name: 'Drawer',
|
||||
props: {
|
||||
title: {
|
||||
default: '',
|
||||
type: String
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
title (val) {
|
||||
switch (val) {
|
||||
case '购物车':
|
||||
this.getCartList()
|
||||
break;
|
||||
case '我的订单':
|
||||
this.getOrderList()
|
||||
break;
|
||||
case '我的足迹':
|
||||
this.getTracksList()
|
||||
break;
|
||||
case '优惠券':
|
||||
this.getCouponList()
|
||||
break;
|
||||
case '我的收藏':
|
||||
this.getCollectList()
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: false, // 控制spin显隐
|
||||
cartList: [], // 购物车列表
|
||||
couponList: [], // 优惠券列表
|
||||
orderList: [], // 订单列表
|
||||
collectList: [], // 收藏列表
|
||||
tracksList: [], // 足迹列表
|
||||
orderStatusList: [ // 订单状态
|
||||
{
|
||||
name: '未付款',
|
||||
status: 'UNPAID'
|
||||
},
|
||||
{
|
||||
name: '已付款',
|
||||
status: 'PAID'
|
||||
},
|
||||
{
|
||||
name: '待发货',
|
||||
status: 'UNDELIVERED'
|
||||
},
|
||||
{
|
||||
name: '已发货',
|
||||
status: 'DELIVERED'
|
||||
},
|
||||
{
|
||||
name: '已完成',
|
||||
status: 'COMPLETED'
|
||||
},
|
||||
{
|
||||
name: '待核验',
|
||||
status: 'TAKE'
|
||||
},
|
||||
{
|
||||
name: '已取消',
|
||||
status: 'CANCELLED'
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getCartList () { // 获取购物车列表
|
||||
this.loading = true
|
||||
cartGoodsAll().then(res => {
|
||||
this.loading = false
|
||||
this.cartList = res.result.skuList
|
||||
})
|
||||
},
|
||||
// 删除商品
|
||||
delGoods (id) {
|
||||
delCartGoods({ skuIds: id }).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success('删除成功');
|
||||
this.getCartList();
|
||||
cartCount().then(res => {
|
||||
this.$store.commit('SET_CARTNUM', res.result)
|
||||
this.Cookies.setItem('cartNum', res.result)
|
||||
})
|
||||
} else {
|
||||
this.$Message.error(res.message);
|
||||
}
|
||||
});
|
||||
},
|
||||
filterOrderStatus (status) { // 获取订单状态中文
|
||||
const ob = this.orderStatusList.filter(e => { return e.status === status });
|
||||
return ob[0].name
|
||||
},
|
||||
receive (item) { // 领取优惠券
|
||||
receiveCoupon(item.id).then(res => {
|
||||
if (res.success) {
|
||||
this.$Modal.confirm({
|
||||
title: '领取优惠券',
|
||||
content: '<p>优惠券领取成功,可到我的优惠券页面查看</p>',
|
||||
okText: '我的优惠券',
|
||||
cancelText: '立即使用',
|
||||
onOk: () => {
|
||||
this.$router.push('/home/Coupons')
|
||||
},
|
||||
onCancel: () => {
|
||||
if (item.storeId !== '0') {
|
||||
this.$router.push({path: '/merchant', query: {id: item.storeId}})
|
||||
} else {
|
||||
if (item.scopeType === 'PORTION_GOODS_CATEGORY') {
|
||||
this.$router.push({path: '/goodsList', query: {categoryId: item.scopeId}})
|
||||
} else {
|
||||
this.$router.push({path: '/goodsList'})
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
},
|
||||
useScope (type, storeName) { // 判断优惠券使用范围
|
||||
let shop = '平台';
|
||||
let goods = '全部商品'
|
||||
if (storeName !== 'platform') shop = storeName
|
||||
switch (type) {
|
||||
case 'ALL':
|
||||
goods = '全部商品'
|
||||
break;
|
||||
case 'PORTION_GOODS':
|
||||
goods = '部分商品'
|
||||
break;
|
||||
case 'PORTION_GOODS_CATEGORY':
|
||||
goods = '部分分类商品'
|
||||
break;
|
||||
}
|
||||
return `${shop}${goods}可用`
|
||||
},
|
||||
addToCart (id) { // 添加商品到购物车
|
||||
const params = {
|
||||
num: 1,
|
||||
skuId: id
|
||||
}
|
||||
this.loading = true;
|
||||
addCartGoods(params).then(res => {
|
||||
this.loading = false;
|
||||
if (res.success) {
|
||||
this.$Message.success('商品已成功添加到购物车')
|
||||
} else {
|
||||
this.$Message.warning(res.message);
|
||||
}
|
||||
}).catch(() => { this.loading = false });
|
||||
},
|
||||
getCouponList () { // 获取优惠券列表
|
||||
// this.loading = true;
|
||||
const params = {
|
||||
pageNumber: 1,
|
||||
pageSize: 10
|
||||
}
|
||||
couponList(params).then(res => {
|
||||
this.loading = false
|
||||
if (res.success) {
|
||||
this.couponList = res.result.records
|
||||
}
|
||||
}).catch(() => { this.loading = false })
|
||||
},
|
||||
getOrderList () { // 获取订单列表
|
||||
this.loading = true
|
||||
const params = {
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
tag: 'ALL'
|
||||
}
|
||||
getOrderList(params).then(res => {
|
||||
this.loading = false
|
||||
if (res.success) {
|
||||
this.orderList = res.result.records;
|
||||
}
|
||||
});
|
||||
},
|
||||
getCollectList () { // 获取收藏列表
|
||||
const params = {
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
type: 'GOODS'
|
||||
}
|
||||
this.loading = true
|
||||
collectList(params).then(res => {
|
||||
this.loading = false
|
||||
this.collectList = res.result.records
|
||||
})
|
||||
},
|
||||
cancelCollect (id) { // 取消商品收藏
|
||||
cancelCollect('GOODS', id).then(res => {
|
||||
if (res.success) {
|
||||
this.$Message.success('取消收藏成功')
|
||||
this.getCollectList();
|
||||
}
|
||||
})
|
||||
},
|
||||
getTracksList () { // 获取足迹列表
|
||||
const params = {
|
||||
pageNumber: 1,
|
||||
pageSize: 20
|
||||
}
|
||||
this.loading = true
|
||||
tracksList(params).then(res => {
|
||||
this.tracksList = res.result.records
|
||||
this.loading = false
|
||||
}).catch(() => { this.loading = false })
|
||||
}
|
||||
import { getOrderList as fetchOrderList } from '@/api/order';
|
||||
import {couponList as fetchCouponList, receiveCoupon, tracksList as fetchTracksList, collectList as fetchCollectList, cancelCollect as apiCancelCollect} from '@/api/member.js'
|
||||
import storage from '@/plugins/storage.js'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
defineOptions({ name: 'Drawer' })
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
default: '',
|
||||
type: String
|
||||
}
|
||||
};
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false) // 控制spin显隐
|
||||
const cartList = ref([]) // 购物车列表
|
||||
const couponList = ref([]) // 优惠券列表
|
||||
const orderList = ref([]) // 订单列表
|
||||
const collectList = ref([]) // 收藏列表
|
||||
const tracksList = ref([]) // 足迹列表
|
||||
const orderStatusList = ref([ // 订单状态
|
||||
{
|
||||
name: '未付款',
|
||||
status: 'UNPAID'
|
||||
},
|
||||
{
|
||||
name: '已付款',
|
||||
status: 'PAID'
|
||||
},
|
||||
{
|
||||
name: '待发货',
|
||||
status: 'UNDELIVERED'
|
||||
},
|
||||
{
|
||||
name: '已发货',
|
||||
status: 'DELIVERED'
|
||||
},
|
||||
{
|
||||
name: '已完成',
|
||||
status: 'COMPLETED'
|
||||
},
|
||||
{
|
||||
name: '待核验',
|
||||
status: 'TAKE'
|
||||
},
|
||||
{
|
||||
name: '已取消',
|
||||
status: 'CANCELLED'
|
||||
}
|
||||
])
|
||||
|
||||
watch(() => props.title, (val) => {
|
||||
switch (val) {
|
||||
case '购物车':
|
||||
getCartList()
|
||||
break;
|
||||
case '我的订单':
|
||||
getOrderList()
|
||||
break;
|
||||
case '我的足迹':
|
||||
getTracksList()
|
||||
break;
|
||||
case '优惠券':
|
||||
getCouponList()
|
||||
break;
|
||||
case '我的收藏':
|
||||
getCollectList()
|
||||
break;
|
||||
}
|
||||
})
|
||||
|
||||
function getCartList () { // 获取购物车列表
|
||||
loading.value = true
|
||||
cartGoodsAll().then(res => {
|
||||
loading.value = false
|
||||
cartList.value = res.result.skuList
|
||||
})
|
||||
}
|
||||
// 删除商品
|
||||
function delGoods (id) {
|
||||
delCartGoods({ skuIds: id }).then((res) => {
|
||||
if (res.success) {
|
||||
Message.success('删除成功');
|
||||
getCartList();
|
||||
cartCount().then(res => {
|
||||
appStore.setCartNum(res.result)
|
||||
storage.setItem('cartNum', res.result)
|
||||
})
|
||||
} else {
|
||||
Message.error(res.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
function filterOrderStatus (status) { // 获取订单状态中文
|
||||
const ob = orderStatusList.value.filter(e => { return e.status === status });
|
||||
return ob[0].name
|
||||
}
|
||||
function receive (item) { // 领取优惠券
|
||||
receiveCoupon(item.id).then(res => {
|
||||
if (res.success) {
|
||||
Modal.confirm({
|
||||
title: '领取优惠券',
|
||||
content: '<p>优惠券领取成功,可到我的优惠券页面查看</p>',
|
||||
okText: '我的优惠券',
|
||||
cancelText: '立即使用',
|
||||
onOk: () => {
|
||||
router.push('/home/Coupons')
|
||||
},
|
||||
onCancel: () => {
|
||||
if (item.storeId !== '0') {
|
||||
router.push({path: '/merchant', query: {id: item.storeId}})
|
||||
} else {
|
||||
if (item.scopeType === 'PORTION_GOODS_CATEGORY') {
|
||||
router.push({path: '/goodsList', query: {categoryId: item.scopeId}})
|
||||
} else {
|
||||
router.push({path: '/goodsList'})
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
function useScope (type, storeName) { // 判断优惠券使用范围
|
||||
let shop = '平台';
|
||||
let goods = '全部商品'
|
||||
if (storeName !== 'platform') shop = storeName
|
||||
switch (type) {
|
||||
case 'ALL':
|
||||
goods = '全部商品'
|
||||
break;
|
||||
case 'PORTION_GOODS':
|
||||
goods = '部分商品'
|
||||
break;
|
||||
case 'PORTION_GOODS_CATEGORY':
|
||||
goods = '部分分类商品'
|
||||
break;
|
||||
}
|
||||
return `${shop}${goods}可用`
|
||||
}
|
||||
function addToCart (id) { // 添加商品到购物车
|
||||
const params = {
|
||||
num: 1,
|
||||
skuId: id
|
||||
}
|
||||
loading.value = true;
|
||||
addCartGoods(params).then(res => {
|
||||
loading.value = false;
|
||||
if (res.success) {
|
||||
Message.success('商品已成功添加到购物车')
|
||||
} else {
|
||||
Message.warning(res.message);
|
||||
}
|
||||
}).catch(() => { loading.value = false });
|
||||
}
|
||||
function getCouponList () { // 获取优惠券列表
|
||||
// loading.value = true;
|
||||
const params = {
|
||||
pageNumber: 1,
|
||||
pageSize: 10
|
||||
}
|
||||
fetchCouponList(params).then(res => {
|
||||
loading.value = false
|
||||
if (res.success) {
|
||||
couponList.value = res.result.records
|
||||
}
|
||||
}).catch(() => { loading.value = false })
|
||||
}
|
||||
function getOrderList () { // 获取订单列表
|
||||
loading.value = true
|
||||
const params = {
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
tag: 'ALL'
|
||||
}
|
||||
fetchOrderList(params).then(res => {
|
||||
loading.value = false
|
||||
if (res.success) {
|
||||
orderList.value = res.result.records;
|
||||
}
|
||||
});
|
||||
}
|
||||
function getCollectList () { // 获取收藏列表
|
||||
const params = {
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
type: 'GOODS'
|
||||
}
|
||||
loading.value = true
|
||||
fetchCollectList(params).then(res => {
|
||||
loading.value = false
|
||||
collectList.value = res.result.records
|
||||
})
|
||||
}
|
||||
function cancelCollect (id) { // 取消商品收藏
|
||||
apiCancelCollect('GOODS', id).then(res => {
|
||||
if (res.success) {
|
||||
Message.success('取消收藏成功')
|
||||
getCollectList();
|
||||
}
|
||||
})
|
||||
}
|
||||
function getTracksList () { // 获取足迹列表
|
||||
const params = {
|
||||
pageNumber: 1,
|
||||
pageSize: 20
|
||||
}
|
||||
loading.value = true
|
||||
fetchTracksList(params).then(res => {
|
||||
tracksList.value = res.result.records
|
||||
loading.value = false
|
||||
}).catch(() => { loading.value = false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -3,102 +3,99 @@
|
||||
<div class="wrapper" :style="{right:handleDrawer ? '300px' : '0px'}">
|
||||
|
||||
<div class="barItem" @mouseenter="showCartNum(item)" @click="clickBar(item)" v-for="(item,index) in resetConfig.menuList" :key="index">
|
||||
<Tooltip placement="left" :content="item.title">
|
||||
<Icon size="20" :type="item.icon"/>
|
||||
<el-tooltip placement="left" :content="item.title">
|
||||
<el-icon :size="20"><component :is="resolveIcon(item.icon)" /></el-icon>
|
||||
<p class="barTitle" v-if="item.titleShow"> {{item.title}}</p>
|
||||
<div class="circle" v-if="item.title === '购物车'">
|
||||
{{cartNum < 100 ? cartNum : 99}}
|
||||
{{ Number(cartNum) > 99 ? '99+' : cartNum }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<Drawer width="300" class="popup" :title="drawerData.title" :mask="resetConfig.mask" :closable="resetConfig.closable"
|
||||
<el-drawer width="300" class="popup" :title="drawerData.title" :modal="resetConfig.mask" :show-close="resetConfig.closable"
|
||||
v-model="handleDrawer">
|
||||
<drawerPage :title="drawerData.title" />
|
||||
</Drawer>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Storage from '@/plugins/storage.js';
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Modal } from "@/utils/message";
|
||||
import storage from '@/plugins/storage.js';
|
||||
import Configuration from './config';
|
||||
import drawerPage from './Drawer'
|
||||
import {cartCount} from '@/api/cart.js'
|
||||
export default {
|
||||
name: 'Main',
|
||||
data () {
|
||||
return {
|
||||
resetConfig: Configuration, // 菜单项
|
||||
handleDrawer: false, // 是否可展开
|
||||
drawerData: '' // 菜单基础数据
|
||||
}
|
||||
},
|
||||
components: {drawerPage},
|
||||
computed: {
|
||||
// 用户信息
|
||||
userInfo () {
|
||||
return Storage.getItem('userInfo');
|
||||
},
|
||||
// 购物车商品数量
|
||||
cartNum () {
|
||||
return this.$store.state.cartNum
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showCartNum (item) { // 获取购物车数量
|
||||
if (this.userInfo && item.title === '购物车') {
|
||||
this.getCartList()
|
||||
}
|
||||
},
|
||||
clickBar (val) { // tabbar点击操作
|
||||
if (!this.userInfo) {
|
||||
this.$Modal.confirm({
|
||||
title: '请登录',
|
||||
content: '<p>请登录后执行此操作</p>',
|
||||
okText: '立即登录',
|
||||
cancelText: '继续浏览',
|
||||
onOk: () => {
|
||||
this.$router.push({
|
||||
path: '/login',
|
||||
query: {
|
||||
rePath: this.$router.history.current.path,
|
||||
query: JSON.stringify(this.$router.history.current.query)
|
||||
}
|
||||
});
|
||||
}
|
||||
import { resolveIcon } from '@/utils/iconMap'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
defineOptions({ name: 'Main' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const resetConfig = ref(Configuration) // 菜单项
|
||||
const handleDrawer = ref(false) // 是否可展开
|
||||
const drawerData = ref('') // 菜单基础数据
|
||||
|
||||
// 用户信息
|
||||
const userInfo = computed(() => {
|
||||
return storage.getItem('userInfo');
|
||||
})
|
||||
|
||||
// 购物车商品数量
|
||||
const cartNum = computed(() => appStore.cartNum)
|
||||
|
||||
function showCartNum (item) { // 获取购物车数量
|
||||
if (userInfo.value && item.title === '购物车') {
|
||||
getCartList()
|
||||
}
|
||||
}
|
||||
|
||||
function clickBar (val) { // tabbar点击操作
|
||||
if (!userInfo.value) {
|
||||
Modal.confirm({
|
||||
title: '请登录',
|
||||
content: '请登录后执行此操作',
|
||||
okText: '立即登录',
|
||||
cancelText: '继续浏览',
|
||||
onOk: () => {
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: {
|
||||
rePath: route.path,
|
||||
query: JSON.stringify(route.query || {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (val.display) {
|
||||
this.handleDrawer = true
|
||||
this.drawerData = val
|
||||
} else {
|
||||
this.handleDrawer = false
|
||||
switch (val.title) {
|
||||
case '会员中心':
|
||||
this.openBlank('/home')
|
||||
break;
|
||||
case '我的资产':
|
||||
this.openBlank('/home/MoneyManagement')
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
openBlank (path) { // 新页面打开地址
|
||||
let routerUrl = this.$router.resolve({
|
||||
path: path
|
||||
})
|
||||
window.open(routerUrl.href, '_blank')
|
||||
},
|
||||
getCartList () { // 获取购物车列表
|
||||
cartCount().then(res => {
|
||||
this.$store.commit('SET_CARTNUM', res.result)
|
||||
this.Cookies.setItem('cartNum', res.result)
|
||||
})
|
||||
});
|
||||
} else {
|
||||
if (val.display) {
|
||||
handleDrawer.value = true
|
||||
drawerData.value = val
|
||||
} else {
|
||||
handleDrawer.value = false
|
||||
switch (val.title) {
|
||||
case '会员中心':
|
||||
router.push('/home')
|
||||
break;
|
||||
case '我的资产':
|
||||
router.push('/home/MoneyManagement')
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCartList () { // 获取购物车列表
|
||||
cartCount().then(res => {
|
||||
appStore.setCartNum(res.result)
|
||||
storage.setItem('cartNum', res.result)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -128,11 +125,15 @@ export default {
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
padding: 0 4px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background: $theme_color;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
@@ -147,11 +148,11 @@ export default {
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
/deep/.popup .ivu-drawer-body{
|
||||
padding: 0!important;
|
||||
:deep(.popup .el-drawer__body) {
|
||||
padding: 0 !important;
|
||||
background-color: #eee;
|
||||
}
|
||||
/deep/.popup .ivu-drawer-wrap{
|
||||
:deep(.popup) {
|
||||
z-index: 3001;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,42 +1,30 @@
|
||||
# 右侧侧边栏组件
|
||||
> 本组件依赖于iview的组件基础上进行封装
|
||||
>项目结构
|
||||
>* Main -- 组件用于挂在右侧的横栏
|
||||
>* drawer -- 右侧横栏的内容
|
||||
>* config -- 用于设置大小
|
||||
|
||||
基于 Element Plus `el-drawer` 封装的右侧悬浮栏。
|
||||
|
||||
#### config设置
|
||||
```
|
||||
## 项目结构
|
||||
|
||||
- `Main.vue` — 挂载右侧横栏的容器
|
||||
- `Drawer.vue` — 横栏展开后的内容面板
|
||||
- `config.js` — 尺寸与菜单配置
|
||||
|
||||
## config 设置
|
||||
|
||||
```js
|
||||
/**
|
||||
menuList // 组件的menu
|
||||
display //是否显示此menu
|
||||
badge //显示徽标数
|
||||
titleShow //是否显示title
|
||||
*/
|
||||
|
||||
//实例代码
|
||||
width : 50, //bar的大小
|
||||
menuList:[
|
||||
{
|
||||
icon
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
* menuList 菜单项列表
|
||||
* display 是否显示该项
|
||||
* badge 徽标数字
|
||||
* titleShow 是否显示标题
|
||||
*/
|
||||
width: 50,
|
||||
menuList: [{ icon: '...' }]
|
||||
```
|
||||
|
||||
## 功能模块
|
||||
|
||||
账户信息、购物车、我的订单、优惠券、我的资产、我的足迹、我的收藏、邮箱订阅
|
||||
|
||||
> 账户信息
|
||||
> 购物车
|
||||
> 我的订单
|
||||
> 优惠券
|
||||
> 我的资产
|
||||
> 我的足迹
|
||||
> 我的收藏
|
||||
> 邮箱订阅
|
||||
## 使用
|
||||
|
||||
|
||||
## 如何使用
|
||||
1.
|
||||
在页面中注册全局组件 `<drawer />` 即可(已在 `global.js` 中注册)。
|
||||
|
||||
@@ -11,20 +11,19 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Main',
|
||||
props: {
|
||||
_Title: { // 描述内容
|
||||
type: null,
|
||||
default: '暂无更多'
|
||||
},
|
||||
_Size: { // 图片大小
|
||||
type: Number,
|
||||
default: 150
|
||||
}
|
||||
<script setup>
|
||||
defineOptions({ name: 'Main' })
|
||||
|
||||
defineProps({
|
||||
_Title: { // 描述内容
|
||||
type: null,
|
||||
default: '暂无更多'
|
||||
},
|
||||
_Size: { // 图片大小
|
||||
type: Number,
|
||||
default: 150
|
||||
}
|
||||
};
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,81 +1,111 @@
|
||||
<template>
|
||||
<div class="shadow-box">
|
||||
<div class="shadow-item flex flex-a-c flex-j-c" v-for="(item,index) in iconList" @click="handleClickIcon(item)" :key="index">
|
||||
<img class="icon" :src="require(`@/assets/iconfont/${item.icon}.png`)">
|
||||
<img class="icon" :src="getIconUrl(item.icon)">
|
||||
<div class="shadow-label">{{item.label}}</div>
|
||||
<div class="line" v-if="iconList.length-1 !=index"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "fixed-index",
|
||||
data(){
|
||||
return{
|
||||
iconList:[
|
||||
{
|
||||
icon:"user",
|
||||
label:"会员中心",
|
||||
path:"/home"
|
||||
},
|
||||
{
|
||||
icon:"carts",
|
||||
label:"购物车",
|
||||
path:"/cart"
|
||||
},
|
||||
{
|
||||
icon:"notification",
|
||||
label:"消息",
|
||||
path:"/home/MsgList"
|
||||
},
|
||||
{
|
||||
icon:"collage",
|
||||
label:"收藏",
|
||||
path:"/home/Favorites"
|
||||
},
|
||||
{
|
||||
icon:"back",
|
||||
label:"顶部",
|
||||
path:"back"
|
||||
},
|
||||
],
|
||||
isScrolling:false,
|
||||
timeoutId:"",
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { getIconUrl } from "@/assets/iconfont/iconMap";
|
||||
import storage from "@/plugins/storage.js";
|
||||
|
||||
defineOptions({ name: "fixed-index" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const iconList = ref([
|
||||
{
|
||||
icon:"user",
|
||||
label:"会员中心",
|
||||
path:"/home",
|
||||
requireLogin: true,
|
||||
},
|
||||
{
|
||||
icon:"carts",
|
||||
label:"购物车",
|
||||
path:"/cart",
|
||||
requireLogin: true,
|
||||
},
|
||||
{
|
||||
icon:"notification",
|
||||
label:"消息",
|
||||
path:"/home/MsgList",
|
||||
requireLogin: true,
|
||||
},
|
||||
{
|
||||
icon:"collage",
|
||||
label:"收藏",
|
||||
path:"/home/Favorites",
|
||||
requireLogin: true,
|
||||
},
|
||||
{
|
||||
icon:"back",
|
||||
label:"顶部",
|
||||
path:"back"
|
||||
},
|
||||
]);
|
||||
let isScrolling = false;
|
||||
let timeoutId = "";
|
||||
|
||||
onMounted(() => {
|
||||
// 添加滚轮事件监听器
|
||||
window.addEventListener('wheel', function() {
|
||||
if (isScrolling) {
|
||||
// 如果页面正在滚动,则取消定时器并设置 isScrolling 为 false
|
||||
window.cancelAnimationFrame(timeoutId);
|
||||
isScrolling = false;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const vm = this
|
||||
// 添加滚轮事件监听器
|
||||
window.addEventListener('wheel', function() {
|
||||
if (vm.isScrolling) {
|
||||
// 如果页面正在滚动,则取消定时器并设置 isScrolling 为 false
|
||||
window.cancelAnimationFrame(vm.timeoutId);
|
||||
vm.isScrolling = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
},
|
||||
methods:{
|
||||
scrollToTop() {
|
||||
const c = document.documentElement.scrollTop || document.body.scrollTop;
|
||||
if (c > 0) {
|
||||
this.timeoutId = window.requestAnimationFrame(this.scrollToTop);
|
||||
window.scrollTo(0, c - c / 4);
|
||||
this.isScrolling = true;
|
||||
}else {
|
||||
this.isScrolling = false;
|
||||
}
|
||||
},
|
||||
handleClickIcon(val){
|
||||
if(val.path === 'back'){
|
||||
this.scrollToTop()
|
||||
}else{
|
||||
this.$router.push(val.path)
|
||||
}
|
||||
});
|
||||
|
||||
function scrollToTop() {
|
||||
const c = document.documentElement.scrollTop || document.body.scrollTop;
|
||||
if (c > 0) {
|
||||
timeoutId = window.requestAnimationFrame(scrollToTop);
|
||||
window.scrollTo(0, c - c / 4);
|
||||
isScrolling = true;
|
||||
}else {
|
||||
isScrolling = false;
|
||||
}
|
||||
}
|
||||
function isLoggedIn () {
|
||||
const rawUserInfo = storage.getItem('userInfo');
|
||||
if (rawUserInfo) {
|
||||
try {
|
||||
return !!JSON.parse(rawUserInfo).username;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return !!storage.getItem('accessToken');
|
||||
}
|
||||
function goLogin (rePath) {
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: {
|
||||
rePath,
|
||||
query: JSON.stringify(route.query || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
function handleClickIcon (val) {
|
||||
if (val.path === 'back') {
|
||||
scrollToTop();
|
||||
return;
|
||||
}
|
||||
if (val.requireLogin && !isLoggedIn()) {
|
||||
goLogin(val.path);
|
||||
return;
|
||||
}
|
||||
router.push(val.path);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -68,34 +68,29 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import config from '@/config'
|
||||
|
||||
export default {
|
||||
name: "Footer",
|
||||
data() {
|
||||
return {
|
||||
config:require('@/config'),
|
||||
guideArr: [
|
||||
// 导航链接
|
||||
["购物指南", "购物流程", "会员介绍", "生活旅行", "常见问题"],
|
||||
["配送方式", "上门自提", "配送查询", "收取标准", "物流规则"],
|
||||
["支付方式", "在线支付", "公司转账", "余额支付", "积分支付"],
|
||||
["售后服务", "售后政策", "退款说明", "返修/退货", "取消订单"],
|
||||
],
|
||||
moreLink: ["关于我们", "联系我们", "联系客服", "商家帮助", "隐私政策"], // 更多链接
|
||||
year: new Date().getFullYear(), // 当前年份
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
goArticle() {
|
||||
// 跳转文章页
|
||||
let routeUrl = this.$router.resolve({
|
||||
path: "/article",
|
||||
});
|
||||
window.open(routeUrl.href, "_blank");
|
||||
},
|
||||
},
|
||||
};
|
||||
defineOptions({ name: 'Footer' })
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const guideArr = ref([
|
||||
// 导航链接
|
||||
["购物指南", "购物流程", "会员介绍", "生活旅行", "常见问题"],
|
||||
["配送方式", "上门自提", "配送查询", "收取标准", "物流规则"],
|
||||
["支付方式", "在线支付", "公司转账", "余额支付", "积分支付"],
|
||||
["售后服务", "售后政策", "退款说明", "返修/退货", "取消订单"],
|
||||
])
|
||||
const moreLink = ref(["关于我们", "联系我们", "联系客服", "商家帮助", "隐私政策"]) // 更多链接
|
||||
const year = ref(new Date().getFullYear()) // 当前年份
|
||||
|
||||
function goArticle() {
|
||||
// 跳转文章页
|
||||
router.push("/article")
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,54 +1,21 @@
|
||||
// 全局组件配置
|
||||
import empty from "./empty/Main.vue";
|
||||
import drawer from "./drawer/Main.vue";
|
||||
import Header from "@/components/header/Header.vue";
|
||||
import FixedTopPage from "@/components/advertising/FixedTop.vue";
|
||||
import Footer from "@/components/footer/Footer.vue";
|
||||
import Search from "@/components/Search.vue";
|
||||
import card from "@/components/card/index.vue";
|
||||
import cateNav from "@/components/nav/CateNav.vue";
|
||||
import UserCenterLayout from "@/components/userCenter/Layout.vue";
|
||||
|
||||
import empty from './empty/Main' // 空状态组件
|
||||
import drawer from './drawer/Main' // 右侧bar
|
||||
import Header from '@/components/header/Header'; // 头部组件
|
||||
import FixedTopPage from '@/components/advertising/FixedTop'; // 顶部广告
|
||||
import Footer from '@/components/footer/Footer'; // 底部栏
|
||||
import Search from '@/components/Search' // 搜索框
|
||||
import card from '@/components/card' // 个人中心 卡片
|
||||
import cateNav from '@/components/nav/CateNav' // 个人中心 卡片
|
||||
|
||||
empty.install = function (Vue) {
|
||||
Vue.component('empty', empty);
|
||||
};
|
||||
|
||||
drawer.install = function (Vue) {
|
||||
Vue.component('drawer', drawer);
|
||||
};
|
||||
|
||||
Header.install = function (Vue) {
|
||||
Vue.component('BaseHeader', Header);
|
||||
};
|
||||
|
||||
FixedTopPage.install = function (Vue) {
|
||||
Vue.component('FixedTopPage', FixedTopPage);
|
||||
};
|
||||
|
||||
Footer.install = function (Vue) {
|
||||
Vue.component('BaseFooter', Footer);
|
||||
};
|
||||
|
||||
Search.install = function (Vue) {
|
||||
Vue.component('Search', Search);
|
||||
};
|
||||
|
||||
card.install = function (Vue) {
|
||||
Vue.component('card', card)
|
||||
}
|
||||
|
||||
cateNav.install = function (Vue) {
|
||||
Vue.component('cateNav', cateNav)
|
||||
}
|
||||
|
||||
// 引用本js中所有的组件
|
||||
export function InstallAll (Vue) {
|
||||
Vue.use(empty)
|
||||
Vue.use(drawer)
|
||||
Vue.use(Header)
|
||||
Vue.use(FixedTopPage)
|
||||
Vue.use(Footer)
|
||||
Vue.use(Search)
|
||||
Vue.use(card)
|
||||
Vue.use(cateNav)
|
||||
export function registerGlobalComponents(app) {
|
||||
app.component("empty", empty);
|
||||
app.component("drawer", drawer);
|
||||
app.component("BaseHeader", Header);
|
||||
app.component("FixedTopPage", FixedTopPage);
|
||||
app.component("BaseFooter", Footer);
|
||||
app.component("Search", Search);
|
||||
app.component("card", card);
|
||||
app.component("UserCenterLayout", UserCenterLayout);
|
||||
app.component("cateNav", cateNav);
|
||||
}
|
||||
|
||||
146
buyer/src/components/goods/GoodsThumb.vue
Normal file
146
buyer/src/components/goods/GoodsThumb.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="goods-thumb" ref="rootEl" :class="{ 'is-loaded': loaded, 'is-error': error }">
|
||||
<img v-if="currentSrc" :src="currentSrc" :alt="alt" @load="loaded = true" @error="onError" />
|
||||
<img v-else-if="error" :src="defaultImg" :alt="alt || '默认商品图片'" class="default-img" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import defaultImg from "@/assets/images/default.png";
|
||||
|
||||
defineOptions({ name: "GoodsThumb" });
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const rootEl = ref(null);
|
||||
const currentSrc = ref("");
|
||||
const loaded = ref(false);
|
||||
const error = ref(false);
|
||||
let observer = null;
|
||||
|
||||
function isValidUrl(url) {
|
||||
if (!url || typeof url !== "string") return false;
|
||||
const s = url.trim().toLowerCase();
|
||||
if (
|
||||
!s ||
|
||||
s === "null" ||
|
||||
s === "undefined" ||
|
||||
s.startsWith("null?") ||
|
||||
s.startsWith("undefined?")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
observe();
|
||||
}
|
||||
);
|
||||
|
||||
function disconnect() {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function observe() {
|
||||
disconnect();
|
||||
currentSrc.value = "";
|
||||
loaded.value = false;
|
||||
error.value = false;
|
||||
if (!isValidUrl(props.src)) {
|
||||
error.value = true;
|
||||
return;
|
||||
}
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
currentSrc.value = props.src;
|
||||
return;
|
||||
}
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
currentSrc.value = props.src;
|
||||
disconnect();
|
||||
},
|
||||
{ rootMargin: "160px 0px" }
|
||||
);
|
||||
observer.observe(rootEl.value);
|
||||
}
|
||||
|
||||
function onError() {
|
||||
error.value = true;
|
||||
loaded.value = false;
|
||||
currentSrc.value = "";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
observe();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.goods-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
|
||||
.goods-thumb::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, #f3f3f3 25%, #e8e8e8 37%, #f3f3f3 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: goods-thumb-loading 1.2s ease infinite;
|
||||
}
|
||||
|
||||
.goods-thumb.is-loaded::before,
|
||||
.goods-thumb.is-error::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.goods-thumb img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
|
||||
&.default-img {
|
||||
object-fit: contain;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes goods-thumb-loading {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="wrapper" v-if="type === 'goodsDetail'">
|
||||
<div class="wr-l"><Icon size="23" type="ios-alarm-outline" /> 秒杀活动</div>
|
||||
<div class="wr-l"><el-icon :size="23"><AlarmClock /></el-icon> 秒杀活动</div>
|
||||
<div class="count-down" v-if="end === ''">
|
||||
<p>倒计时:</p><span>{{ hours }}</span><span>{{ minutes }}</span><span>{{ seconds }}</span>
|
||||
<span class="count-down-label">倒计时:</span>
|
||||
<span class="count-down-num">{{ hours }}</span>
|
||||
<span class="count-down-colon">:</span>
|
||||
<span class="count-down-num">{{ minutes }}</span>
|
||||
<span class="count-down-colon">:</span>
|
||||
<span class="count-down-num">{{ seconds }}</span>
|
||||
</div>
|
||||
<div v-else>{{end}}</div>
|
||||
</div>
|
||||
@@ -13,58 +18,57 @@
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
time: { // 传入的初始时间
|
||||
default: 1718977559428
|
||||
},
|
||||
type: { // 区分是在详情还是购物车调用
|
||||
default: 'goodsDetail', // 设置两个值,goodsDetail和cart,样式不同
|
||||
type: String
|
||||
}
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { AlarmClock } from '@element-plus/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
time: { // 传入的初始时间
|
||||
default: 1718977559428
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
end: '', // 结束状态
|
||||
hours: '', // 小时
|
||||
minutes: '', // 分钟
|
||||
seconds: '', // 秒
|
||||
interval: '' // 定时器
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
this.init()
|
||||
},
|
||||
methods: {
|
||||
countDown (val) { // 倒计时方法
|
||||
function addZero (i) {
|
||||
return i < 10 ? '0' + i : i + '';
|
||||
}
|
||||
var nowtime = new Date();
|
||||
var endtime = new Date(val);
|
||||
var lefttime = parseInt((endtime.getTime() - nowtime.getTime()) / 1000);
|
||||
var h = parseInt((lefttime / (60 * 60)) % 24);
|
||||
var m = parseInt((lefttime / 60) % 60);
|
||||
var s = parseInt(lefttime % 60);
|
||||
h = addZero(h);
|
||||
m = addZero(m);
|
||||
s = addZero(s);
|
||||
this.hours = h;
|
||||
this.minutes = m
|
||||
this.seconds = s;
|
||||
if (lefttime <= 0) {
|
||||
this.end = `活动已结束`;
|
||||
clearInterval(this.interval)
|
||||
}
|
||||
},
|
||||
init () { // 初始化
|
||||
this.interval = setInterval(() => {
|
||||
this.countDown(this.time);
|
||||
}, 1000);
|
||||
}
|
||||
type: { // 区分是在详情还是购物车调用
|
||||
default: 'goodsDetail', // 设置两个值,goodsDetail和cart,样式不同
|
||||
type: String
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const end = ref(''); // 结束状态
|
||||
const hours = ref(''); // 小时
|
||||
const minutes = ref(''); // 分钟
|
||||
const seconds = ref(''); // 秒
|
||||
let interval = null; // 定时器
|
||||
|
||||
function countDown (val) { // 倒计时方法
|
||||
function addZero (i) {
|
||||
return i < 10 ? '0' + i : i + '';
|
||||
}
|
||||
var nowtime = new Date();
|
||||
var endtime = new Date(val);
|
||||
var lefttime = parseInt((endtime.getTime() - nowtime.getTime()) / 1000);
|
||||
var h = parseInt((lefttime / (60 * 60)) % 24);
|
||||
var m = parseInt((lefttime / 60) % 60);
|
||||
var s = parseInt(lefttime % 60);
|
||||
h = addZero(h);
|
||||
m = addZero(m);
|
||||
s = addZero(s);
|
||||
hours.value = h;
|
||||
minutes.value = m
|
||||
seconds.value = s;
|
||||
if (lefttime <= 0) {
|
||||
end.value = `活动已结束`;
|
||||
clearInterval(interval)
|
||||
}
|
||||
}
|
||||
|
||||
function init () { // 初始化
|
||||
interval = setInterval(() => {
|
||||
countDown(props.time);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.cart-promotion{
|
||||
@@ -87,36 +91,35 @@ export default {
|
||||
font-size: 13px;
|
||||
}
|
||||
.count-down {
|
||||
margin-right: -20px;
|
||||
p{
|
||||
float: left;
|
||||
line-height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
|
||||
.count-down-label {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
> span {
|
||||
position: relative;
|
||||
float: left;
|
||||
width: 20px;
|
||||
|
||||
.count-down-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
padding: 0 2px;
|
||||
background-color: #2f3430;
|
||||
margin-right: 20px;
|
||||
color: white;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
&::after {
|
||||
content: ":";
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: -20px;
|
||||
font-weight: bolder;
|
||||
font-size: 14px;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
}
|
||||
line-height: 1;
|
||||
border-radius: 2px;
|
||||
}
|
||||
> span:last-child::after {
|
||||
content: "";
|
||||
|
||||
.count-down-colon {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
495
buyer/src/components/goodsDetail/StoreSide.vue
Normal file
495
buyer/src/components/goodsDetail/StoreSide.vue
Normal file
@@ -0,0 +1,495 @@
|
||||
<template>
|
||||
<aside class="store-side" v-if="storeId">
|
||||
<div class="store-card">
|
||||
<div class="store-head" @click="goStore">
|
||||
<img
|
||||
v-if="store.storeLogo && !logoError"
|
||||
class="store-logo"
|
||||
:src="store.storeLogo"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
:alt="storeName"
|
||||
@error="logoError = true"
|
||||
/>
|
||||
<span v-else class="store-logo store-logo-fallback">{{ storeInitial }}</span>
|
||||
<div class="store-head-text">
|
||||
<p class="store-name">
|
||||
<span v-if="isSelfOperated" class="store-self">自营</span>
|
||||
<span class="store-name-text">{{ storeName }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="store-scores">
|
||||
<div class="score-item">
|
||||
<span class="score-label">描述</span>
|
||||
<span class="score-value">{{ formatScore(store.descriptionScore) }}</span>
|
||||
</div>
|
||||
<div class="score-item">
|
||||
<span class="score-label">服务</span>
|
||||
<span class="score-value">{{ formatScore(store.serviceScore) }}</span>
|
||||
</div>
|
||||
<div class="score-item">
|
||||
<span class="score-label">物流</span>
|
||||
<span class="score-value">{{ formatScore(store.deliveryScore) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="store-actions">
|
||||
<button type="button" class="store-btn" @click="contact">联系客服</button>
|
||||
<button type="button" class="store-btn" :class="{ 'is-on': collected }" @click="toggleCollect">
|
||||
{{ collected ? "已收藏" : "收藏店铺" }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="enter-store" @click="goStore">进店逛逛</button>
|
||||
</div>
|
||||
|
||||
<div class="recommend-card" v-if="recommends.length">
|
||||
<p class="recommend-title">店铺推荐</p>
|
||||
<div
|
||||
class="recommend-item"
|
||||
v-for="item in recommends"
|
||||
:key="item.id"
|
||||
@click="goGoods(item)"
|
||||
>
|
||||
<img class="recommend-img" :src="item.thumbnail || item.small" loading="lazy" decoding="async" :alt="item.goodsName" />
|
||||
<div class="recommend-info">
|
||||
<p class="recommend-name">{{ item.goodsName }}</p>
|
||||
<div class="recommend-meta">
|
||||
<p class="recommend-price">{{ $filters.unitPrice(item.price, "¥") }}</p>
|
||||
<span class="recommend-sales">销量 {{ item.buyCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal } from "@/utils/message";
|
||||
import { goodsList } from "@/api/goods";
|
||||
import { getStoreById } from "@/api/shopentry";
|
||||
import { cancelStoreCollect, collectStore, isStoreCollection } from "@/api/member";
|
||||
import { getIMDetail } from "@/api/common";
|
||||
import { getMemberMsg } from "@/api/login";
|
||||
import storage from "@/plugins/storage.js";
|
||||
|
||||
defineOptions({ name: "StoreSide" });
|
||||
|
||||
const props = defineProps({
|
||||
storeId: {
|
||||
type: [String, Number],
|
||||
default: "",
|
||||
},
|
||||
goodsId: {
|
||||
type: [String, Number],
|
||||
default: "",
|
||||
},
|
||||
skuId: {
|
||||
type: [String, Number],
|
||||
default: "",
|
||||
},
|
||||
storeNameFallback: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const store = ref({});
|
||||
const logoError = ref(false);
|
||||
const collected = ref(false);
|
||||
const recommends = ref([]);
|
||||
// 原 talkIm mixin 的 IM 链接
|
||||
const IMLink = ref("");
|
||||
|
||||
const storeName = computed(() => {
|
||||
return store.value.storeName || props.storeNameFallback || "店铺";
|
||||
});
|
||||
const storeInitial = computed(() => {
|
||||
return storeName.value.slice(0, 1);
|
||||
});
|
||||
// 自营标记后端可能是 true、1 或 "1"
|
||||
const isSelfOperated = computed(() => {
|
||||
const flag = store.value.selfOperated;
|
||||
return flag === true || flag === 1 || flag === "1";
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.storeId,
|
||||
(id) => {
|
||||
if (!id) return;
|
||||
logoError.value = false;
|
||||
loadStore(id);
|
||||
loadRecommend(id);
|
||||
loadCollected(id);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 没有评分或小于等于 0 时按满分展示
|
||||
function formatScore(value) {
|
||||
const score = Number(value);
|
||||
if (!Number.isFinite(score) || score <= 0) return "5.00";
|
||||
return score.toFixed(2);
|
||||
}
|
||||
|
||||
function loadStore(id) {
|
||||
getStoreById(id).then((res) => {
|
||||
if (res.success && res.result) store.value = res.result;
|
||||
}).catch((err) => {
|
||||
console.warn('获取店铺信息失败', err);
|
||||
});
|
||||
}
|
||||
|
||||
// 按销量取店内商品,去掉当前商品,最多 4 个
|
||||
function loadRecommend(id) {
|
||||
goodsList({
|
||||
storeId: id,
|
||||
pageNumber: 1,
|
||||
pageSize: 8,
|
||||
sort: "buyCount",
|
||||
order: "desc",
|
||||
}).then((res) => {
|
||||
const records = (res.success && res.result && res.result.records) || [];
|
||||
recommends.value = records
|
||||
.filter((item) => String(item.goodsId) !== String(props.goodsId))
|
||||
.slice(0, 4);
|
||||
}).catch(() => {
|
||||
// 推荐商品为可选数据,加载失败时静默
|
||||
});
|
||||
}
|
||||
|
||||
// 同时有 token 和用户信息才查收藏,避免残留 userInfo 触发登录跳转
|
||||
function loadCollected(id) {
|
||||
if (!storage.getItem("accessToken") || !storage.getItem("userInfo")) return;
|
||||
isStoreCollection("STORE", id).then((res) => {
|
||||
if (res.success && res.result) collected.value = true;
|
||||
}).catch(() => {
|
||||
// 收藏状态为可选数据,失败时静默
|
||||
});
|
||||
}
|
||||
|
||||
function goStore() {
|
||||
router.push({ path: "/merchant", query: { id: props.storeId } });
|
||||
}
|
||||
|
||||
// 推荐商品新开详情页,店铺页仍在当前页打开
|
||||
function goGoods(item) {
|
||||
const href = router.resolve({
|
||||
path: "/goodsDetail",
|
||||
query: { skuId: item.id, goodsId: item.goodsId },
|
||||
}).href;
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
|
||||
// 原 talkIm mixin 的登录提示
|
||||
function promptLogin() {
|
||||
Modal.confirm({
|
||||
title: "温馨提示",
|
||||
content: "请登录后执行此操作",
|
||||
okText: "立即登录",
|
||||
cancelText: "取消",
|
||||
onOk: () => {
|
||||
router.push({
|
||||
path: "/login",
|
||||
query: {
|
||||
rePath: route.path,
|
||||
query: JSON.stringify(route.query || {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 获取im信息(原 talkIm mixin)
|
||||
async function getIMDetailMethods() {
|
||||
let res = await getIMDetail();
|
||||
if (res.success) {
|
||||
IMLink.value = res.result;
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转im客服(原 talkIm mixin;本组件无 storeMsg,mixin 的 storeMsg?.storeId 兜底恒为 undefined,等价于直接用传入的 id)
|
||||
async function IMService(id, goodsId, skuId) {
|
||||
if (!storage.getItem("accessToken")) {
|
||||
promptLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await getIMDetailMethods();
|
||||
const userInfo = await getMemberMsg();
|
||||
if (!userInfo.success) {
|
||||
promptLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = storage.getItem("accessToken");
|
||||
const storeId = id;
|
||||
const baseUrl = `${IMLink.value}?token=${accessToken}&id=${storeId}`;
|
||||
|
||||
if (goodsId && skuId) {
|
||||
window.open(`${baseUrl}&goodsId=${goodsId}&skuId=${skuId}`);
|
||||
} else {
|
||||
window.open(baseUrl);
|
||||
}
|
||||
} catch {
|
||||
// token 过期等场景由 request 拦截器处理;未登录时避免未捕获异常
|
||||
if (!storage.getItem("accessToken")) {
|
||||
promptLogin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function contact() {
|
||||
IMService(props.storeId, props.goodsId, props.skuId);
|
||||
}
|
||||
|
||||
// 未登录先去登录,并带回当前商品页
|
||||
async function toggleCollect() {
|
||||
if (!storage.getItem("accessToken") || !storage.getItem("userInfo")) {
|
||||
router.push({
|
||||
path: "/login",
|
||||
query: {
|
||||
rePath: route.path,
|
||||
query: JSON.stringify(route.query || {}),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (collected.value) {
|
||||
const cancel = await cancelStoreCollect("STORE", props.storeId);
|
||||
if (cancel.success) {
|
||||
collected.value = false;
|
||||
Message.success("已取消收藏");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const collect = await collectStore("STORE", props.storeId);
|
||||
if (collect.code === 200) {
|
||||
collected.value = true;
|
||||
Message.success("收藏店铺成功,可以前往个人中心我的收藏查看");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.store-side {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.store-card,
|
||||
.recommend-card {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.store-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.store-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.store-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.store-logo-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: $theme_color;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.store-head-text {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.store-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
color: #171A26;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.store-name-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-self {
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
border-radius: 2px;
|
||||
background: $theme_color;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.store-scores {
|
||||
display: flex;
|
||||
margin-top: 14px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.score-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
display: block;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: $theme_color;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.store-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.store-btn,
|
||||
.enter-store {
|
||||
border: 1px solid #eee;
|
||||
background: #fff;
|
||||
color: #171A26;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $theme_color;
|
||||
border-color: $theme_color;
|
||||
}
|
||||
}
|
||||
|
||||
.store-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.store-btn.is-on {
|
||||
color: $theme_color;
|
||||
border-color: $theme_color;
|
||||
background: #fff5f4;
|
||||
}
|
||||
|
||||
.enter-store {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.recommend-card {
|
||||
margin-top: 12px;
|
||||
padding: 12px 12px 4px;
|
||||
}
|
||||
|
||||
.recommend-title {
|
||||
margin: 0 0 8px;
|
||||
color: #171A26;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.recommend-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0 12px;
|
||||
cursor: pointer;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.recommend-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.recommend-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recommend-name {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recommend-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.recommend-price {
|
||||
margin: 0;
|
||||
color: $theme_color;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.recommend-sales {
|
||||
flex-shrink: 0;
|
||||
color: #999;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
}
|
||||
</style>
|
||||
@@ -23,12 +23,13 @@
|
||||
<li v-show="!!userInfo.username">
|
||||
<div class="username-p">
|
||||
<div>
|
||||
<Avatar class="person-icon" :src="userInfo.face" icon="person" size="small"/>
|
||||
<span class="username">{{
|
||||
userInfo.nickName ? userInfo.nickName : userInfo.username | secrecyMobile
|
||||
}}</span>
|
||||
<el-avatar class="person-icon" :src="userInfo.face" :size="24" />
|
||||
<span class="username">{{ $filters.secrecyMobile(
|
||||
userInfo.nickName ? userInfo.nickName : userInfo.username ) }}</span>
|
||||
</div>
|
||||
<transition name='fade'>
|
||||
<!-- 下拉菜单通过父级 hover 的 CSS 控制显隐,transition 内无需 v-if/v-show -->
|
||||
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
||||
<ul class="drop-items">
|
||||
<li @click="goUserCenter('/home')">我的主页</li>
|
||||
<li @click="goUserCenter('/home/Coupons')">优惠券</li>
|
||||
@@ -40,46 +41,45 @@
|
||||
</li>
|
||||
<li @click="goUserCenter('/home/MyOrder')"><span class="nav-item">我的订单</span></li>
|
||||
<li @click="goUserCenter('/home/MyTracks')"><span class="nav-item">我的足迹</span></li>
|
||||
<li @click="goUserCenter('/home/MsgList')"><span class="nav-item">我的消息</span></li>
|
||||
<li v-if="$route.name !== 'Cart'" style="position:relative;">
|
||||
<Dropdown placement="bottom-start">
|
||||
<router-link to="/cart" target="_blank">
|
||||
<span class="nav-item" @mouseenter="getCartList">
|
||||
<Icon size="18" type="ios-cart-outline"></Icon>
|
||||
购物车({{ cartNum < 100 ? cartNum : '99' }})
|
||||
</span>
|
||||
</router-link>
|
||||
<DropdownMenu slot="list">
|
||||
<div class="shopping-cart-null" style="width:200px" v-show="shoppingCart.length <= 0">
|
||||
<Icon type="ios-cart-outline" class="cart-null-icon"></Icon>
|
||||
<li @click="goUserCenter('/home/MsgList')">
|
||||
<span class="nav-item">
|
||||
我的消息
|
||||
<i v-if="unreadMsg > 0" class="msg-badge">{{ unreadLabel }}</i>
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="$route.name !== 'Cart'" class="cart-nav-item">
|
||||
<el-dropdown
|
||||
class="cart-nav-dropdown"
|
||||
placement="bottom-start"
|
||||
trigger="hover"
|
||||
@visible-change="handleCartDropdownVisible"
|
||||
>
|
||||
<span class="nav-item" @click="goToPay">
|
||||
<el-icon :size="18"><ShoppingCart /></el-icon>
|
||||
购物车({{ Number(cartNum) > 99 ? '99+' : cartNum }})
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<div class="shopping-cart-null" v-show="shoppingCart.length <= 0">
|
||||
<el-icon class="cart-null-icon"><ShoppingCart /></el-icon>
|
||||
<span>你的购物车没有宝贝哦</span>
|
||||
<span>赶快去添加商品吧~</span>
|
||||
</div>
|
||||
<div class="shopping-cart-list" v-show="shoppingCart.length > 0">
|
||||
<div class="shopping-cart-box" v-for="(item, index) in shoppingCart" @click="goToPay" :key="index">
|
||||
<div class="shopping-cart-img">
|
||||
<img :src="item.goodsSku.thumbnail" class="hover-pointer"/>
|
||||
<img :src="item.goodsSku.thumbnail" alt="" />
|
||||
</div>
|
||||
<div class="shopping-cart-info">
|
||||
<div class="shopping-cart-title ">
|
||||
<p class="hover-pointer goods-title ellipsis">{{ item.goodsSku.goodsName }}</p>
|
||||
</div>
|
||||
<p class="goods-title">{{ item.goodsSku.goodsName }}</p>
|
||||
<div class="shopping-cart-detail">
|
||||
<div>
|
||||
数量:
|
||||
<span class="shopping-cart-text">{{ item.num }}</span>
|
||||
|
||||
</div>
|
||||
<div class="shopping-price">
|
||||
{{ item.purchasePrice | unitPrice('¥') }}
|
||||
</div>
|
||||
<span>数量 {{ item.num }}</span>
|
||||
<span class="shopping-price">{{ $filters.unitPrice(item.purchasePrice, '¥') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</li>
|
||||
<li>
|
||||
<span class="nav-item" @click="shopEntry">店铺入驻</span>
|
||||
@@ -90,145 +90,249 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Modal } from "@/utils/message";
|
||||
import { ShoppingCart } from '@element-plus/icons-vue';
|
||||
import storage from '@/plugins/storage.js';
|
||||
import {cartGoodsAll} from '@/api/cart.js';
|
||||
import {logout} from '@/api/account.js';
|
||||
import config from '@/config';
|
||||
import { refreshUnreadMessage } from '@/utils/unreadMessage';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
|
||||
defineOptions({ name: 'M-Header' });
|
||||
|
||||
export default {
|
||||
name: 'M-Header',
|
||||
created() {
|
||||
if (storage.getItem('userInfo')) {
|
||||
this.userInfo = JSON.parse(storage.getItem('userInfo'));
|
||||
}
|
||||
},
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
|
||||
data() {
|
||||
return {
|
||||
config: require('@/config'),
|
||||
userInfo: {}, // 用户信息
|
||||
shoppingCart: [] // 购物车
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 购物车商品数量
|
||||
cartNum() {
|
||||
return this.$store.state.cartNum;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goToPay() { // 跳转购物车
|
||||
let url = this.$router.resolve({
|
||||
path: '/cart'
|
||||
});
|
||||
window.open(url.href, '_blank');
|
||||
},
|
||||
myInfo() { // 跳转会员中心
|
||||
let url = this.$router.resolve({
|
||||
path: '/home'
|
||||
});
|
||||
window.open(url.href, '_blank');
|
||||
},
|
||||
signOutFun() { // 退出登录
|
||||
logout().then(res => {
|
||||
storage.removeItem('accessToken');
|
||||
storage.removeItem('refreshToken');
|
||||
storage.removeItem('userInfo');
|
||||
storage.removeItem('cartNum');
|
||||
this.$store.commit('SET_CARTNUM', 0)
|
||||
this.$router.push('/login');
|
||||
});
|
||||
},
|
||||
goUserCenter(path) {
|
||||
// 跳转我的订单,我的足迹、收藏等
|
||||
if (this.userInfo.username) {
|
||||
this.$router.push({path: path});
|
||||
} else {
|
||||
this.$Modal.confirm({
|
||||
title: '请登录',
|
||||
content: '<p>请登录后执行此操作</p>',
|
||||
okText: '立即登录',
|
||||
cancelText: '继续浏览',
|
||||
onOk: () => {
|
||||
this.$router.push({
|
||||
path: '/login',
|
||||
query: {
|
||||
rePath: this.$router.history.current.path,
|
||||
query: JSON.stringify(this.$router.history.current.query)
|
||||
}
|
||||
});
|
||||
}
|
||||
const userInfo = ref({}); // 用户信息
|
||||
const shoppingCart = ref([]); // 购物车
|
||||
const cartLoading = ref(false);
|
||||
const cartLoadedAt = ref(0);
|
||||
const cartCacheTime = ref(5000);
|
||||
|
||||
// 购物车商品数量
|
||||
const cartNum = computed(() => appStore.cartNum);
|
||||
const unreadMsg = computed(() => appStore.unreadMsg);
|
||||
const unreadLabel = computed(() => {
|
||||
return unreadMsg.value > 99 ? '99+' : String(unreadMsg.value);
|
||||
});
|
||||
|
||||
if (storage.getItem('userInfo')) {
|
||||
userInfo.value = JSON.parse(storage.getItem('userInfo'));
|
||||
refreshUnreadMessage();
|
||||
}
|
||||
|
||||
function goToPay() {
|
||||
if (!userInfo.value.username) {
|
||||
Modal.confirm({
|
||||
title: '温馨提示',
|
||||
content: '请登录后执行此操作',
|
||||
okText: '立即登录',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: {
|
||||
rePath: '/cart',
|
||||
query: JSON.stringify(route.query || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
shopEntry() {
|
||||
// 店铺入驻
|
||||
if (storage.getItem('accessToken')) {
|
||||
let routeUrl = this.$router.resolve({
|
||||
path: '/shopEntry',
|
||||
query: {id: 1}
|
||||
});
|
||||
window.open(routeUrl.href, '_blank');
|
||||
} else {
|
||||
this.$router.push('login');
|
||||
}
|
||||
},
|
||||
getCartList() {
|
||||
// 获取购物车列表
|
||||
if (this.userInfo.username) {
|
||||
cartGoodsAll().then((res) => {
|
||||
this.shoppingCart = res.result.skuList;
|
||||
this.$store.commit('SET_CARTNUM', this.shoppingCart.length);
|
||||
this.Cookies.setItem('cartNum', this.shoppingCart.length);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
router.push('/cart');
|
||||
}
|
||||
|
||||
function myInfo() { // 跳转会员中心
|
||||
router.push('/home');
|
||||
}
|
||||
|
||||
function signOutFun() { // 退出登录
|
||||
logout().then(res => {
|
||||
storage.removeItem('accessToken');
|
||||
storage.removeItem('refreshToken');
|
||||
storage.removeItem('userInfo');
|
||||
storage.removeItem('cartNum');
|
||||
appStore.setCartNum(0)
|
||||
appStore.setUnreadMsg(0)
|
||||
router.push('/login');
|
||||
});
|
||||
}
|
||||
|
||||
function goUserCenter(path) {
|
||||
// 跳转我的订单,我的足迹、收藏等
|
||||
if (userInfo.value.username) {
|
||||
router.push({path: path});
|
||||
} else {
|
||||
Modal.confirm({
|
||||
title: '请登录',
|
||||
content: '请登录后执行此操作',
|
||||
okText: '立即登录',
|
||||
cancelText: '继续浏览',
|
||||
onOk: () => {
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: {
|
||||
rePath: route.path,
|
||||
query: JSON.stringify(route.query || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function shopEntry() {
|
||||
if (storage.getItem('accessToken')) {
|
||||
router.push({
|
||||
path: '/shopEntry',
|
||||
query: { id: 1 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '温馨提示',
|
||||
content: '请登录后执行此操作',
|
||||
okText: '立即登录',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: {
|
||||
rePath: '/shopEntry',
|
||||
query: JSON.stringify({ id: 1 }),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleCartDropdownVisible(visible) {
|
||||
if (visible) {
|
||||
getCartList();
|
||||
}
|
||||
}
|
||||
|
||||
function getCartList() {
|
||||
// 获取购物车列表
|
||||
if (!userInfo.value.username || cartLoading.value) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (cartLoadedAt.value && now - cartLoadedAt.value < cartCacheTime.value) {
|
||||
return;
|
||||
}
|
||||
cartLoadedAt.value = now;
|
||||
cartLoading.value = true;
|
||||
cartGoodsAll().then((res) => {
|
||||
if (res.success) {
|
||||
shoppingCart.value = (res.result && res.result.skuList) || [];
|
||||
appStore.setCartNum(shoppingCart.value.length);
|
||||
storage.setItem('cartNum', shoppingCart.value.length);
|
||||
}
|
||||
}).finally(() => {
|
||||
cartLoading.value = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.box {
|
||||
width: 100%;
|
||||
font-size: 12px !important;
|
||||
height: 36px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
height: 40px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nav ul {
|
||||
.nav > ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav li {
|
||||
.nav > ul > li,
|
||||
.location > ul.flex > li {
|
||||
cursor: pointer;
|
||||
float: left;
|
||||
line-height: 36px;
|
||||
float: none;
|
||||
line-height: 40px;
|
||||
margin-right: 15px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav a,
|
||||
.nav-item {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
text-decoration: none;
|
||||
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
color: #64748b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding-left: 20px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.msg-badge {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: $theme_color;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cart-nav-item {
|
||||
:deep(.cart-nav-dropdown),
|
||||
:deep(.el-dropdown) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
vertical-align: middle;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
:deep(.el-tooltip__trigger),
|
||||
:deep(.el-dropdown-selfdefine),
|
||||
:deep([aria-expanded="true"]) {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.location a {
|
||||
@@ -261,30 +365,10 @@ export default {
|
||||
}
|
||||
|
||||
|
||||
.shopping-cart-img {
|
||||
margin-right: 15px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.shopping-cart-img img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shopping-cart-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-content: space-between;
|
||||
width: 200px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
|
||||
.shopping-cart-null {
|
||||
padding: 15px;
|
||||
width: 320px;
|
||||
padding: 28px 16px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -292,20 +376,102 @@ export default {
|
||||
}
|
||||
|
||||
.cart-null-icon {
|
||||
font-size: 38px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 36px;
|
||||
margin-bottom: 12px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.shopping-cart-null span {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.shopping-cart-list {
|
||||
width: 320px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
box-sizing: border-box;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.shopping-cart-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
& + .shopping-cart-box {
|
||||
border-top: 1px solid #f2f2f2;
|
||||
}
|
||||
}
|
||||
|
||||
.shopping-cart-img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.shopping-cart-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.shopping-cart-info .goods-title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #333;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.shopping-cart-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shopping-price {
|
||||
color: $theme_color;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.username-p {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
|
||||
div {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> span {
|
||||
margin-left: 5px;
|
||||
@@ -315,59 +481,71 @@ export default {
|
||||
.drop-items {
|
||||
position: absolute;
|
||||
display: none;
|
||||
top: 45px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0 auto;
|
||||
padding: 5px 10px;
|
||||
z-index: 20;
|
||||
height: 150px;
|
||||
flex-direction: column;
|
||||
top: calc(100% + 10px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
z-index: 1000;
|
||||
min-width: 100px;
|
||||
width: max-content;
|
||||
background-color: #fff;
|
||||
width: 80px;
|
||||
border: 1px solid #eee;
|
||||
box-shadow: 2px 2px 7px #999;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
color: rgb(107, 106, 106);
|
||||
display: block;
|
||||
float: none;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid rgb(207, 206, 206);
|
||||
margin: 0;
|
||||
padding: 8px 16px;
|
||||
line-height: 1.4;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:last-child {
|
||||
border: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
color: $theme_color;
|
||||
background-color: #fff7f7;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 30px;
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
content: '';
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 10px solid #999;
|
||||
border-color: transparent transparent #fff transparent;
|
||||
border: 6px solid transparent;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
height: 20px;
|
||||
top: -20px;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
top: -10px;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.drop-items {
|
||||
display: block;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,7 +582,7 @@ export default {
|
||||
}
|
||||
|
||||
.my-info p {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sign-out {
|
||||
@@ -424,7 +602,7 @@ export default {
|
||||
}
|
||||
|
||||
.sign-out p {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.goods-title:hover {
|
||||
@@ -446,34 +624,4 @@ export default {
|
||||
z-index: 3;
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
.shopping-cart-box {
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px #ccc dotted;
|
||||
}
|
||||
|
||||
.shopping-cart-list {
|
||||
padding: 10px 10px;
|
||||
box-sizing: border-box;
|
||||
max-height: 300px;
|
||||
overflow-y: scroll;
|
||||
color: #333;
|
||||
|
||||
}
|
||||
|
||||
.shopping-cart-detail {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
|
||||
}
|
||||
|
||||
.shopping-price {
|
||||
color: $theme_color;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,117 +1,251 @@
|
||||
<template>
|
||||
<div class="scroll-show">
|
||||
<div class="content clearfix">
|
||||
<cateNav class="cate" :hover="true" :showNavBar="false"
|
||||
useClass="fixed-show"></cateNav>
|
||||
<Search
|
||||
class="search-con"
|
||||
:hover="true"
|
||||
ref="search"
|
||||
:showLogo="false"
|
||||
:showTag="false"
|
||||
useClass="fixed-show"
|
||||
></Search>
|
||||
<div class="flex flex-a-c cart">
|
||||
<Icon
|
||||
type="ios-cart-outline"
|
||||
@click="goCartList"
|
||||
class="cart-icon"
|
||||
@mouseenter.native="getCartList"
|
||||
/>
|
||||
<i class="cart-badge">{{ cartNum < 100 ? cartNum : "99" }}</i>
|
||||
<div class="content">
|
||||
<!-- 1. 左侧区域:商城 Logo -->
|
||||
<div class="left-section">
|
||||
<router-link to="/" class="logo-link">
|
||||
<img v-if="logoSrc" :src="logoSrc" alt="Logo" class="site-logo" />
|
||||
<span v-else class="text-logo">LiliShop</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 2. 中间搜索区域:与 SearchHeader 样式完全一致(蓝底色、圆角4px、搜索按钮) -->
|
||||
<div class="search-main">
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
class="search-input"
|
||||
:placeholder="placeholderText"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button type="button" class="search-btn" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 右侧“我的购物车”胶囊入口:与 SearchHeader 样式完全一致 -->
|
||||
<div class="cart-entry">
|
||||
<router-link to="/cart" class="cart-btn" @mouseenter="getCartList">
|
||||
<el-icon class="cart-icon"><ShoppingCart /></el-icon>
|
||||
<span class="cart-text">{{ options.cartText || '我的购物车' }}</span>
|
||||
<span class="cart-count">{{ Number(cartNum) > 99 ? '99+' : cartNum }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { cartCount } from "@/api/cart.js";
|
||||
import storage from "@/plugins/storage.js";
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
userInfo: {}, // 用户信息
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
cartNum() {
|
||||
// 购物车商品数量
|
||||
return this.$store.state.cartNum;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
goCartList() {
|
||||
// 跳转购物车页面
|
||||
let routerUrl = this.$router.resolve({
|
||||
path: "/cart",
|
||||
});
|
||||
window.open(routerUrl.href, "_blank");
|
||||
},
|
||||
getCartList() {
|
||||
// 获取购物车列表
|
||||
if (storage.getItem("userInfo")) {
|
||||
cartCount().then((res) => {
|
||||
this.$store.commit("SET_CARTNUM", res.result);
|
||||
this.Cookies.setItem("cartNum", res.result);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
||||
if (storage.getItem("userInfo")) {
|
||||
this.userInfo = JSON.parse(storage.getItem("userInfo"));
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { cartCount } from '@/api/cart.js';
|
||||
import storage from '@/plugins/storage.js';
|
||||
import { ShoppingCart, Search } from '@element-plus/icons-vue';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const keyword = ref(route.query.keyword || '');
|
||||
const cartNum = computed(() => appStore.cartNum || 0);
|
||||
|
||||
watch(() => route.query.keyword, (v) => {
|
||||
keyword.value = v || '';
|
||||
});
|
||||
|
||||
const logoSrc = computed(() => {
|
||||
return props.options?.logoUrl || appStore.logoImg || '';
|
||||
});
|
||||
|
||||
const placeholderText = computed(() => {
|
||||
const ph = props.options?.placeholder;
|
||||
if (!ph || ph === '搜索品牌或商品' || ph === '请输入商品名称、品牌或型号') {
|
||||
return '请输入商品名称';
|
||||
}
|
||||
return ph;
|
||||
});
|
||||
|
||||
function handleSearch() {
|
||||
const kw = (keyword.value || '').trim();
|
||||
if (route.path === '/goodsList') {
|
||||
const nextQuery = { ...route.query };
|
||||
if (kw) {
|
||||
nextQuery.keyword = kw;
|
||||
} else {
|
||||
delete nextQuery.keyword;
|
||||
}
|
||||
},
|
||||
};
|
||||
router.push({ path: '/goodsList', query: nextQuery });
|
||||
} else {
|
||||
router.push({ path: '/goodsList', query: kw ? { keyword: kw } : {} });
|
||||
}
|
||||
}
|
||||
|
||||
function getCartList() {
|
||||
if (storage.getItem('userInfo')) {
|
||||
cartCount().then((res) => {
|
||||
appStore.setCartNum(res.result);
|
||||
storage.setItem('cartNum', res.result);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getCartList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.scroll-show {
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 1200px;
|
||||
height: 40px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
.cate {
|
||||
float: left;
|
||||
width: 200px !important;
|
||||
}
|
||||
.search-con {
|
||||
float: left;
|
||||
width: 800px;
|
||||
overflow: hidden;
|
||||
margin-top: -27px;
|
||||
}
|
||||
.cart{
|
||||
max-width: 100%;
|
||||
height: 60px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.cart-icon {
|
||||
width: 30px;
|
||||
float: left;
|
||||
font-size: 25px;
|
||||
|
||||
color: $theme_color;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
.left-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.logo-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
height: 40px;
|
||||
|
||||
.site-logo {
|
||||
max-height: 36px;
|
||||
max-width: 160px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.text-logo {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #409EFF;
|
||||
letter-spacing: -0.5px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
.cart-badge {
|
||||
position: absolute;
|
||||
font-style: normal;
|
||||
right: 165px;
|
||||
display: block;
|
||||
background-color: $theme_color;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-radius: 10px;
|
||||
line-height: 17px;
|
||||
text-align: center;
|
||||
z-index: 5;
|
||||
top: 10px;
|
||||
|
||||
.search-main {
|
||||
width: 560px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
border: 2px solid #409EFF;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
outline: none;
|
||||
background: #ffffff;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
background: #409EFF;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 0 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cart-entry {
|
||||
flex-shrink: 0;
|
||||
|
||||
.cart-btn {
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
background: #ffffff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.15s ease;
|
||||
box-sizing: border-box;
|
||||
|
||||
.cart-icon {
|
||||
font-size: 16px;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.cart-text {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cart-count {
|
||||
background: #409EFF;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #409EFF;
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,31 +1,82 @@
|
||||
<template>
|
||||
<div class="model-form">
|
||||
<div class="model-content">
|
||||
<template v-for="(element, index) in data.list">
|
||||
<model-form-item
|
||||
v-if="element && element.key"
|
||||
:key="element.key"
|
||||
:element="element"
|
||||
:index="index"
|
||||
:data="data"
|
||||
></model-form-item>
|
||||
</template>
|
||||
<div class="model-form" :style="pageContainerStyle">
|
||||
<!-- Version 2: 新万米行模型渲染 -->
|
||||
<div v-if="isVersion2" class="v2-rows-container">
|
||||
<row-wrapper
|
||||
v-for="row in v2Rows"
|
||||
:key="row.id"
|
||||
:row="row"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 旧版降级渲染 -->
|
||||
<div v-else class="model-content">
|
||||
<model-form-item
|
||||
v-for="(element, index) in renderList"
|
||||
:key="element.key"
|
||||
:element="element"
|
||||
:index="index"
|
||||
:data="data"
|
||||
></model-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ModelFormItem from './ModelFormItem.vue';
|
||||
export default {
|
||||
name: 'modelForm',
|
||||
components: {
|
||||
ModelFormItem
|
||||
},
|
||||
props: ['data']
|
||||
};
|
||||
import RowWrapper from './RowWrapper.vue';
|
||||
|
||||
defineOptions({ name: 'modelForm' });
|
||||
|
||||
const props = defineProps(['data']);
|
||||
|
||||
// 是否为 v2 行模型数据
|
||||
const isVersion2 = computed(() => {
|
||||
return (props.data?.version === 2 || props.data?.version === '2.0') && Array.isArray(props.data?.list);
|
||||
});
|
||||
|
||||
const v2Rows = computed(() => {
|
||||
return props.data?.list || [];
|
||||
});
|
||||
|
||||
const pageContainerStyle = computed(() => {
|
||||
if (isVersion2.value && props.data?.pageStyle?.backgroundColor) {
|
||||
return {
|
||||
backgroundColor: props.data.pageStyle.backgroundColor
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const renderList = computed(() => {
|
||||
const list = props.data?.list || [];
|
||||
return list.filter(
|
||||
(el) => el && el.key && !isBuiltinModule(el.type)
|
||||
);
|
||||
});
|
||||
|
||||
function isBuiltinModule(type) {
|
||||
return type === 'topAdvert' || type === 'navBar';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.model-form {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.v2-rows-container {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.model-content {
|
||||
width: 100%;
|
||||
min-height: 1200px;
|
||||
box-sizing: border-box;
|
||||
overflow-x: visible;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
<div class="model-item" v-if="element && element.key">
|
||||
<!-- 轮播图模块,包括个人信息,快捷导航模块 -->
|
||||
<template v-if="element.type == 'carousel'">
|
||||
<model-carousel
|
||||
:data="element"
|
||||
class="mb_20 width_1200_auto"
|
||||
></model-carousel>
|
||||
<div class="carousel-wrap mb_20">
|
||||
<model-carousel :key="element.key" :data="element"></model-carousel>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="element.type == 'carousel1'">
|
||||
<model-carousel1 :data="element" class="mb_20"></model-carousel1>
|
||||
@@ -18,62 +17,66 @@
|
||||
</template>
|
||||
<!-- 热门广告 -->
|
||||
<template v-if="element.type == 'hotAdvert'">
|
||||
<div class="mb_20 width_1200_auto">
|
||||
<img
|
||||
style="display: block"
|
||||
class="hover-pointer"
|
||||
:src="element.options.list[0].img"
|
||||
@click="linkTo(element.options.list[0].url)"
|
||||
width="1200"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<ul class="advert-list width_1200_auto">
|
||||
<template v-for="(item, index) in element.options.list">
|
||||
<li
|
||||
v-if="index !== 0"
|
||||
@click="linkTo(item.url)"
|
||||
<div class="hot-advert mb_20 width_1200_auto">
|
||||
<div class="hot-advert-banner">
|
||||
<img
|
||||
class="hover-pointer"
|
||||
:key="index"
|
||||
>
|
||||
<img :src="item.img" width="230" height="190" alt="" />
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
:src="element.options.list[0].img"
|
||||
@click="linkTo(element.options.list[0].url)"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<ul class="advert-list">
|
||||
<template v-for="(item, index) in element.options.list" :key="index">
|
||||
<li
|
||||
v-if="index !== 0"
|
||||
@click="linkTo(item.url)"
|
||||
class="hover-pointer"
|
||||
>
|
||||
<img :src="item.img" alt="" />
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 限时秒杀 待完善 -->
|
||||
<template v-if="element.type == 'seckill' && element.options.list.length">
|
||||
<template v-if="element.type == 'seckill' && element.options?.list?.length">
|
||||
<seckill :data="element" class="mb_20 width_1200_auto"></seckill>
|
||||
</template>
|
||||
<!-- 折扣广告 -->
|
||||
<template v-if="element.type == 'discountAdvert'">
|
||||
<div
|
||||
class="discountAdvert"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'url(' + require('@/assets/images/decorate.png') + ')',
|
||||
}"
|
||||
>
|
||||
<img
|
||||
@click="linkTo(item.url)"
|
||||
class="hover-pointer"
|
||||
v-for="(item, index) in element.options.classification"
|
||||
:key="index"
|
||||
:src="item.img"
|
||||
width="190"
|
||||
height="210"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
@click="linkTo(item.url)"
|
||||
class="hover-pointer"
|
||||
v-for="(item, index) in element.options.brandList"
|
||||
:key="'discount' + index"
|
||||
:src="item.img"
|
||||
width="240"
|
||||
height="105"
|
||||
alt=""
|
||||
/>
|
||||
<div class="discountAdvert-wrap width_1200_auto mb_20">
|
||||
<div
|
||||
class="discountAdvert"
|
||||
:style="{
|
||||
backgroundImage: 'url(' + decorateImg + ')',
|
||||
}"
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
@click="linkTo(item.url)"
|
||||
class="hover-pointer"
|
||||
v-for="(item, index) in element.options.classification"
|
||||
:key="index"
|
||||
:src="item.img"
|
||||
width="190"
|
||||
height="210"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<img
|
||||
@click="linkTo(item.url)"
|
||||
class="hover-pointer"
|
||||
v-for="(item, index) in element.options.brandList"
|
||||
:key="'discount' + index"
|
||||
:src="item.img"
|
||||
width="240"
|
||||
height="105"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -117,15 +120,12 @@
|
||||
></first-page-advert>
|
||||
</template>
|
||||
<!-- 横幅广告 -->
|
||||
<template v-if="element.type == 'bannerAdvert'">
|
||||
<div style="width: 100%; text-align: center">
|
||||
<img
|
||||
width="1200"
|
||||
class="hover-pointer mb_20 bannerAd"
|
||||
@click="linkTo(element.options.url)"
|
||||
:src="element.options.img"
|
||||
alt=""
|
||||
/>
|
||||
<template v-if="element.type == 'bannerAdvert' && element.options.img">
|
||||
<div
|
||||
class="horizontal-advert width_1200_auto mb_20 hover-pointer"
|
||||
@click="linkTo(element.options.url)"
|
||||
>
|
||||
<img class="bannerAd" :src="element.options.img" alt="" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="element.type == 'notEnough'"
|
||||
@@ -138,7 +138,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import ModelCarousel from "./modelList/Carousel.vue";
|
||||
import ModelCarousel1 from "./modelList/Carousel1.vue";
|
||||
import ModelCarousel2 from "./modelList/Carousel2.vue";
|
||||
@@ -152,53 +153,79 @@ import goodsAndType from "./modelList/goodsAndType.vue";
|
||||
import onlyGoodsModel from "./modelList/onlyGoodsModel.vue";
|
||||
import mixModel from "./modelList/mixModel.vue";
|
||||
import forYour from "./modelList/forYour.vue";
|
||||
import decorateImgUrl from "@/assets/images/decorate.png";
|
||||
|
||||
defineOptions({ name: "modelFormItem" });
|
||||
|
||||
export default {
|
||||
name: "modelFormItem",
|
||||
props: ["element", "select", "index", "data"],
|
||||
components: {
|
||||
ModelCarousel,
|
||||
ModelCarousel1,
|
||||
ModelCarousel2,
|
||||
Recommend,
|
||||
NewGoodsSort,
|
||||
FirstPageAdvert,
|
||||
NotEnough,
|
||||
Seckill,
|
||||
oneRowThreeColumns,
|
||||
goodsAndType,
|
||||
onlyGoodsModel,
|
||||
mixModel,
|
||||
forYour
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showModal: false, // 控制模态框显隐
|
||||
selected: {}, // 已选数据
|
||||
};
|
||||
},
|
||||
};
|
||||
defineProps(["element", "select", "index", "data"]);
|
||||
|
||||
const decorateImg = ref(decorateImgUrl);
|
||||
const showModal = ref(false); // 控制模态框显隐
|
||||
const selected = ref({}); // 已选数据
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.model-item {
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.bannerAd{
|
||||
width: 1183px;
|
||||
height: 166.6px;
|
||||
.carousel-wrap {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
/** 横幅广告 */
|
||||
.horizontal-advert {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
line-height: 0;
|
||||
}
|
||||
.bannerAd {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 166px;
|
||||
object-fit: cover;
|
||||
}
|
||||
/** 热门广告 */
|
||||
.advert-list {
|
||||
background: $theme_color;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 3px 10px;
|
||||
> li {
|
||||
.hot-advert {
|
||||
width: 1200px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.hot-advert-banner {
|
||||
line-height: 0;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
.advert-list {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
list-style: none;
|
||||
box-sizing: border-box;
|
||||
background: $theme_color;
|
||||
height: 210px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
> li {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 190px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: all 150ms ease-in-out;
|
||||
@@ -211,25 +238,43 @@ export default {
|
||||
}
|
||||
|
||||
/** 折扣广告 */
|
||||
.discountAdvert-wrap {
|
||||
width: 1200px;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
.discountAdvert {
|
||||
width: 1300px;
|
||||
height: 566px;
|
||||
margin: 0 auto;
|
||||
margin-bottom: 20px;
|
||||
height: 585px;
|
||||
margin-left: -100px;
|
||||
box-sizing: border-box;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1300px 585px;
|
||||
background-position: left top;
|
||||
position: relative;
|
||||
left: -47px;
|
||||
padding-left: 295px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
img {
|
||||
margin-top: 10px;
|
||||
margin-right: 10px;
|
||||
transition: all 150ms ease-in-out;
|
||||
&:hover {
|
||||
box-shadow: 0 5px 12px 0 rgba(0, 0, 0, 0.4);
|
||||
transform: translateY(-2px);
|
||||
> div {
|
||||
padding-left: 295px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
&:nth-child(1) img {
|
||||
margin: 10px 10px 0 0;
|
||||
transition: all 150ms ease-in-out;
|
||||
&:hover {
|
||||
box-shadow: 0 5px 12px 0 rgba(0, 0, 0, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
&:nth-child(2) {
|
||||
margin-top: 10px;
|
||||
img {
|
||||
margin: 0 10px 0 0;
|
||||
transition: all 150ms ease-in-out;
|
||||
&:hover {
|
||||
box-shadow: 0 5px 12px 0 rgba(0, 0, 0, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
368
buyer/src/components/indexDecorate/RowWrapper.vue
Normal file
368
buyer/src/components/indexDecorate/RowWrapper.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
|
||||
<div class="row-wrapper" :style="rowWrapperStyle">
|
||||
|
||||
<div class="row-inner-flex" :style="rowInnerFlexStyle">
|
||||
|
||||
<div
|
||||
|
||||
v-for="comp in row.components"
|
||||
|
||||
:key="comp.id"
|
||||
|
||||
class="component-cell"
|
||||
|
||||
:style="getComponentCellStyle(comp.componentStyle, row.rowStyle, row.components ? row.components.length : 1)"
|
||||
|
||||
>
|
||||
|
||||
<component
|
||||
|
||||
:is="resolveComponent(comp.type)"
|
||||
|
||||
v-if="resolveComponent(comp.type)"
|
||||
:component="comp"
|
||||
|
||||
:options="comp.options || {}"
|
||||
|
||||
:component-style="comp.componentStyle || {}"
|
||||
|
||||
:row-style="row.rowStyle || {}"
|
||||
|
||||
/>
|
||||
|
||||
<div v-else class="unknown-component-placeholder">
|
||||
|
||||
未知组件: {{ comp.type }}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { resolveComponent } from './registry.js';
|
||||
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
row: {
|
||||
|
||||
type: Object,
|
||||
|
||||
required: true
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const rowWrapperStyle = computed(() => {
|
||||
const s = props.row.rowStyle || {};
|
||||
const style = {};
|
||||
|
||||
// 宽度 (width)
|
||||
if (s.width !== undefined && s.width !== '') {
|
||||
const w = String(s.width).trim();
|
||||
if (w && w !== '100%') {
|
||||
const num = parseInt(w, 10);
|
||||
if (!isNaN(num) && num >= 1920 && !w.endsWith('%')) {
|
||||
style.width = '100%';
|
||||
} else {
|
||||
style.width = /^\d+$/.test(w) ? `${w}px` : w;
|
||||
style.maxWidth = '100%';
|
||||
}
|
||||
} else {
|
||||
style.width = '100%';
|
||||
}
|
||||
} else {
|
||||
style.width = '100%';
|
||||
}
|
||||
|
||||
// 高度 (height)
|
||||
if (s.height !== undefined && s.height !== '') {
|
||||
const h = String(s.height).trim();
|
||||
if (h && Number(h) > 0) {
|
||||
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
|
||||
style.height = hVal;
|
||||
style.minHeight = hVal;
|
||||
}
|
||||
}
|
||||
|
||||
// 外边距 (margin)
|
||||
const isNarrow = style.width !== '100%';
|
||||
if (Array.isArray(s.margin)) {
|
||||
style.marginTop = `${s.margin[0] || 0}px`;
|
||||
style.marginRight = s.margin[1] ? `${s.margin[1]}px` : (isNarrow ? 'auto' : '0px');
|
||||
style.marginBottom = `${s.margin[2] || 0}px`;
|
||||
style.marginLeft = s.margin[3] ? `${s.margin[3]}px` : (isNarrow ? 'auto' : '0px');
|
||||
} else if (isNarrow) {
|
||||
style.marginLeft = 'auto';
|
||||
style.marginRight = 'auto';
|
||||
}
|
||||
|
||||
// 内边距 (padding)
|
||||
if (Array.isArray(s.padding)) {
|
||||
style.paddingTop = `${s.padding[0] || 0}px`;
|
||||
style.paddingRight = `${s.padding[1] || 0}px`;
|
||||
style.paddingBottom = `${s.padding[2] || 0}px`;
|
||||
style.paddingLeft = `${s.padding[3] || 0}px`;
|
||||
}
|
||||
|
||||
// 圆角 (radius)
|
||||
if (Array.isArray(s.radius)) {
|
||||
style.borderTopLeftRadius = `${s.radius[0] || 0}px`;
|
||||
style.borderTopRightRadius = `${s.radius[1] || 0}px`;
|
||||
style.borderBottomRightRadius = `${s.radius[2] || 0}px`;
|
||||
style.borderBottomLeftRadius = `${s.radius[3] || 0}px`;
|
||||
}
|
||||
|
||||
// 行背景由各组件配置统一管理
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
|
||||
|
||||
const rowInnerFlexStyle = computed(() => {
|
||||
|
||||
const s = props.row.rowStyle || {};
|
||||
|
||||
const styleObj = {
|
||||
|
||||
display: 'flex',
|
||||
|
||||
flexDirection: 'row',
|
||||
|
||||
flexWrap: 'nowrap',
|
||||
|
||||
alignItems: s.alignItems || 'stretch',
|
||||
|
||||
justifyContent: s.justifyContent || 'flex-start',
|
||||
|
||||
width: '100%',
|
||||
|
||||
height: '100%',
|
||||
|
||||
gap: `${s.gap || 0}px`
|
||||
|
||||
};
|
||||
|
||||
if (s.height !== undefined && s.height !== '') {
|
||||
const h = String(s.height).trim();
|
||||
if (h && Number(h) > 0) {
|
||||
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
|
||||
styleObj.height = hVal;
|
||||
styleObj.minHeight = hVal;
|
||||
}
|
||||
}
|
||||
|
||||
return styleObj;
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
function getComponentCellStyle(style, rowStyle = {}, totalComps = 1) {
|
||||
|
||||
if (!style) return { flex: '1 1 0%' };
|
||||
|
||||
const s = {};
|
||||
|
||||
if (rowStyle?.height !== undefined && rowStyle?.height !== '') {
|
||||
const rh = String(rowStyle.height).trim();
|
||||
if (rh && Number(rh) > 0) {
|
||||
const rhVal = /^\d+$/.test(rh) ? `${rh}px` : rh;
|
||||
s.height = rhVal;
|
||||
s.minHeight = rhVal;
|
||||
}
|
||||
}
|
||||
const gap = Number(rowStyle?.gap) || 0;
|
||||
const totalGap = (totalComps > 1 && gap > 0) ? gap * (totalComps - 1) : 0;
|
||||
|
||||
if (style.widthRatio && style.widthRatio !== 'auto') {
|
||||
let w = style.widthRatio;
|
||||
if (w === '16.67%' || w === '16.6%' || w === '16.7%') {
|
||||
w = totalGap > 0 ? `calc((100% - ${totalGap}px) / 6)` : 'calc(100% / 6)';
|
||||
} else if (w === '66.67%' || w === '66.6%' || w === '66.7%') {
|
||||
w = totalGap > 0 ? `calc((100% - ${totalGap}px) * 2 / 3)` : 'calc(100% * 2 / 3)';
|
||||
} else if (w === '33.33%' || w === '33.3%') {
|
||||
w = totalGap > 0 ? `calc((100% - ${totalGap}px) / 3)` : 'calc(100% / 3)';
|
||||
} else if (w === '50%') {
|
||||
w = totalGap > 0 ? `calc((100% - ${totalGap}px) / 2)` : '50%';
|
||||
}
|
||||
s.flex = `0 0 ${w}`;
|
||||
|
||||
s.width = w;
|
||||
|
||||
s.maxWidth = w;
|
||||
|
||||
s.minWidth = '0';
|
||||
|
||||
} else {
|
||||
|
||||
s.flex = '1 1 0%';
|
||||
|
||||
s.minWidth = '0';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (Array.isArray(style.padding)) {
|
||||
|
||||
s.paddingTop = `${style.padding[0] || 0}px`;
|
||||
|
||||
s.paddingRight = `${style.padding[1] || 0}px`;
|
||||
|
||||
s.paddingBottom = `${style.padding[2] || 0}px`;
|
||||
|
||||
s.paddingLeft = `${style.padding[3] || 0}px`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (Array.isArray(style.radius)) {
|
||||
|
||||
s.borderTopLeftRadius = `${style.radius[0] || 0}px`;
|
||||
|
||||
s.borderTopRightRadius = `${style.radius[1] || 0}px`;
|
||||
|
||||
s.borderBottomRightRadius = `${style.radius[2] || 0}px`;
|
||||
|
||||
s.borderBottomLeftRadius = `${style.radius[3] || 0}px`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (Array.isArray(style.margin)) {
|
||||
|
||||
s.marginTop = `${style.margin[0] || 0}px`;
|
||||
|
||||
s.marginRight = `${style.margin[1] || 0}px`;
|
||||
|
||||
s.marginBottom = `${style.margin[2] || 0}px`;
|
||||
|
||||
s.marginLeft = `${style.margin[3] || 0}px`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (style.backgroundColor && style.backgroundColor !== 'transparent') {
|
||||
let bg = style.backgroundColor;
|
||||
if (/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*0\s*\)$/.test(bg.trim())) {
|
||||
bg = bg.trim().replace(/,\s*0\s*\)$/, ', 1)');
|
||||
}
|
||||
s.backgroundColor = bg;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (style.backgroundImage) {
|
||||
|
||||
s.backgroundImage = `url(${style.backgroundImage})`;
|
||||
|
||||
s.backgroundSize = 'cover';
|
||||
|
||||
s.backgroundRepeat = 'no-repeat';
|
||||
|
||||
s.backgroundPosition = 'center';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return s;
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.row-wrapper {
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
|
||||
|
||||
.row-inner-flex {
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
flex: 1;
|
||||
|
||||
height: 100%;
|
||||
|
||||
min-height: 0;
|
||||
|
||||
|
||||
|
||||
.component-cell {
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
height: 100%;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.unknown-component-placeholder {
|
||||
|
||||
padding: 20px;
|
||||
|
||||
background: #fef2f2;
|
||||
|
||||
border: 1px dashed #f87171;
|
||||
|
||||
color: #dc2626;
|
||||
|
||||
text-align: center;
|
||||
|
||||
font-size: 13px;
|
||||
|
||||
border-radius: 6px;
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
202
buyer/src/components/indexDecorate/components/ArticleContent.vue
Normal file
202
buyer/src/components/indexDecorate/components/ArticleContent.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="decor-article-content" :style="customStyle">
|
||||
<!-- 文章顶部标题区 -->
|
||||
<div class="article-header" v-if="options.showTitle !== false && options.title">
|
||||
<h2 class="article-main-title">{{ options.title }}</h2>
|
||||
<p class="article-sub-title" v-if="options.subtitle">{{ options.subtitle }}</p>
|
||||
|
||||
<div class="article-meta-bar" v-if="options.showMeta !== false">
|
||||
<span class="meta-tag">{{ options.tag || '品牌专栏' }}</span>
|
||||
<span class="meta-item author" v-if="options.author">作者:{{ options.author }}</span>
|
||||
<span class="meta-item date" v-if="options.date">发布于 {{ options.date }}</span>
|
||||
<span class="meta-item views">阅读 2.4k+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章正文排版区 (Rich Text Article Body) -->
|
||||
<div
|
||||
class="article-body-typography"
|
||||
:class="[`align-${options.textAlign || 'left'}`]"
|
||||
:style="{ fontSize: `${options.fontSize || 15}px` }"
|
||||
v-html="renderedContent"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const defaultArticleHtml = `
|
||||
<h3>关于我们 · 甄选美好生活</h3>
|
||||
<p>我们始终坚信,一件好的产品不仅能改善日常生活的品质,更传递着对美好生活的执着追求。自创立以来,我们严选全球顶尖供应链,深耕每一道生产工艺与质检环节,力求将兼具美学价值与极致实用性的商品送达每一位用户手中。</p>
|
||||
<blockquote>品质是我们的信仰,体验是我们的底线。从选材到交付,每一步都经得起时光检验。</blockquote>
|
||||
<h4>四大核心服务保障</h4>
|
||||
<ul>
|
||||
<li><strong>100% 官方正品</strong>:源头品牌直采,全链路防伪溯源,假一赔十保障。</li>
|
||||
<li><strong>极速顺丰冷链</strong>:全国多仓联动发货,核心城市支持当日达/次日达。</li>
|
||||
<li><strong>7天无理由退换</strong>:专业客服团队 7×24 小时贴心守护,售后无忧。</li>
|
||||
<li><strong>会员专享权益</strong>:尊享积分抵现、生日特权礼及专属品鉴沙龙。</li>
|
||||
</ul>
|
||||
<p>感谢一路相伴的每一位朋友,未来我们将持续探索更多优质生活场景,与您共同见证品质生活的美好绽放。</p>
|
||||
`.trim();
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
return props.options?.content || defaultArticleHtml;
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || '#ffffff',
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '24px 28px',
|
||||
borderRadius: Array.isArray(s.radius)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '12px'
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-article-content {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.article-header {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
.article-main-title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
line-height: 1.35;
|
||||
margin: 0 0 8px 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.article-sub-title {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.article-meta-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
|
||||
.meta-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.article-body-typography {
|
||||
color: #334155;
|
||||
line-height: 1.8;
|
||||
letter-spacing: 0.2px;
|
||||
|
||||
&.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
&.align-justify {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
:deep(h2), :deep(h3), :deep(h4) {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
:deep(h2) {
|
||||
font-size: 20px;
|
||||
border-left: 4px solid #2563eb;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
:deep(h3) {
|
||||
font-size: 18px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
:deep(h4) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:deep(p) {
|
||||
margin: 0 0 14px 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
:deep(blockquote) {
|
||||
margin: 16px 0;
|
||||
padding: 12px 18px;
|
||||
background: #f8fafc;
|
||||
border-left: 4px solid #3b82f6;
|
||||
border-radius: 0 8px 8px 0;
|
||||
color: #475569;
|
||||
font-style: italic;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
:deep(ul), :deep(ol) {
|
||||
margin: 12px 0 16px 20px;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
margin: 16px auto;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
:deep(strong) {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
142
buyer/src/components/indexDecorate/components/BrandWall.vue
Normal file
142
buyer/src/components/indexDecorate/components/BrandWall.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="decor-brand-wall" :style="customStyle">
|
||||
<div
|
||||
class="brand-grid"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(${actualColumns}, 1fr)`,
|
||||
gap: `${options.gap ?? 12}px`
|
||||
}"
|
||||
>
|
||||
<router-link
|
||||
v-for="(item, idx) in brandList"
|
||||
:key="idx"
|
||||
:to="item.url || '/goods'"
|
||||
class="brand-card"
|
||||
>
|
||||
<img
|
||||
v-if="item.img"
|
||||
:src="item.img"
|
||||
:alt="item.title || 'brand'"
|
||||
class="brand-img"
|
||||
/>
|
||||
<div v-else class="brand-placeholder">
|
||||
<span>{{ item.title || item.name || '品牌' }}</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const brandList = computed(() => {
|
||||
if (Array.isArray(props.options.items) && props.options.items.length > 0) {
|
||||
return props.options.items;
|
||||
}
|
||||
return [
|
||||
{ title: '数码潮玩 · 爆款立减500', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_1.png', url: '/goodsList?category=1' },
|
||||
{ title: '美妆奢宠 · 每满300减50', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_2.png', url: '/goodsList?category=2' },
|
||||
{ title: '运动潮品 · 爆款低至5折', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_3.png', url: '/goodsList?category=3' },
|
||||
{ title: '品质生活 · 限时第2件半价', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
|
||||
];
|
||||
});
|
||||
|
||||
const actualColumns = computed(() => {
|
||||
const baseCols = Number(props.options?.columns) || 4;
|
||||
const ratio = props.componentStyle?.widthRatio || '100%';
|
||||
if (ratio === '33.33%' || ratio === '33.3%') {
|
||||
return Math.min(baseCols, 2);
|
||||
}
|
||||
if (ratio === '50%') {
|
||||
return Math.min(baseCols, 2);
|
||||
}
|
||||
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
|
||||
return Math.min(baseCols, 3);
|
||||
}
|
||||
return baseCols;
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || 'transparent',
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '10px 0'
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-brand-wall {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.brand-grid {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
|
||||
.brand-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 2 / 1;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 80px;
|
||||
max-height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
border-color: #e2e8f0;
|
||||
|
||||
.brand-img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
.brand-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.brand-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
170
buyer/src/components/indexDecorate/components/Carousel.vue
Normal file
170
buyer/src/components/indexDecorate/components/Carousel.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div
|
||||
class="decor-carousel"
|
||||
:class="[
|
||||
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
|
||||
]"
|
||||
:style="customStyle"
|
||||
>
|
||||
<el-carousel
|
||||
height="100%"
|
||||
:autoplay="options.autoPlay !== false"
|
||||
:interval="Number(options.interval) || 4000"
|
||||
:arrow="options.showArrow !== false ? 'hover' : 'never'"
|
||||
:indicator-position="options.showIndicator !== false ? '' : 'none'"
|
||||
class="custom-carousel"
|
||||
>
|
||||
<el-carousel-item v-for="(slide, idx) in slidesList" :key="idx">
|
||||
<router-link :to="slide.url || '/'" class="slide-link">
|
||||
<img
|
||||
:src="slide.img"
|
||||
:alt="slide.title || 'banner'"
|
||||
class="slide-img"
|
||||
/>
|
||||
</router-link>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
rowStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const isMini = computed(() => props.componentStyle?.widthRatio === '16.67%');
|
||||
const isCompact = computed(() => props.componentStyle?.widthRatio === '33.33%');
|
||||
|
||||
const slidesList = computed(() => {
|
||||
if (Array.isArray(props.options.list) && props.options.list.length > 0) {
|
||||
return props.options.list;
|
||||
}
|
||||
if (Array.isArray(props.options.banners) && props.options.banners.length > 0) {
|
||||
return props.options.banners.map(b => ({
|
||||
title: b.title,
|
||||
img: b.image || b.img || b.url,
|
||||
url: b.url || b.link
|
||||
}));
|
||||
}
|
||||
return [
|
||||
{ title: '潮流数码', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_1.png', url: '/goodsList?category=1' },
|
||||
{ title: '奢宠美妆', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_2.png', url: '/goodsList?category=2' },
|
||||
{ title: '运动潮流', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_3.png', url: '/goodsList?category=3' },
|
||||
{ title: '居家生活', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_4.png', url: '/goodsList?category=4' }
|
||||
];
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
const fit = props.options.fitMode || 'cover';
|
||||
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
|
||||
|
||||
const styleObj = {
|
||||
borderRadius: Array.isArray(s.radius) && s.radius.some(r => r > 0)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '8px',
|
||||
backgroundColor: (s.backgroundColor && s.backgroundColor !== 'transparent') ? s.backgroundColor : '#ffffff',
|
||||
'--carousel-fit': fit,
|
||||
height: '100%',
|
||||
minHeight: hasRowHeight ? '0' : '420px'
|
||||
};
|
||||
|
||||
if (props.options.aspectRatio === 'custom' && props.options.height) {
|
||||
styleObj.height = `${props.options.height}px`;
|
||||
styleObj.minHeight = `${props.options.height}px`;
|
||||
}
|
||||
return styleObj;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-carousel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.custom-carousel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
|
||||
:deep(.el-carousel__container) {
|
||||
height: 100% !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicators--horizontal) {
|
||||
bottom: 12px;
|
||||
z-index: 10;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 10px;
|
||||
padding: 3px 8px;
|
||||
backdrop-filter: blur(2px);
|
||||
|
||||
.el-carousel__indicator {
|
||||
padding: 3px;
|
||||
|
||||
.el-carousel__button {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
opacity: 1;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
&.is-active .el-carousel__button {
|
||||
width: 16px;
|
||||
border-radius: 3px;
|
||||
background-color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slide-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #ffffff;
|
||||
|
||||
.slide-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: var(--carousel-fit, cover);
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-mini {
|
||||
:deep(.el-carousel__indicators--horizontal) {
|
||||
bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<div
|
||||
class="decor-category-mega-menu"
|
||||
:class="{ 'is-hovering': panelVisible && activeChildren.length }"
|
||||
:style="customStyle"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<!-- 顶部标题:分类 / 商品分类 -->
|
||||
<div class="menu-header">
|
||||
<span class="header-title">{{ options.title || '商品分类' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 垂直一级类目列表 -->
|
||||
<div class="category-list">
|
||||
<div
|
||||
v-for="(cat, idx) in displayCategories"
|
||||
:key="cat.id || idx"
|
||||
class="cat-item"
|
||||
:class="{ 'is-active': activeIndex === idx && panelVisible }"
|
||||
:style="{
|
||||
'--text-color': options.textColor || '#334155',
|
||||
'--active-color': options.activeColor || '#409EFF',
|
||||
'--font-size': `${options.fontSize || 13}px`
|
||||
}"
|
||||
@mouseenter="handleMouseEnter(idx, cat)"
|
||||
@click="goGoodsList(cat.id)"
|
||||
>
|
||||
<span class="cat-name">{{ cat.name }}</span>
|
||||
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧悬浮超级大面板 (Mega Menu) -->
|
||||
<transition name="fade-flyout">
|
||||
<div
|
||||
v-if="panelVisible && activeChildren.length"
|
||||
class="mega-flyout-panel"
|
||||
@mouseenter="handlePanelEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<!-- 顶部快捷推荐标签(展示前8个二级分类) -->
|
||||
<div class="flyout-top-tags" v-if="activeChildren && activeChildren.length">
|
||||
<span
|
||||
v-for="(sub, sIdx) in activeChildren.slice(0, 8)"
|
||||
:key="sub.id || sIdx"
|
||||
class="top-tag-pill"
|
||||
@click.stop="goGoodsList(sub.id, sub.parentId)"
|
||||
>
|
||||
{{ sub.name }}
|
||||
<el-icon class="pill-arrow"><ArrowRight /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 详细分类二级与三级平铺网格 -->
|
||||
<div class="flyout-content-scroll">
|
||||
<div
|
||||
v-for="(sub, sIdx) in activeChildren"
|
||||
:key="sub.id || sIdx"
|
||||
class="cat-row"
|
||||
>
|
||||
<!-- 二级分类标题 -->
|
||||
<div
|
||||
class="lvl2-title"
|
||||
@click.stop="goGoodsList(sub.id, sub.parentId)"
|
||||
>
|
||||
<span class="lvl2-name">{{ sub.name }}</span>
|
||||
<el-icon class="lvl2-arrow"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 三级分类细项 -->
|
||||
<div class="lvl3-tags-wrap">
|
||||
<span
|
||||
v-for="(leaf, lIdx) in (sub.children || [])"
|
||||
:key="leaf.id || lIdx"
|
||||
class="lvl3-tag"
|
||||
@click.stop="goGoodsList(leaf.id, sub.id, sub.parentId)"
|
||||
>
|
||||
{{ leaf.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ArrowRight } from '@element-plus/icons-vue';
|
||||
import { getCategory } from '@/api/goods';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
rowStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// 默认真实类目备选数据(防止弱网下未加载出接口时空白)
|
||||
const fallbackCategories = [
|
||||
{
|
||||
id: '1348576427264204941',
|
||||
name: '电脑办公',
|
||||
children: [
|
||||
{ id: '1-1', name: '电脑整机', parentId: '1348576427264204941', children: [{ id: '1-1-1', name: '笔记本' }, { id: '1-1-2', name: '游戏本' }, { id: '1-1-3', name: '台式机' }, { id: '1-1-4', name: '平板电脑' }] },
|
||||
{ id: '1-2', name: '电脑配件', parentId: '1348576427264204941', children: [{ id: '1-2-1', name: '显示器' }, { id: '1-2-2', name: '显卡' }, { id: '1-2-3', name: '机械键盘' }, { id: '1-2-4', name: '鼠标' }] },
|
||||
{ id: '1-3', name: '手机通讯', parentId: '1348576427264204941', children: [{ id: '1-3-1', name: '5G智能手机' }, { id: '1-3-2', name: '拍照手机' }, { id: '1-3-3', name: '蓝牙耳机' }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '1348576427268399553',
|
||||
name: '家用电器',
|
||||
children: [
|
||||
{ id: '2-1', name: '大家电', parentId: '1348576427268399553', children: [{ id: '2-1-1', name: '平板电视' }, { id: '2-1-2', name: '风冷冰箱' }, { id: '2-1-3', name: '滚筒洗衣机' }, { id: '2-1-4', name: '变频空调' }] },
|
||||
{ id: '2-2', name: '生活电器', parentId: '1348576427268399553', children: [{ id: '2-2-1', name: '空气净化器' }, { id: '2-2-2', name: '扫地机器人' }, { id: '2-2-3', name: '吸尘器' }] },
|
||||
{ id: '2-3', name: '厨房小电', parentId: '1348576427268399553', children: [{ id: '2-3-1', name: '电饭煲' }, { id: '2-3-2', name: '复古咖啡机' }, { id: '2-3-3', name: '破壁料理机' }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '1348576427260010496',
|
||||
name: '服装鞋包',
|
||||
children: [
|
||||
{ id: '3-1', name: '女装流行', parentId: '1348576427260010496', children: [{ id: '3-1-1', name: '连衣裙' }, { id: '3-1-2', name: '羊绒大衣' }, { id: '3-1-3', name: '早秋衬衫' }, { id: '3-1-4', name: '休闲裤' }] },
|
||||
{ id: '3-2', name: '男装精选', parentId: '1348576427260010496', children: [{ id: '3-2-1', name: '商务夹克' }, { id: '3-2-2', name: '休闲T恤' }, { id: '3-2-3', name: '免烫衬衫' }, { id: '3-2-4', name: '牛仔裤' }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '1348576427268399363',
|
||||
name: '食品生鲜',
|
||||
children: [
|
||||
{ id: '4-1', name: '生鲜水果', parentId: '1348576427268399363', children: [{ id: '4-1-1', name: '进口车厘子' }, { id: '4-1-2', name: '红富士苹果' }, { id: '4-1-3', name: '佳沛奇异果' }] },
|
||||
{ id: '4-2', name: '休闲零食', parentId: '1348576427268399363', children: [{ id: '4-2-1', name: '每日坚果' }, { id: '4-2-2', name: '烘焙饼干' }, { id: '4-2-3', name: '巧克力糖果' }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '1348576427268399172',
|
||||
name: '精品箱包',
|
||||
children: [
|
||||
{ id: '5-1', name: '潮流女包', parentId: '1348576427268399172', children: [{ id: '5-1-1', name: '真皮单肩包' }, { id: '5-1-2', name: '托特大包' }, { id: '5-1-3', name: '斜挎小方包' }] },
|
||||
{ id: '5-2', name: '商务男包', parentId: '1348576427268399172', children: [{ id: '5-2-1', name: '双肩电脑包' }, { id: '5-2-2', name: '公文手提包' }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '1348576427268399318',
|
||||
name: '美妆个护',
|
||||
children: [
|
||||
{ id: '6-1', name: '面部护肤', parentId: '1348576427268399318', children: [{ id: '6-1-1', name: '修护精华' }, { id: '6-1-2', name: '补水面膜' }, { id: '6-1-3', name: '爽肤水' }, { id: '6-1-4', name: '眼霜' }] },
|
||||
{ id: '6-2', name: '彩妆香水', parentId: '1348576427268399318', children: [{ id: '6-2-1', name: '大牌口红' }, { id: '6-2-2', name: '持久气垫' }, { id: '6-2-3', name: '沙龙香氛' }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '1348576427268399274',
|
||||
name: '运动户外',
|
||||
children: [
|
||||
{ id: '7-1', name: '运动鞋服', parentId: '1348576427268399274', children: [{ id: '7-1-1', name: '缓震跑鞋' }, { id: '7-1-2', name: '专业篮球鞋' }, { id: '7-1-3', name: '运动速干衣' }] },
|
||||
{ id: '7-2', name: '户外装备', parentId: '1348576427268399274', children: [{ id: '7-2-1', name: '露营帐篷' }, { id: '7-2-2', name: '登山包' }, { id: '7-2-3', name: '运动手表' }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '1348576427268399217',
|
||||
name: '家居家装',
|
||||
children: [
|
||||
{ id: '8-1', name: '家纺布艺', parentId: '1348576427268399217', children: [{ id: '8-1-1', name: '四件套' }, { id: '8-1-2', name: '乳胶枕' }, { id: '8-1-3', name: '羽绒被' }] },
|
||||
{ id: '8-2', name: '现代家具', parentId: '1348576427268399217', children: [{ id: '8-2-1', name: '人体工学椅' }, { id: '8-2-2', name: '升降电脑桌' }, { id: '8-2-3', name: '真皮沙发' }] }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const systemCategories = ref([]);
|
||||
const activeIndex = ref(-1);
|
||||
const activeCategory = ref(null);
|
||||
const panelVisible = ref(false);
|
||||
|
||||
const isCustomSource = computed(() => {
|
||||
// 1. 如果明确指定为系统分类,始终按系统商品分类处理
|
||||
if (props.options?.source === 'system') return false;
|
||||
// 2. 如果配置为 custom,但分类条目依然是初始未编辑的 4 个占位条目,依然展示真实系统分类
|
||||
if (props.options?.source === 'custom') {
|
||||
if (Array.isArray(props.options?.categories) && props.options.categories.length === 4) {
|
||||
const names = props.options.categories.map(c => c.name || c.title || '');
|
||||
const isPlaceholder = names.some(n => n.includes('精选')) &&
|
||||
names.some(n => n.includes('新品')) &&
|
||||
names.some(n => n.includes('企业')) &&
|
||||
names.some(n => n.includes('领券'));
|
||||
if (isPlaceholder) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// 3. 其它默认情况均默认展示系统商品分类
|
||||
return false;
|
||||
});
|
||||
|
||||
const displayCategories = computed(() => {
|
||||
if (isCustomSource.value && Array.isArray(props.options?.categories) && props.options.categories.length) {
|
||||
return props.options.categories.map(c => ({
|
||||
...c,
|
||||
name: c.name || c.title || ''
|
||||
}));
|
||||
}
|
||||
if (systemCategories.value && systemCategories.value.length) {
|
||||
return systemCategories.value;
|
||||
}
|
||||
return fallbackCategories;
|
||||
});
|
||||
|
||||
const activeChildren = computed(() => {
|
||||
if (!activeCategory.value) return [];
|
||||
if (Array.isArray(activeCategory.value.children) && activeCategory.value.children.length) {
|
||||
return activeCategory.value.children;
|
||||
}
|
||||
if (Array.isArray(activeCategory.value.subCategories) && activeCategory.value.subCategories.length) {
|
||||
return activeCategory.value.subCategories.map((s, idx) => ({
|
||||
id: s.id || `sub_${idx}`,
|
||||
name: s.name,
|
||||
parentId: activeCategory.value.id,
|
||||
children: (s.items || []).map((it, iIdx) => ({
|
||||
id: `leaf_${idx}_${iIdx}`,
|
||||
name: typeof it === 'string' ? it : it.name,
|
||||
parentId: s.id
|
||||
}))
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
function handleMouseEnter(idx, cat) {
|
||||
activeIndex.value = idx;
|
||||
activeCategory.value = cat;
|
||||
if (cat.children && cat.children.length) {
|
||||
panelVisible.value = true;
|
||||
} else if (cat.subCategories && cat.subCategories.length) {
|
||||
panelVisible.value = true;
|
||||
} else {
|
||||
panelVisible.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePanelEnter() {
|
||||
panelVisible.value = true;
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
panelVisible.value = false;
|
||||
activeIndex.value = -1;
|
||||
activeCategory.value = null;
|
||||
}
|
||||
|
||||
function goGoodsList(id, secondId, firstId) {
|
||||
const arr = [firstId, secondId, id];
|
||||
if (!arr[1]) arr.splice(0, 2);
|
||||
if (!arr[0]) arr.shift();
|
||||
router.push({
|
||||
path: '/goodsList',
|
||||
query: { categoryId: arr.filter(Boolean).join(',') }
|
||||
});
|
||||
}
|
||||
|
||||
function loadCategories() {
|
||||
// 1. 优先读取 localStorage 缓存快速渲染
|
||||
try {
|
||||
const cached = localStorage.getItem('category');
|
||||
const exp = localStorage.getItem('category_expiration_time');
|
||||
if (cached && exp && new Date() <= Number(exp)) {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (Array.isArray(parsed) && parsed.length) {
|
||||
systemCategories.value = parsed;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 2. 调接口拉取最新分类
|
||||
getCategory(0).then(res => {
|
||||
if (res.success && Array.isArray(res.result) && res.result.length) {
|
||||
systemCategories.value = res.result;
|
||||
const expTime = new Date().setHours(new Date().getHours() + 1);
|
||||
localStorage.setItem('category_expiration_time', expTime);
|
||||
localStorage.setItem('category', JSON.stringify(res.result));
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('获取分类失败', err);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories();
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
|
||||
return {
|
||||
backgroundColor: (s.backgroundColor && s.backgroundColor !== 'transparent') ? s.backgroundColor : '#ffffff',
|
||||
borderRadius: Array.isArray(s.radius) && s.radius.some(r => r > 0)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '8px',
|
||||
height: '100%',
|
||||
minHeight: hasRowHeight ? '0' : '420px'
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-category-mega-menu {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
|
||||
&.is-hovering {
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
height: 42px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
flex-shrink: 0;
|
||||
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
padding: 4px 0 8px;
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
.cat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
.cat-name {
|
||||
font-size: var(--font-size, 13px);
|
||||
color: var(--text-color, #334155);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
transition: transform 0.15s ease, color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
background: #ecf5ff;
|
||||
|
||||
.cat-name {
|
||||
color: var(--active-color, #409EFF);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
color: var(--active-color, #409EFF);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mega-flyout-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
width: 780px;
|
||||
min-height: 100%;
|
||||
max-height: 520px;
|
||||
background: #ffffff;
|
||||
z-index: 1100;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: none;
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 16px 20px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.flyout-top-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.top-tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
.pill-arrow {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #ecf5ff;
|
||||
color: #409EFF;
|
||||
|
||||
.pill-arrow {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flyout-content-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cat-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed #f1f5f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.lvl2-title {
|
||||
width: 96px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
padding-top: 1px;
|
||||
|
||||
.lvl2-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lvl2-arrow {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
.lvl2-arrow {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lvl3-tags-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
|
||||
.lvl3-tag {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
line-height: 1.6;
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fade-flyout-enter-active,
|
||||
.fade-flyout-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.fade-flyout-enter-from,
|
||||
.fade-flyout-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
</style>
|
||||
199
buyer/src/components/indexDecorate/components/CategoryNav.vue
Normal file
199
buyer/src/components/indexDecorate/components/CategoryNav.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="decor-category-nav" :style="customStyle">
|
||||
<div class="nav-inner">
|
||||
<!-- 1. 全部分类入口 (当开启显示全部分类时) -->
|
||||
<div
|
||||
v-if="options.showAllCategory"
|
||||
class="all-category-btn"
|
||||
:style="{ backgroundColor: options.activeColor || '#e11d48' }"
|
||||
>
|
||||
<img
|
||||
v-if="options.allCategoryIcon"
|
||||
:src="options.allCategoryIcon"
|
||||
class="all-icon-img"
|
||||
alt="icon"
|
||||
/>
|
||||
<el-icon v-else class="all-icon-svg"><Menu /></el-icon>
|
||||
<span class="all-text">{{ options.allCategoryText || '全部分类' }}</span>
|
||||
<el-icon class="all-arrow"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 2. 分类导航项水平列表 -->
|
||||
<ul class="nav-items-ul">
|
||||
<li
|
||||
v-for="(item, idx) in options.navItems"
|
||||
:key="idx"
|
||||
class="nav-li"
|
||||
>
|
||||
<router-link
|
||||
:to="item.url || '/'"
|
||||
class="nav-link"
|
||||
:style="{
|
||||
'--text-color': options.textColor || '#334155',
|
||||
'--active-color': options.activeColor || '#e11d48'
|
||||
}"
|
||||
active-class="is-active"
|
||||
>
|
||||
<img v-if="item.img" :src="item.img" class="cat-icon" alt="cat-icon" />
|
||||
<span class="cat-title">{{ item.title }}</span>
|
||||
<span
|
||||
class="badge"
|
||||
v-if="item.badge"
|
||||
:style="{ backgroundColor: options.activeColor || '#e11d48' }"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Menu, ArrowDown } from '@element-plus/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || '#ffffff',
|
||||
height: `${s.height || 42}px`
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-category-nav {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.nav-inner {
|
||||
max-width: 1600px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
// 全部分类按钮
|
||||
.all-category-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
padding: 0 18px;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin-right: 20px;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.all-icon-img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.all-icon-svg {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.all-text {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.all-arrow {
|
||||
font-size: 12px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-items-ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-li {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.nav-link {
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-color, #334155);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s;
|
||||
|
||||
.cat-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover, &.is-active {
|
||||
color: var(--active-color, #e11d48) !important;
|
||||
|
||||
.cat-icon {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: -12px;
|
||||
color: #ffffff;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
572
buyer/src/components/indexDecorate/components/ChannelFloor.vue
Normal file
572
buyer/src/components/indexDecorate/components/ChannelFloor.vue
Normal file
@@ -0,0 +1,572 @@
|
||||
<template>
|
||||
<div class="decor-channel-floor" :style="customStyle">
|
||||
<!-- 1. 标题区 -->
|
||||
<div class="channel-floor-header" v-if="options.showTitle !== false && options.title">
|
||||
<div class="header-main-title">
|
||||
<span class="title-pill"></span>
|
||||
<h3 class="title-text">{{ options.title }}</h3>
|
||||
<span class="subtitle-text" v-if="options.subtitle">{{ options.subtitle }}</span>
|
||||
</div>
|
||||
<router-link :to="options.heroHref || '/goods'" class="header-more">
|
||||
探索更多场景 >
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 2. 场景频道组合布局:左侧主视觉+活动入口 + 右侧商品网格 -->
|
||||
<div class="channel-floor-body" :class="{ 'no-hero': !showHero }">
|
||||
<!-- 左侧主视觉卡片 (Hero Card) -->
|
||||
<div
|
||||
class="channel-hero-card"
|
||||
:class="[`style-${options.heroStyle || 'gradient'}`]"
|
||||
v-if="showHero"
|
||||
>
|
||||
<div class="hero-top-info">
|
||||
<div class="hero-badge">SCENE CHANNEL</div>
|
||||
<h3 class="hero-title">{{ options.heroTitle || '品质生活馆' }}</h3>
|
||||
<p class="hero-sub">{{ options.heroSubtitle || '精选场景好物,一站购齐' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-image-wrap">
|
||||
<img :src="heroImg" alt="channel hero" class="hero-img" />
|
||||
<router-link :to="options.heroHref || '/goods'" class="hero-cta-btn">
|
||||
<span>{{ options.heroButtonText || '进入频道' }}</span>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 专属活动入口 (Promos) -->
|
||||
<div class="hero-promos-wrap" v-if="promosList.length">
|
||||
<router-link
|
||||
v-for="(p, pIdx) in promosList"
|
||||
:key="pIdx"
|
||||
:to="p.url || '/goods'"
|
||||
class="hero-promo-item"
|
||||
>
|
||||
<span class="promo-tag" v-if="p.tag">{{ p.tag }}</span>
|
||||
<div class="promo-text-box">
|
||||
<span class="promo-title">{{ p.title }}</span>
|
||||
<span class="promo-desc">{{ p.desc }}</span>
|
||||
</div>
|
||||
<span class="promo-arrow">></span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧商品网格 (Scene Goods Grid) -->
|
||||
<div
|
||||
class="channel-goods-grid"
|
||||
:class="`cols-${rightColumns}`"
|
||||
:style="{ gridTemplateColumns: `repeat(${rightColumns}, 1fr)` }"
|
||||
>
|
||||
<div
|
||||
v-for="(g, idx) in displayGoods"
|
||||
:key="g.id || idx"
|
||||
class="scene-goods-card"
|
||||
>
|
||||
<router-link :to="`/goods/${g.id || 1}`" class="card-thumb-wrap">
|
||||
<img :src="g.thumbnail || defaultThumb" :alt="g.goodsName" class="goods-img" />
|
||||
<span class="scene-tag">场景精选</span>
|
||||
</router-link>
|
||||
|
||||
<div class="card-info">
|
||||
<h4 class="goods-name">
|
||||
<router-link :to="`/goods/${g.id || 1}`">{{ g.goodsName }}</router-link>
|
||||
</h4>
|
||||
<div class="price-action-row">
|
||||
<div class="price-box">
|
||||
<span class="sym">¥</span>
|
||||
<span class="val">{{ g.price }}</span>
|
||||
<span class="orig" v-if="g.originalPrice">¥{{ g.originalPrice }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cart-btn"
|
||||
title="立即加购"
|
||||
@click="addToCart(g)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function handleNavigate(url) {
|
||||
const target = url ? String(url).trim() : '/goods';
|
||||
if (!target) return;
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
window.open(target, '_blank');
|
||||
} else {
|
||||
router.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
|
||||
|
||||
const heroImg = computed(() => {
|
||||
return props.options?.heroImage || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/banner-floor/floor_banner_1.png';
|
||||
});
|
||||
|
||||
const showHero = computed(() => {
|
||||
const ratio = props.componentStyle?.widthRatio || '100%';
|
||||
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '33.33%' || ratio === '33.3%') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const rightColumns = computed(() => {
|
||||
const ratio = props.componentStyle?.widthRatio || '100%';
|
||||
if (ratio === '16.67%' || ratio === '16.6%') return 1;
|
||||
if (ratio === '33.33%' || ratio === '33.3%') return 2;
|
||||
if (ratio === '50%') return showHero.value ? 2 : 3;
|
||||
if (ratio === '66.67%' || ratio === '66.6%') return showHero.value ? 3 : 4;
|
||||
return 3;
|
||||
});
|
||||
|
||||
const promosList = computed(() => {
|
||||
if (Array.isArray(props.options?.promos) && props.options.promos.length > 0) {
|
||||
return props.options.promos;
|
||||
}
|
||||
return [
|
||||
{ title: '大牌补贴', desc: '领券最高减100', tag: '限时', url: '/goods' },
|
||||
{ title: '新品驾到', desc: '官方正品首发', tag: '新品', url: '/goods' }
|
||||
];
|
||||
});
|
||||
|
||||
const defaultGoods = [
|
||||
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
|
||||
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
|
||||
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
|
||||
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
|
||||
{ id: '5', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
|
||||
{ id: '6', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' }
|
||||
];
|
||||
|
||||
const displayGoods = computed(() => {
|
||||
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
|
||||
return props.options.goods;
|
||||
}
|
||||
const count = props.options?.goodsCount || 6;
|
||||
return defaultGoods.slice(0, count);
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || '#ffffff',
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '16px 20px',
|
||||
borderRadius: Array.isArray(s.radius)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '12px'
|
||||
};
|
||||
});
|
||||
|
||||
function addToCart(item) {
|
||||
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-channel-floor {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.channel-floor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 2px solid #f1f5f9;
|
||||
|
||||
.header-main-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.title-pill {
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: linear-gradient(180deg, #6366f1, #4f46e5);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle-text {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-more {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #4f46e5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.channel-floor-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
|
||||
&.no-hero {
|
||||
.channel-goods-grid {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-hero-card {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 18px 16px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.style-gradient {
|
||||
background: linear-gradient(145deg, #1e1b4b, #312e81 60%, #4338ca);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&.style-plain {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #1e293b;
|
||||
|
||||
.hero-badge {
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.hero-cta-btn {
|
||||
background: #4f46e5;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-top-info {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.hero-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(4px);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 4px 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-image-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
|
||||
.hero-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
&:hover .hero-img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.hero-cta-btn {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
background: #ffffff;
|
||||
color: #312e81;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #4f46e5;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero-promos-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
|
||||
.hero-promo-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.promo-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: #ef4444;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.promo-text-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
.promo-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.promo-desc {
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.promo-arrow {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.channel-goods-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
||||
.scene-goods-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-thumb-wrap {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.goods-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
&:hover .goods-img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.scene-tag {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
background: #4f46e5;
|
||||
color: #ffffff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
.goods-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
margin: 0 0 6px 0;
|
||||
|
||||
a {
|
||||
color: #1e293b;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: #4f46e5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.price-action-row {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.price-box {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1px;
|
||||
|
||||
.sym {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.val {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.orig {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
text-decoration: line-through;
|
||||
margin-left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #4f46e5;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
277
buyer/src/components/indexDecorate/components/ChannelGrid.vue
Normal file
277
buyer/src/components/indexDecorate/components/ChannelGrid.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="decor-channel-grid" :style="customStyle">
|
||||
<div
|
||||
class="grid-container"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
||||
gap: `${options.gap ?? 12}px`
|
||||
}"
|
||||
>
|
||||
<router-link
|
||||
v-for="(item, idx) in options.items"
|
||||
:key="idx"
|
||||
:to="item.url || '/goods'"
|
||||
class="channel-card"
|
||||
:class="[
|
||||
`is-${layoutMode}`,
|
||||
{ 'is-compact': isCompact }
|
||||
]"
|
||||
>
|
||||
<!-- 图标/图片 -->
|
||||
<div
|
||||
class="card-thumb"
|
||||
:style="{ borderRadius: `${options.imageRadius ?? 10}px` }"
|
||||
>
|
||||
<img
|
||||
v-if="item.img"
|
||||
:src="item.img"
|
||||
:alt="item.title"
|
||||
class="thumb-img"
|
||||
/>
|
||||
<div v-else class="thumb-placeholder">
|
||||
<span>{{ (item.title || '频').charAt(0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主标题与副标题 -->
|
||||
<div class="card-info">
|
||||
<div class="card-title">{{ item.title || '频道名称' }}</div>
|
||||
<div class="card-sub">{{ item.subtitle || '副标题文案' }}</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
// 排版宽度比例 (整行: 100%, 三分之二: 66.67%, 一半: 50%, 三分之一: 33.33%, 六分之一: 16.67%)
|
||||
const widthRatio = computed(() => {
|
||||
return props.componentStyle?.widthRatio || '100%';
|
||||
});
|
||||
|
||||
// 响应式列数:50%、33.33%、16.67% 为 2 列;100%、66.67% 为 4 列
|
||||
const gridColumns = computed(() => {
|
||||
const w = widthRatio.value;
|
||||
if (w === '50%' || w === '33.33%' || w === '33.3%' || w === '16.67%' || w === '16.6%') {
|
||||
return 2;
|
||||
}
|
||||
return 4;
|
||||
});
|
||||
|
||||
// 卡片内部排布方式:
|
||||
// 100% (整行)、50% (一半) -> 水平横向 (左图右文)
|
||||
// 66.67% (三分之二)、33.33% (三分之一)、16.67% (六分之一) -> 垂直纵向 (上图下文居中)
|
||||
const layoutMode = computed(() => {
|
||||
const w = widthRatio.value;
|
||||
if (w === '66.67%' || w === '66.6%' || w === '33.33%' || w === '33.3%' || w === '16.67%' || w === '16.6%') {
|
||||
return 'vertical';
|
||||
}
|
||||
return 'horizontal';
|
||||
});
|
||||
|
||||
// 六分之一紧凑模式
|
||||
const isCompact = computed(() => {
|
||||
const w = widthRatio.value;
|
||||
return w === '16.67%' || w === '16.6%';
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || 'transparent',
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '10px 0'
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-channel-grid {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.grid-container {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.channel-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||
box-sizing: border-box;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||
border-color: #e2e8f0;
|
||||
|
||||
.card-thumb .thumb-img {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.card-info .card-title {
|
||||
color: #ea580c;
|
||||
}
|
||||
}
|
||||
|
||||
.card-thumb {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
|
||||
.thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #f97316, #ea580c);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 水平横向排布 (整行 100%、一半 50%)
|
||||
&.is-horizontal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
|
||||
.card-thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
gap: 3px;
|
||||
text-align: left;
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 垂直纵向排布 (三分之二 66.67%、三分之一 33.33%、六分之一 16.67%)
|
||||
&.is-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
padding: 14px 10px 12px 10px;
|
||||
|
||||
.card-thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
width: 100%;
|
||||
gap: 2px;
|
||||
text-align: center;
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 六分之一紧凑模式
|
||||
&.is-compact {
|
||||
padding: 10px 6px 8px 6px;
|
||||
gap: 5px;
|
||||
|
||||
.card-thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
gap: 1px;
|
||||
|
||||
.card-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 11px;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
184
buyer/src/components/indexDecorate/components/CouponList.vue
Normal file
184
buyer/src/components/indexDecorate/components/CouponList.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="decor-coupon-list" :class="`skin-${options.skin || 'red'}`" :style="customStyle">
|
||||
<div
|
||||
class="coupon-grid"
|
||||
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
|
||||
>
|
||||
<div
|
||||
v-for="(cp, idx) in coupons"
|
||||
:key="idx"
|
||||
class="coupon-card"
|
||||
>
|
||||
<div class="card-left">
|
||||
<div class="amount-wrap">
|
||||
<span class="symbol">¥</span>
|
||||
<span class="amount">{{ cp.price }}</span>
|
||||
</div>
|
||||
<span class="threshold">{{ cp.consumeThreshold ? `满 ${cp.consumeThreshold} 可用` : '无门槛' }}</span>
|
||||
</div>
|
||||
<div class="card-right">
|
||||
<span class="scope">{{ cp.title }}</span>
|
||||
<button type="button" class="claim-btn" @click="handleClaim(cp)">
|
||||
立即领取
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const defaultCoupons = [
|
||||
{ id: '1', price: 20, consumeThreshold: 199, title: '全品类通用券', limitText: '全场通用 无门槛限制' },
|
||||
{ id: '2', price: 50, consumeThreshold: 399, title: '数码家电专享券', limitText: '数码/手机/家电可用' },
|
||||
{ id: '3', price: 100, consumeThreshold: 699, title: '大牌美妆特惠券', limitText: '美妆奢护专区可用' },
|
||||
{ id: '4', price: 10, consumeThreshold: 99, title: '新客首单立减券', limitText: '首次下单专享福利' }
|
||||
];
|
||||
|
||||
const actualColumns = computed(() => {
|
||||
const baseCols = Number(props.options?.columns) || 4;
|
||||
const ratio = props.componentStyle?.widthRatio || '100%';
|
||||
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '16.7%') {
|
||||
return 1;
|
||||
}
|
||||
if (ratio === '33.33%' || ratio === '33.3%') {
|
||||
return Math.min(baseCols, 2);
|
||||
}
|
||||
if (ratio === '50%') {
|
||||
return Math.min(baseCols, 2);
|
||||
}
|
||||
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
|
||||
return Math.min(baseCols, 3);
|
||||
}
|
||||
return baseCols;
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || 'transparent',
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '10px 0',
|
||||
borderRadius: Array.isArray(s.radius)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '0'
|
||||
};
|
||||
});
|
||||
|
||||
const coupons = computed(() => {
|
||||
if (Array.isArray(props.options?.coupons) && props.options.coupons.length > 0) {
|
||||
return props.options.coupons;
|
||||
}
|
||||
const count = props.options?.columns || 4;
|
||||
return defaultCoupons.slice(0, count);
|
||||
});
|
||||
|
||||
function handleClaim(cp) {
|
||||
ElMessage.success(`领取成功!¥${cp.price} 优惠券已放入您的卡券包`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-coupon-list {
|
||||
width: 100%;
|
||||
|
||||
.coupon-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
.coupon-card {
|
||||
display: flex;
|
||||
height: 90px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-left {
|
||||
width: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
padding: 8px;
|
||||
|
||||
.amount-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.symbol { font-size: 14px; font-weight: 700; }
|
||||
.amount { font-size: 28px; font-weight: 800; }
|
||||
}
|
||||
|
||||
.threshold {
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.card-right {
|
||||
flex: 1;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
|
||||
.scope {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.claim-btn {
|
||||
align-self: flex-start;
|
||||
padding: 4px 12px;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover { opacity: 0.85; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.skin-red {
|
||||
.coupon-card .card-left { background: linear-gradient(135deg, #f43f5e, #e11d48); }
|
||||
.claim-btn { background: #ffe4e6; color: #e11d48; }
|
||||
}
|
||||
|
||||
&.skin-dark {
|
||||
.coupon-card .card-left { background: linear-gradient(135deg, #334155, #0f172a); }
|
||||
.claim-btn { background: #f1f5f9; color: #0f172a; }
|
||||
}
|
||||
|
||||
&.skin-gradient {
|
||||
.coupon-card .card-left { background: linear-gradient(135deg, #8b5cf6, #ec4899); }
|
||||
.claim-btn { background: #fdf2f8; color: #db2777; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
165
buyer/src/components/indexDecorate/components/FooterNav.vue
Normal file
165
buyer/src/components/indexDecorate/components/FooterNav.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="decor-footer-nav" :style="customStyle">
|
||||
<div class="footer-inner">
|
||||
<!-- 1. 服务保障条 -->
|
||||
<div class="assurance-row" v-if="options.showAssurance && options.assurances?.length">
|
||||
<div
|
||||
v-for="(item, idx) in options.assurances"
|
||||
:key="idx"
|
||||
class="assurance-col"
|
||||
>
|
||||
<div class="code-badge">{{ item.code }}</div>
|
||||
<div class="text-info">
|
||||
<span class="main-t">{{ item.title }}</span>
|
||||
<span class="sub-t">{{ item.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 服务指南与链接列 -->
|
||||
<div class="guides-row" v-if="options.guideGroups?.length">
|
||||
<div
|
||||
v-for="(group, gIdx) in options.guideGroups"
|
||||
:key="gIdx"
|
||||
class="guide-col"
|
||||
>
|
||||
<h4 class="col-title">{{ group.title }}</h4>
|
||||
<ul class="col-links">
|
||||
<li v-for="(link, lIdx) in group.links" :key="lIdx">
|
||||
<router-link :to="link.url || '/article'">{{ link.text }}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 版权与备案 -->
|
||||
<div class="copyright-row">
|
||||
<p class="icp-text" v-if="options.icpText">{{ options.icpText }}</p>
|
||||
<p class="copy-text">{{ options.copyright || 'Copyright © 2026 Lilishop. All Rights Reserved.' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || '#f1f5f9'
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-footer-nav {
|
||||
width: 100%;
|
||||
padding: 40px 0 30px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
|
||||
.footer-inner {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
|
||||
.assurance-row {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding-bottom: 30px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.assurance-col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.code-badge {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e11d48;
|
||||
color: #e11d48;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.text-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.main-t {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.sub-t {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.guides-row {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 30px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.guide-col {
|
||||
.col-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.col-links {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
li a {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #e11d48;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.copyright-row {
|
||||
text-align: center;
|
||||
padding-top: 24px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
60
buyer/src/components/indexDecorate/components/FreeLayout.vue
Normal file
60
buyer/src/components/indexDecorate/components/FreeLayout.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="decor-free-layout" :style="customStyle">
|
||||
<div
|
||||
v-for="(el, idx) in options.elements"
|
||||
:key="idx"
|
||||
class="free-element"
|
||||
:style="{
|
||||
top: `${el.top || 0}px`,
|
||||
left: `${el.left || 0}px`,
|
||||
width: el.width ? `${el.width}px` : 'auto',
|
||||
height: el.height ? `${el.height}px` : 'auto'
|
||||
}"
|
||||
>
|
||||
<img v-if="el.type === 'image'" :src="el.src" alt="" style="width:100%;height:100%;object-fit:cover;" />
|
||||
<span v-else-if="el.type === 'text'">{{ el.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
height: `${props.options?.height || 320}px`,
|
||||
backgroundColor: s.backgroundColor || 'transparent',
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '0',
|
||||
borderRadius: Array.isArray(s.radius)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '0'
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-free-layout {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
.free-element {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
343
buyer/src/components/indexDecorate/components/GoodsGrid.vue
Normal file
343
buyer/src/components/indexDecorate/components/GoodsGrid.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<div class="decor-goods-grid" :style="customStyle">
|
||||
<!-- 宫格顶部标题区 -->
|
||||
<div class="grid-header" v-if="options.title">
|
||||
<div class="grid-title-wrap">
|
||||
<span class="grid-title-decor"></span>
|
||||
<h3 class="grid-main-title">{{ options.title }}</h3>
|
||||
<span class="grid-sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
|
||||
</div>
|
||||
<a href="javascript:;" class="grid-more" v-if="options.showMoreLink !== false" @click="handleNavigate(options.moreUrl || '/goods')">
|
||||
{{ options.moreText || '查看全部' }} >
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 紧凑严谨的商品九宫格/矩阵网格 (Bordered Matrix Grid) -->
|
||||
<div
|
||||
class="matrix-grid-container"
|
||||
:class="[`cols-${actualColumns}`, { 'with-grid-border': options.showBorder !== false }]"
|
||||
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
|
||||
>
|
||||
<div
|
||||
v-for="(item, idx) in displayGoods"
|
||||
:key="item.id || idx"
|
||||
class="matrix-grid-cell"
|
||||
>
|
||||
<div class="cell-rank-tag" v-if="idx < 5">
|
||||
<span class="rank-num">TOP {{ idx + 1 }}</span>
|
||||
</div>
|
||||
|
||||
<a href="javascript:;" class="cell-thumb-box" @click="handleNavigate(item.url || `/goods/${item.id || 1}`)">
|
||||
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" class="cell-thumb" />
|
||||
</a>
|
||||
|
||||
<div class="cell-body">
|
||||
<h4 class="cell-name">
|
||||
<a href="javascript:;" @click="handleNavigate(item.url || `/goods/${item.id || 1}`)">{{ item.goodsName }}</a>
|
||||
</h4>
|
||||
<div class="cell-meta">
|
||||
<span class="badge-label">热卖好物</span>
|
||||
</div>
|
||||
<div class="cell-bottom-row">
|
||||
<div class="price-box">
|
||||
<span class="sym">¥</span>
|
||||
<span class="val">{{ item.price }}</span>
|
||||
<span class="del" v-if="item.originalPrice">¥{{ item.originalPrice }}</span>
|
||||
</div>
|
||||
<span class="action-add-pill" title="立即选购" @click="addToCart(item)">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
|
||||
|
||||
const actualColumns = computed(() => {
|
||||
const ratio = props.componentStyle?.widthRatio || '100%';
|
||||
const base = Number(props.options?.columns) || 5;
|
||||
if (ratio === '16.67%' || ratio === '16.6%') return 1;
|
||||
if (ratio === '33.33%' || ratio === '33.3%') return 2;
|
||||
if (ratio === '50%') return 3;
|
||||
if (ratio === '66.67%' || ratio === '66.6%') return 4;
|
||||
return base;
|
||||
});
|
||||
|
||||
const defaultGridGoods = [
|
||||
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
|
||||
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
|
||||
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
|
||||
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
|
||||
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
|
||||
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
|
||||
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
|
||||
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_8.png' },
|
||||
{ id: '9', goodsName: '智能降噪AI翻译蓝牙墨镜 超轻防紫外线偏光太阳镜', price: '599.00', originalPrice: '799.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
|
||||
{ id: '10', goodsName: '15.6英寸 4K超高清便携式显示器 Type-C一线直连高色域', price: '1299.00', originalPrice: '1699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' }
|
||||
];
|
||||
|
||||
const displayGoods = computed(() => {
|
||||
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
|
||||
return props.options.goods;
|
||||
}
|
||||
const count = (actualColumns.value * 2) || 10;
|
||||
return defaultGridGoods.slice(0, count);
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || '#ffffff',
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '18px 20px',
|
||||
borderRadius: Array.isArray(s.radius)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '12px'
|
||||
};
|
||||
});
|
||||
|
||||
function addToCart(item) {
|
||||
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-goods-grid {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.grid-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
.grid-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.grid-title-decor {
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: #f97316;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.grid-main-title {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.grid-sub-title {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-more {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.matrix-grid-container {
|
||||
display: grid;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.with-grid-border {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.matrix-grid-cell {
|
||||
border-right: 1px solid #f1f5f9;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
.matrix-grid-cell {
|
||||
position: relative;
|
||||
padding: 16px 14px 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #fafbfc;
|
||||
z-index: 1;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.cell-thumb {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.action-add-pill {
|
||||
background: #f97316;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-rank-tag {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 2;
|
||||
|
||||
.rank-num {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: #f97316;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-thumb-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
text-decoration: none;
|
||||
|
||||
.cell-thumb {
|
||||
width: 85%;
|
||||
height: 85%;
|
||||
object-fit: contain;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
.cell-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
margin: 0 0 6px 0;
|
||||
|
||||
a {
|
||||
color: #334155;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cell-meta {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.badge-label {
|
||||
font-size: 10px;
|
||||
color: #d97706;
|
||||
background: #fef3c7;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-bottom-row {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
|
||||
.price-box {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1px;
|
||||
|
||||
.sym {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.val {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.del {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
text-decoration: line-through;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-add-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
435
buyer/src/components/indexDecorate/components/GoodsList.vue
Normal file
435
buyer/src/components/indexDecorate/components/GoodsList.vue
Normal file
@@ -0,0 +1,435 @@
|
||||
<template>
|
||||
<div class="decor-goods-list" :style="customStyle">
|
||||
<!-- 标题栏 -->
|
||||
<div class="floor-title-bar" v-if="options.showTitle !== false && options.title">
|
||||
<div class="title-left">
|
||||
<h3 class="main-title">{{ options.title }}</h3>
|
||||
<span class="sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
|
||||
</div>
|
||||
<router-link
|
||||
:to="options.moreUrl || (options.categoryId ? `/goodsList?categoryId=${options.categoryId}` : '/goodsList')"
|
||||
class="more-link"
|
||||
v-if="options.showMoreLink !== false"
|
||||
>
|
||||
{{ options.moreText || '查看更多' }} >
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 6 列商品列表网格 (整行展示 6 个商品) -->
|
||||
<div
|
||||
class="goods-flow-grid"
|
||||
:class="`cols-${actualColumns}`"
|
||||
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
|
||||
>
|
||||
<div
|
||||
v-for="(g, idx) in sampleGoods"
|
||||
:key="g.id || idx"
|
||||
class="goods-flow-card"
|
||||
>
|
||||
<router-link
|
||||
:to="{ path: '/goodsDetail', query: { skuId: g.id, goodsId: g.goodsId || g.id } }"
|
||||
class="card-thumb-wrap"
|
||||
>
|
||||
<img :src="g.thumbnail || defaultThumb" :alt="g.goodsName" class="thumb-img" />
|
||||
<span class="self-tag" v-if="idx % 3 === 0">自营</span>
|
||||
</router-link>
|
||||
|
||||
<div class="card-body">
|
||||
<h4 class="goods-name" v-if="options.showTitle !== false">
|
||||
<router-link :to="{ path: '/goodsDetail', query: { skuId: g.id, goodsId: g.goodsId || g.id } }">
|
||||
{{ g.goodsName }}
|
||||
</router-link>
|
||||
</h4>
|
||||
|
||||
<div class="price-row">
|
||||
<div class="price-box">
|
||||
<span class="symbol">¥</span>
|
||||
<span class="price-val" v-if="options.showPrice !== false">{{ g.price }}</span>
|
||||
<span class="orig-price" v-if="options.showOriginalPrice && g.originalPrice">¥{{ g.originalPrice }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="add-cart-circle"
|
||||
v-if="options.showCartBtn !== false"
|
||||
title="加入购物车"
|
||||
@click="addToCart(g)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { goodsList } from '@/api/goods';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function handleNavigateGoods(g) {
|
||||
if (g.url) {
|
||||
const url = String(g.url).trim();
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
window.open(url, '_blank');
|
||||
return;
|
||||
}
|
||||
router.push(url);
|
||||
return;
|
||||
}
|
||||
router.push({
|
||||
path: '/goodsDetail',
|
||||
query: { skuId: g.id, goodsId: g.goodsId || g.id }
|
||||
});
|
||||
}
|
||||
|
||||
function handleNavigateMore() {
|
||||
const url = props.options?.moreUrl || (props.options?.categoryId ? `/goodsList?categoryId=${props.options.categoryId}` : '/goodsList');
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
|
||||
|
||||
const defaultMockGoods = [
|
||||
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
|
||||
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
|
||||
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
|
||||
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
|
||||
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
|
||||
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
|
||||
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
|
||||
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_8.png' },
|
||||
{ id: '9', goodsName: '折叠屏旗舰手机 典藏陶瓷白 悬停双旋水滴铰链', price: '6999.00', originalPrice: '7999.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
|
||||
{ id: '10', goodsName: '真无线触控降噪蓝牙耳机 超低延迟游戏降噪', price: '599.00', originalPrice: '799.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
|
||||
{ id: '11', goodsName: '国AA级减蓝光润眼长臂台灯 智能感光多档色温无极调光', price: '249.00', originalPrice: '359.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
|
||||
{ id: '12', goodsName: '航空级铝镁合金静音万向轮拉杆登机箱 20英寸防刮耐磨', price: '499.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' }
|
||||
];
|
||||
|
||||
const categoryFetchedGoods = ref([]);
|
||||
const isFetching = ref(false);
|
||||
|
||||
async function fetchGoodsByCategory() {
|
||||
if (props.options.dataSource !== 'category') return;
|
||||
|
||||
const count = Number(props.options.goodsCount) || (props.component?.type === 'goodsCard' ? 1 : 6);
|
||||
const params = {
|
||||
pageNumber: 1,
|
||||
pageSize: count
|
||||
};
|
||||
|
||||
if (props.options.categoryId) {
|
||||
const rawCat = String(props.options.categoryId);
|
||||
const catParts = rawCat.split(',');
|
||||
params.categoryId = catParts[catParts.length - 1];
|
||||
}
|
||||
|
||||
// 排序参数映射
|
||||
if (props.options.sortType === 'sales') {
|
||||
params.sort = 'buyCount';
|
||||
params.order = 'desc';
|
||||
} else if (props.options.sortType === 'price_asc') {
|
||||
params.sort = 'price';
|
||||
params.order = 'asc';
|
||||
} else if (props.options.sortType === 'price_desc') {
|
||||
params.sort = 'price';
|
||||
params.order = 'desc';
|
||||
} else if (props.options.sortType === 'new') {
|
||||
params.sort = 'releaseTime';
|
||||
params.order = 'desc';
|
||||
}
|
||||
|
||||
try {
|
||||
isFetching.value = true;
|
||||
const res = await goodsList(params);
|
||||
const records = res?.data?.result?.records || res?.result?.records || res?.data?.records || [];
|
||||
if (Array.isArray(records) && records.length > 0) {
|
||||
categoryFetchedGoods.value = records.map(item => ({
|
||||
id: item.id || item.goodsId || item.sn,
|
||||
goodsId: item.goodsId || item.id,
|
||||
goodsName: item.goodsName || item.name,
|
||||
price: item.price,
|
||||
originalPrice: item.originalPrice || item.price,
|
||||
thumbnail: item.thumbnail || item.pic || defaultThumb
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Fetch goods by category failed:', err);
|
||||
} finally {
|
||||
isFetching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchGoodsByCategory();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.options.dataSource, props.options.categoryId, props.options.goodsCount, props.options.sortType],
|
||||
() => {
|
||||
fetchGoodsByCategory();
|
||||
}
|
||||
);
|
||||
|
||||
const isGoodsCard = computed(() => {
|
||||
return props.component?.type === 'goodsCard' || props.options?.type === 'goodsCard';
|
||||
});
|
||||
|
||||
const actualColumns = computed(() => {
|
||||
let baseCols = Number(props.options?.columns) || 6;
|
||||
const ratio = props.componentStyle?.widthRatio || '100%';
|
||||
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '16.7%') {
|
||||
return 1;
|
||||
}
|
||||
if (ratio === '33.33%' || ratio === '33.3%') {
|
||||
return Math.min(baseCols, 2);
|
||||
}
|
||||
if (ratio === '50%') {
|
||||
return Math.min(baseCols, 3);
|
||||
}
|
||||
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
|
||||
return Math.min(baseCols, 4);
|
||||
}
|
||||
return baseCols; // 100% 默认 6 列
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || 'transparent',
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '14px 0',
|
||||
borderRadius: Array.isArray(s.radius)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '0'
|
||||
};
|
||||
});
|
||||
|
||||
const sampleGoods = computed(() => {
|
||||
if (props.options.dataSource === 'category' && categoryFetchedGoods.value.length > 0) {
|
||||
const targetCount = Number(props.options.goodsCount) || (props.component?.type === 'goodsCard' ? 1 : 6);
|
||||
return categoryFetchedGoods.value.slice(0, targetCount);
|
||||
}
|
||||
if (Array.isArray(props.options.goods) && props.options.goods.length > 0) {
|
||||
return props.options.goods;
|
||||
}
|
||||
const count = props.options.goodsCount || (actualColumns.value * 2) || 12;
|
||||
return defaultMockGoods.slice(0, count);
|
||||
});
|
||||
|
||||
function addToCart(item) {
|
||||
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-goods-list {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.floor-title-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
padding: 0 4px;
|
||||
|
||||
.title-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
|
||||
.main-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.more-link {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #e11d48;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goods-flow-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
||||
.goods-flow-card {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f1f5f9;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.08);
|
||||
border-color: #e2e8f0;
|
||||
|
||||
.thumb-img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.add-cart-circle {
|
||||
background: #e11d48;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.card-thumb-wrap {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.self-tag {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
background: #e11d48;
|
||||
color: #ffffff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
.goods-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
margin: 0 0 4px 0;
|
||||
|
||||
a {
|
||||
color: #1e293b;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: #e11d48;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.price-box {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.symbol {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.price-val {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.orig-price {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
text-decoration: line-through;
|
||||
margin-left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-cart-circle {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
576
buyer/src/components/indexDecorate/components/GridFloor.vue
Normal file
576
buyer/src/components/indexDecorate/components/GridFloor.vue
Normal file
@@ -0,0 +1,576 @@
|
||||
<template>
|
||||
<div class="decor-grid-floor" :style="customStyle">
|
||||
<!-- 楼层大标题栏 (Floor Title Bar) -->
|
||||
<div class="floor-title-header" v-if="options.showFloorTitle !== false">
|
||||
<div class="floor-main-heading">
|
||||
<span class="floor-badge">{{ options.floorNum || '1F' }}</span>
|
||||
<h3 class="floor-title-text">{{ options.title || '潮流数码' }}</h3>
|
||||
<span class="floor-subtitle-text" v-if="options.subtitle">{{ options.subtitle }}</span>
|
||||
</div>
|
||||
|
||||
<div class="floor-header-right">
|
||||
<!-- 子分类快捷标签 -->
|
||||
<div class="sub-category-tags" v-if="subCategories.length">
|
||||
<router-link
|
||||
v-for="(tag, idx) in subCategories"
|
||||
:key="idx"
|
||||
to="/goods"
|
||||
class="tag-item"
|
||||
:class="{ active: activeTagIdx === idx }"
|
||||
@click.prevent="activeTagIdx = idx"
|
||||
>
|
||||
{{ tag }}
|
||||
</router-link>
|
||||
</div>
|
||||
<a href="javascript:;" class="more-link" @click="handleNavigate(options.moreUrl || '/goods')">
|
||||
查看更多 >
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 楼层主体:左侧竖向营销海报 + 右侧商品网格 -->
|
||||
<div class="floor-main-body" :class="{ 'no-poster': !showPoster }">
|
||||
<!-- 左侧营销大促海报 -->
|
||||
<div class="floor-left-poster" v-if="showPoster">
|
||||
<div class="poster-link" @click="handleNavigate(options.bannerUrl || options.posterUrl || '/goods')">
|
||||
<img :src="posterImg" alt="floor poster" class="poster-img" />
|
||||
<div class="poster-overlay">
|
||||
<div class="poster-top-badge">{{ options.floorNum || '1F' }} 甄选</div>
|
||||
<h4 class="poster-headline">{{ options.posterTitle || '数码潮电 酷玩季' }}</h4>
|
||||
<p class="poster-subline">{{ options.posterSub || '领券最高立减 600 元' }}</p>
|
||||
<div class="poster-pill-tags" v-if="posterTags.length">
|
||||
<span v-for="(pt, pIdx) in posterTags" :key="pIdx" class="pill-tag">{{ pt }}</span>
|
||||
</div>
|
||||
<div class="poster-action-btn">
|
||||
<span>立即抢购 ></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧商品网格 (2行 x N列) -->
|
||||
<div
|
||||
class="floor-right-goods"
|
||||
:class="`cols-${rightColumns}`"
|
||||
:style="{ gridTemplateColumns: `repeat(${rightColumns}, 1fr)` }"
|
||||
>
|
||||
<div
|
||||
v-for="(g, idx) in displayGoods"
|
||||
:key="g.id || idx"
|
||||
class="floor-goods-card"
|
||||
>
|
||||
<a href="javascript:;" class="card-thumb-wrap" @click="handleNavigate(g.url || `/goods/${g.id || 1}`)">
|
||||
<img :src="g.thumbnail || defaultThumb" :alt="g.goodsName" class="goods-img" />
|
||||
<span class="corner-badge" v-if="idx < 3">HOT</span>
|
||||
</a>
|
||||
|
||||
<div class="card-detail">
|
||||
<h4 class="goods-name-line">
|
||||
<a href="javascript:;" @click="handleNavigate(g.url || `/goods/${g.id || 1}`)">{{ g.goodsName }}</a>
|
||||
</h4>
|
||||
<div class="goods-tag-row">
|
||||
<span class="mini-tag">官方直营</span>
|
||||
<span class="mini-tag light">免息</span>
|
||||
</div>
|
||||
<div class="price-action-row">
|
||||
<div class="price-group">
|
||||
<span class="currency">¥</span>
|
||||
<span class="amount">{{ g.price }}</span>
|
||||
<span class="orig-amount" v-if="g.originalPrice">¥{{ g.originalPrice }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="buy-action-btn"
|
||||
title="加入购物车"
|
||||
@click="addToCart(g)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M11 9h2V6h3V4h-3V1h-2v3H8v2h3v3zm-4 9c-1.1 0-1.99.9-1.99 2S5.9 22 7 22s2-.9 2-2-.9-2-2-2zm10 0c-1.1 0-1.99.9-1.99 2s.89 2 1.99 2 2-.9 2-2-.9-2-2-2zm-9.83-3.25l.03-.12.9-1.63h7.45c.75 0 1.41-.41 1.75-1.03l3.86-7.01L19.42 4h-.01l-1.1 2-2.76 5H8.53l-.13-.27L6.16 6l-.95-2-.94-2H1v2h2l3.6 7.59-1.35 2.45c-.16.28-.25.61-.25.96 0 1.1.9 2 2 2h12v-2H7.42c-.13 0-.25-.11-.25-.25z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function handleNavigate(url) {
|
||||
const target = url ? String(url).trim() : '/goods';
|
||||
if (!target) return;
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
window.open(target, '_blank');
|
||||
} else {
|
||||
router.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNavigateGoods(g) {
|
||||
if (g.url) {
|
||||
handleNavigate(g.url);
|
||||
return;
|
||||
}
|
||||
router.push(`/goods/${g.id || 1}`);
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
|
||||
const activeTagIdx = ref(0);
|
||||
|
||||
const subCategories = computed(() => {
|
||||
if (Array.isArray(props.options?.subCategories) && props.options.subCategories.length > 0) {
|
||||
return props.options.subCategories;
|
||||
}
|
||||
return ['旗舰手机', '轻薄笔电', '降噪耳机', '智能穿戴', '数码配件'];
|
||||
});
|
||||
|
||||
const posterTags = computed(() => {
|
||||
if (Array.isArray(props.options?.posterTags) && props.options.posterTags.length > 0) {
|
||||
return props.options.posterTags;
|
||||
}
|
||||
return ['爆款低至5折', '新品首发', '分期免息'];
|
||||
});
|
||||
|
||||
const posterImg = computed(() => {
|
||||
return props.options?.posterImg || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/banner-floor/floor_banner_1.png';
|
||||
});
|
||||
|
||||
const showPoster = computed(() => {
|
||||
const ratio = props.componentStyle?.widthRatio || '100%';
|
||||
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '33.33%' || ratio === '33.3%') {
|
||||
return false;
|
||||
}
|
||||
return props.options?.showPoster !== false;
|
||||
});
|
||||
|
||||
const rightColumns = computed(() => {
|
||||
const ratio = props.componentStyle?.widthRatio || '100%';
|
||||
let baseCols = Number(props.options?.columns) || 4;
|
||||
if (ratio === '16.67%' || ratio === '16.6%') return 1;
|
||||
if (ratio === '33.33%' || ratio === '33.3%') return 2;
|
||||
if (ratio === '50%') return showPoster.value ? 2 : 3;
|
||||
if (ratio === '66.67%' || ratio === '66.6%') return showPoster.value ? 3 : 4;
|
||||
return baseCols;
|
||||
});
|
||||
|
||||
const defaultFloorGoods = [
|
||||
{ id: '101', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
|
||||
{ id: '102', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
|
||||
{ id: '103', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
|
||||
{ id: '104', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
|
||||
{ id: '105', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
|
||||
{ id: '106', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
|
||||
{ id: '107', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
|
||||
{ id: '108', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_8.png' }
|
||||
];
|
||||
|
||||
const displayGoods = computed(() => {
|
||||
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
|
||||
return props.options.goods;
|
||||
}
|
||||
const count = (rightColumns.value * 2) || 8;
|
||||
return defaultFloorGoods.slice(0, count);
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || '#ffffff',
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '16px 20px',
|
||||
borderRadius: Array.isArray(s.radius)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '12px'
|
||||
};
|
||||
});
|
||||
|
||||
function addToCart(item) {
|
||||
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-grid-floor {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.floor-title-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 2px solid #f1f5f9;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.floor-main-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.floor-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.floor-title-text {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
letter-spacing: -0.2px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.floor-subtitle-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.floor-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.sub-category-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.tag-item {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
padding: 4px 10px;
|
||||
border-radius: 14px;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover, &.active {
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more-link {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.floor-main-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
|
||||
&.no-poster {
|
||||
.floor-right-goods {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.floor-left-poster {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #1e293b;
|
||||
|
||||
.poster-link {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 520px;
|
||||
text-decoration: none;
|
||||
|
||||
.poster-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
filter: brightness(0.85);
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
&:hover .poster-img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.poster-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 24px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.75) 0%, rgba(15, 23, 42, 0.2) 40%, rgba(15, 23, 42, 0.85) 100%);
|
||||
color: #ffffff;
|
||||
|
||||
.poster-top-badge {
|
||||
align-self: flex-start;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(4px);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.poster-headline {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 6px 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.poster-subline {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.poster-pill-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: auto;
|
||||
|
||||
.pill-tag {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.poster-action-btn {
|
||||
align-self: flex-start;
|
||||
margin-top: auto;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.floor-right-goods {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
||||
.floor-goods-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-thumb-wrap {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.goods-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
&:hover .goods-img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.corner-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
.goods-name-line {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
margin: 0 0 6px 0;
|
||||
|
||||
a {
|
||||
color: #1e293b;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goods-tag-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.mini-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
|
||||
&.light {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.price-action-row {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.price-group {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
|
||||
.currency {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.orig-amount {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
text-decoration: line-through;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.buy-action-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
116
buyer/src/components/indexDecorate/components/HotZone.vue
Normal file
116
buyer/src/components/indexDecorate/components/HotZone.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="decor-hot-zone" :style="customStyle">
|
||||
<div class="hot-zone-wrap" v-if="displayImageUrl">
|
||||
<img :src="displayImageUrl" alt="hotzone" class="base-image" />
|
||||
<div
|
||||
v-for="(zone, idx) in displayZones"
|
||||
:key="idx"
|
||||
class="zone-link"
|
||||
:style="{
|
||||
top: `${zone.top || 0}%`,
|
||||
left: `${zone.left || 0}%`,
|
||||
width: `${zone.width || 10}%`,
|
||||
height: `${zone.height || 10}%`
|
||||
}"
|
||||
:title="zone.title"
|
||||
@click="handleClickZone(zone)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const defaultZones = [
|
||||
{ title: '大促主会场 · 领券中心', url: '/couponCenter', left: 3.5, top: 12, width: 28.5, height: 76 },
|
||||
{ title: '数码潮电馆 · 爆款直降', url: '/goodsList?category=1', left: 35.5, top: 12, width: 28.5, height: 76 },
|
||||
{ title: '美妆奢品馆 · 5折特惠', url: '/goodsList?category=2', left: 67.5, top: 12, width: 29, height: 76 }
|
||||
];
|
||||
|
||||
const displayImageUrl = computed(() => {
|
||||
return props.options?.imageUrl || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/hot-zone/hotzone_default.png';
|
||||
});
|
||||
|
||||
const displayZones = computed(() => {
|
||||
if (Array.isArray(props.options?.zones) && props.options.zones.length > 0) {
|
||||
return props.options.zones;
|
||||
}
|
||||
return defaultZones;
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || 'transparent',
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '10px 0',
|
||||
borderRadius: Array.isArray(s.radius)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '8px',
|
||||
overflow: 'hidden'
|
||||
};
|
||||
});
|
||||
|
||||
function handleClickZone(zone) {
|
||||
const url = zone?.url ? String(zone.url).trim() : '';
|
||||
if (!url) return;
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-hot-zone {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.hot-zone-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
|
||||
.base-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.zone-link {
|
||||
position: absolute;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
336
buyer/src/components/indexDecorate/components/NoticeBar.vue
Normal file
336
buyer/src/components/indexDecorate/components/NoticeBar.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<div
|
||||
class="decor-notice-bar"
|
||||
:class="[
|
||||
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
|
||||
]"
|
||||
:style="customStyle"
|
||||
@mouseenter="stopTimer"
|
||||
@mouseleave="startTimer"
|
||||
>
|
||||
<!-- 左侧快讯徽章 -->
|
||||
<div class="notice-badge-box">
|
||||
<img
|
||||
v-if="options.badgeImg || options.icon"
|
||||
:src="options.badgeImg || options.icon"
|
||||
class="badge-custom-img"
|
||||
alt="icon"
|
||||
/>
|
||||
<div v-else class="badge-icon">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="badge-title">{{ options.title || '公告快讯' }}</span>
|
||||
<span class="badge-divider"></span>
|
||||
</div>
|
||||
|
||||
<!-- 中间动态滚动快讯区 (Vertical Carousel) -->
|
||||
<div class="notice-content-viewport">
|
||||
<div
|
||||
class="notice-items-strip"
|
||||
:style="{ transform: `translateY(-${currentIndex * 40}px)` }"
|
||||
>
|
||||
<div
|
||||
v-for="(item, idx) in noticeList"
|
||||
:key="idx"
|
||||
class="notice-strip-item"
|
||||
>
|
||||
<span class="notice-item-tag" :class="getTagClass(item.tag)">
|
||||
{{ item.tag || '最新' }}
|
||||
</span>
|
||||
<a
|
||||
href="javascript:;"
|
||||
class="notice-item-link"
|
||||
@click="handleNavigate(item.url || item.href || '/article')"
|
||||
>
|
||||
{{ item.title || item.text }}
|
||||
</a>
|
||||
<span class="notice-item-time" v-if="item.time && !isMini && !isCompact">
|
||||
{{ item.time }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧更多链接 -->
|
||||
<div class="notice-more-action" v-if="options.showMore !== false && !isMini">
|
||||
<a
|
||||
href="javascript:;"
|
||||
class="more-link"
|
||||
@click="handleNavigate(options.moreUrl || '/article')"
|
||||
>
|
||||
{{ options.moreText || '更多' }} >
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
function handleNavigate(url) {
|
||||
if (!url) return;
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
const isMini = computed(() => {
|
||||
const r = props.componentStyle?.widthRatio;
|
||||
return r === '16.67%' || r === '16.6%' || r === '16.7%';
|
||||
});
|
||||
|
||||
const isCompact = computed(() => {
|
||||
const r = props.componentStyle?.widthRatio;
|
||||
return r === '33.33%' || r === '33.3%';
|
||||
});
|
||||
|
||||
const defaultItems = [
|
||||
{ tag: '最新', title: '商城秋季大促重磅开启,全场满300立减50元!', time: '10-28', url: '/article' },
|
||||
{ tag: '活动', title: '会员专享积分商城全新上线,精选好礼0元兑换', time: '10-27', url: '/article' },
|
||||
{ tag: '通知', title: '关于商城系统例行优化维护及物流发货保障通知', time: '10-26', url: '/article' },
|
||||
{ tag: '热议', title: '品质生活馆场景专区火热来袭,领券最高减100', time: '10-25', url: '/article' }
|
||||
];
|
||||
|
||||
const noticeList = computed(() => {
|
||||
const list = props.options?.items || props.options?.notices;
|
||||
if (Array.isArray(list) && list.length > 0) {
|
||||
return list;
|
||||
}
|
||||
return defaultItems;
|
||||
});
|
||||
|
||||
const currentIndex = ref(0);
|
||||
let timer = null;
|
||||
|
||||
function startTimer() {
|
||||
if (timer) clearInterval(timer);
|
||||
if (noticeList.value.length <= 1) return;
|
||||
timer = setInterval(() => {
|
||||
currentIndex.value = (currentIndex.value + 1) % noticeList.value.length;
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTimer();
|
||||
});
|
||||
|
||||
function getTagClass(tag) {
|
||||
if (tag === '活动') return 'tag-promo';
|
||||
if (tag === '通知') return 'tag-notice';
|
||||
if (tag === '热议') return 'tag-hot';
|
||||
return 'tag-new';
|
||||
}
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || '#ffffff',
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '0 16px',
|
||||
borderRadius: Array.isArray(s.radius)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '8px'
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-notice-bar {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.02);
|
||||
gap: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
|
||||
&.is-mini {
|
||||
gap: 6px;
|
||||
padding: 0 8px !important;
|
||||
|
||||
.notice-badge-box {
|
||||
.badge-title, .badge-divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-compact {
|
||||
gap: 8px;
|
||||
padding: 0 10px !important;
|
||||
|
||||
.notice-badge-box {
|
||||
.badge-divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-badge-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.badge-icon {
|
||||
color: #f97316;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.badge-custom-img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: #cbd5e1;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-content-viewport {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.notice-items-strip {
|
||||
transition: transform 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.notice-strip-item {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.notice-item-tag {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.tag-new {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
&.tag-promo {
|
||||
background: #fef2f2;
|
||||
color: #ef4444;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&.tag-notice {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&.tag-hot {
|
||||
background: #fdf2f8;
|
||||
color: #db2777;
|
||||
border: 1px solid #fbcfe8;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-item-link {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.15s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-item-time {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-more-action {
|
||||
flex-shrink: 0;
|
||||
|
||||
.more-link {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
154
buyer/src/components/indexDecorate/components/PromoGroup.vue
Normal file
154
buyer/src/components/indexDecorate/components/PromoGroup.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="decor-promo-group" :style="customStyle">
|
||||
<div
|
||||
class="promo-grid"
|
||||
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
|
||||
>
|
||||
<div
|
||||
v-for="(card, idx) in cardsList"
|
||||
:key="idx"
|
||||
class="promo-card"
|
||||
:style="card.bgImg ? { backgroundImage: `url(${card.bgImg})` } : {}"
|
||||
@click="handleClickCard(card)"
|
||||
>
|
||||
<div class="card-inner">
|
||||
<h3 class="card-title">{{ card.title }}</h3>
|
||||
<p class="card-sub">{{ card.subtitle }}</p>
|
||||
<span class="action-btn">立即查看 ></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const defaultCards = [
|
||||
{ title: '限时秒杀', subtitle: '抢券立减50元 · 爆款直降', bgImg: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/promo-group/promo_1.png', url: '/goodsList?seckill=1' },
|
||||
{ title: '品牌特惠', subtitle: '大牌买一送一 · 领百元神券', bgImg: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/promo-group/promo_2.png', url: '/goodsList?brand=1' },
|
||||
{ title: '新品首发', subtitle: '首单专享特惠 · 1元起尝鲜', bgImg: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/promo-group/promo_3.png', url: '/goodsList?new=1' }
|
||||
];
|
||||
|
||||
const cardsList = computed(() => {
|
||||
if (Array.isArray(props.options?.cards) && props.options.cards.length > 0) {
|
||||
return props.options.cards;
|
||||
}
|
||||
return defaultCards;
|
||||
});
|
||||
|
||||
const actualColumns = computed(() => {
|
||||
const baseCols = Number(props.options?.columns) || 3;
|
||||
const ratio = props.componentStyle?.widthRatio || '100%';
|
||||
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '16.7%') {
|
||||
return 1;
|
||||
}
|
||||
if (ratio === '33.33%' || ratio === '33.3%') {
|
||||
return 1;
|
||||
}
|
||||
if (ratio === '50%') {
|
||||
return Math.min(baseCols, 2);
|
||||
}
|
||||
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
|
||||
return Math.min(baseCols, 2);
|
||||
}
|
||||
return baseCols;
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || 'transparent',
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '10px 0'
|
||||
};
|
||||
});
|
||||
|
||||
function handleClickCard(card) {
|
||||
const url = card?.url ? String(card.url).trim() : '';
|
||||
if (!url) return;
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-promo-group {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.promo-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
.promo-card {
|
||||
min-height: 160px;
|
||||
padding: 24px;
|
||||
background-color: #1e293b;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
.card-title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 6px 0 14px 0;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
688
buyer/src/components/indexDecorate/components/RecommendFeed.vue
Normal file
688
buyer/src/components/indexDecorate/components/RecommendFeed.vue
Normal file
@@ -0,0 +1,688 @@
|
||||
<template>
|
||||
<div class="decor-recommend-feed" :style="customStyle">
|
||||
<!-- 1. 推荐流居中标题栏 -->
|
||||
<div class="recommend-feed-header">
|
||||
<div class="header-decor-line left"></div>
|
||||
<div class="header-center-title">
|
||||
<svg class="heart-icon" viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
<h3 class="main-title">{{ options.title || '猜你喜欢' }}</h3>
|
||||
<span class="sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
|
||||
</div>
|
||||
<div class="header-decor-line right"></div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 推荐流布局:精选大卡+网格 (featured) 或 标准网格 (grid) -->
|
||||
<!-- A. 精选大卡+网格 (featured 布局) -->
|
||||
<div
|
||||
v-if="(options.layoutStyle || 'featured') === 'featured'"
|
||||
class="recommend-featured-layout"
|
||||
>
|
||||
<!-- 左侧精选特大卡片 (Featured Hero Card) -->
|
||||
<div class="featured-big-card" v-if="featuredGoods">
|
||||
<div class="big-card-badge">为你特别甄选</div>
|
||||
<router-link :to="{ path: '/goodsDetail', query: { skuId: featuredGoods.id, goodsId: featuredGoods.goodsId || featuredGoods.id } }" class="big-card-thumb">
|
||||
<img :src="featuredGoods.thumbnail || defaultThumb" :alt="featuredGoods.goodsName" />
|
||||
</router-link>
|
||||
<div class="big-card-info">
|
||||
<h4 class="big-title">
|
||||
<router-link :to="{ path: '/goodsDetail', query: { skuId: featuredGoods.id, goodsId: featuredGoods.goodsId || featuredGoods.id } }">
|
||||
{{ featuredGoods.goodsName }}
|
||||
</router-link>
|
||||
</h4>
|
||||
<div class="big-reason">官方甄选 · 猜你对这款高人气商品感兴趣</div>
|
||||
|
||||
<!-- 核心特色标签 -->
|
||||
<div class="big-tags-row">
|
||||
<span class="feat-tag highlight">🔥 镇店爆品</span>
|
||||
<span class="feat-tag">🛡️ 官方正品</span>
|
||||
<span class="feat-tag">⚡ 极速发货</span>
|
||||
<span class="feat-tag">🏷️ 售后无忧</span>
|
||||
</div>
|
||||
|
||||
<!-- 权威热度指标 -->
|
||||
<div class="big-stats-bar">
|
||||
<div class="stat-cell">
|
||||
<span class="stat-val">100%</span>
|
||||
<span class="stat-lbl">品质正品</span>
|
||||
</div>
|
||||
<div class="stat-line"></div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-val">极速达</span>
|
||||
<span class="stat-lbl">顺丰速运</span>
|
||||
</div>
|
||||
<div class="stat-line"></div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-val">TOP 1</span>
|
||||
<span class="stat-lbl">品类热榜</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务保障权益 -->
|
||||
<div class="big-guarantees">
|
||||
<div class="guar-item"><span class="guar-icon">✓</span> 7天无理由退换货</div>
|
||||
<div class="guar-item"><span class="guar-icon">✓</span> 赠运费险 · 破损包赔</div>
|
||||
</div>
|
||||
|
||||
<div class="big-price-row">
|
||||
<div class="big-price">
|
||||
<span class="sym">¥</span>
|
||||
<span class="val">{{ featuredGoods.price }}</span>
|
||||
<span class="del" v-if="featuredGoods.originalPrice">¥{{ featuredGoods.originalPrice }}</span>
|
||||
</div>
|
||||
<button type="button" class="big-buy-btn" @click="addToCart(featuredGoods)">立即入手 ></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧与后续标准卡片网格 -->
|
||||
<div class="featured-sub-grid">
|
||||
<div
|
||||
v-for="(item, idx) in regularGoods"
|
||||
:key="item.id || idx"
|
||||
class="recommend-card"
|
||||
>
|
||||
<router-link :to="{ path: '/goodsDetail', query: { skuId: item.id, goodsId: item.goodsId || item.id } }" class="card-thumb">
|
||||
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" />
|
||||
<span class="rec-tag">推荐</span>
|
||||
</router-link>
|
||||
<div class="card-body">
|
||||
<h4 class="goods-title">
|
||||
<router-link :to="{ path: '/goodsDetail', query: { skuId: item.id, goodsId: item.goodsId || item.id } }">{{ item.goodsName }}</router-link>
|
||||
</h4>
|
||||
<div class="recommend-meta">
|
||||
<span class="reason-tag">同品类热度榜</span>
|
||||
</div>
|
||||
<div class="price-row">
|
||||
<div class="price-box">
|
||||
<span class="sym">¥</span>
|
||||
<span class="val">{{ item.price }}</span>
|
||||
</div>
|
||||
<button type="button" class="cart-pill-btn" @click="addToCart(item)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- B. 标准网格流 (grid 布局) -->
|
||||
<div
|
||||
v-else
|
||||
class="recommend-standard-grid"
|
||||
:class="`cols-${actualCols}`"
|
||||
:style="{ gridTemplateColumns: `repeat(${actualCols}, 1fr)` }"
|
||||
>
|
||||
<div
|
||||
v-for="(item, idx) in displayGoods"
|
||||
:key="item.id || idx"
|
||||
class="recommend-card"
|
||||
>
|
||||
<router-link :to="{ path: '/goodsDetail', query: { skuId: item.id, goodsId: item.goodsId || item.id } }" class="card-thumb">
|
||||
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" />
|
||||
<span class="rec-tag">为你推荐</span>
|
||||
</router-link>
|
||||
<div class="card-body">
|
||||
<h4 class="goods-title">
|
||||
<router-link :to="{ path: '/goodsDetail', query: { skuId: item.id, goodsId: item.goodsId || item.id } }">{{ item.goodsName }}</router-link>
|
||||
</h4>
|
||||
<div class="recommend-meta">
|
||||
<span class="reason-tag">甄选推荐</span>
|
||||
</div>
|
||||
<div class="price-row">
|
||||
<div class="price-box">
|
||||
<span class="sym">¥</span>
|
||||
<span class="val">{{ item.price }}</span>
|
||||
</div>
|
||||
<button type="button" class="cart-pill-btn" @click="addToCart(item)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部换一批 / 探索更多推荐 -->
|
||||
<div class="recommend-footer-action">
|
||||
<button type="button" class="refresh-rec-btn" @click="handleRefresh">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
|
||||
</svg>
|
||||
<span>换一批推荐好物</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
|
||||
|
||||
const defaultPool = [
|
||||
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
|
||||
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
|
||||
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
|
||||
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
|
||||
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
|
||||
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
|
||||
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
|
||||
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_8.png' },
|
||||
{ id: '9', goodsName: '智能降噪AI翻译蓝牙墨镜 超轻防紫外线偏光太阳镜', price: '599.00', originalPrice: '799.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' }
|
||||
];
|
||||
|
||||
const displayGoods = computed(() => {
|
||||
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
|
||||
return props.options.goods;
|
||||
}
|
||||
const count = props.options?.pageSize || 8;
|
||||
return defaultPool.slice(0, count);
|
||||
});
|
||||
|
||||
const featuredGoods = computed(() => {
|
||||
return displayGoods.value[0] || defaultPool[0];
|
||||
});
|
||||
|
||||
const regularGoods = computed(() => {
|
||||
return displayGoods.value.slice(1, 7);
|
||||
});
|
||||
|
||||
const actualCols = computed(() => {
|
||||
const ratio = props.componentStyle?.widthRatio || '100%';
|
||||
if (ratio === '16.67%' || ratio === '16.6%') return 1;
|
||||
if (ratio === '33.33%' || ratio === '33.3%') return 2;
|
||||
if (ratio === '50%') return 3;
|
||||
if (ratio === '66.67%' || ratio === '66.6%') return 4;
|
||||
return 5;
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
const bg = props.options?.backgroundColor || s.backgroundColor || '#f8fafc';
|
||||
return {
|
||||
backgroundColor: bg,
|
||||
padding: Array.isArray(s.padding)
|
||||
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
|
||||
: '24px 20px',
|
||||
borderRadius: Array.isArray(s.radius)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '12px'
|
||||
};
|
||||
});
|
||||
|
||||
function addToCart(item) {
|
||||
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
ElMessage.info('已刷新为您推荐的专属商品');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-recommend-feed {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.recommend-feed-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
|
||||
.header-decor-line {
|
||||
flex: 1;
|
||||
max-width: 120px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #cbd5e1);
|
||||
|
||||
&.right {
|
||||
background: linear-gradient(90deg, #cbd5e1, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.header-center-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.heart-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recommend-featured-layout {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
|
||||
.featured-big-card {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f1f5f9;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.big-card-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: linear-gradient(135deg, #ef4444, #f97316);
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.big-card-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
background: #f8fafc;
|
||||
text-decoration: none;
|
||||
|
||||
img {
|
||||
width: 85%;
|
||||
height: 85%;
|
||||
object-fit: contain;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.big-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
.big-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 0 0 6px 0;
|
||||
|
||||
a {
|
||||
color: #0f172a;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.big-reason {
|
||||
font-size: 12px;
|
||||
color: #f97316;
|
||||
background: #fff7ed;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.big-tags-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.feat-tag {
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
&.highlight {
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.big-stats-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 6px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.stat-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
.stat-val {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.stat-lbl {
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-line {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.big-guarantees {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: #fbfbfb;
|
||||
border-left: 3px solid #ef4444;
|
||||
border-radius: 0 4px 4px 0;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.guar-item {
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.guar-icon {
|
||||
color: #16a34a;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.big-price-row {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.big-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.sym {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.val {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.del {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
text-decoration: line-through;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.big-buy-btn {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 6px 14px;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.featured-sub-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.recommend-standard-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recommend-card {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #f1f5f9;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.card-thumb img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.cart-pill-btn {
|
||||
background: #ef4444;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.card-thumb {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.rec-tag {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
background: #ef4444;
|
||||
color: #ffffff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
.goods-title {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
margin: 0 0 4px 0;
|
||||
|
||||
a {
|
||||
color: #1e293b;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recommend-meta {
|
||||
font-size: 10px;
|
||||
color: #f97316;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.reason-tag {
|
||||
background: #fff7ed;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sales-num {
|
||||
color: #94a3b8;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.rate-num {
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
.price-row {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.price-box {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.sym {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.val {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-pill-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recommend-footer-action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
|
||||
.refresh-rec-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 24px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
421
buyer/src/components/indexDecorate/components/SearchHeader.vue
Normal file
421
buyer/src/components/indexDecorate/components/SearchHeader.vue
Normal file
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<div class="decor-search-header" :style="customStyle">
|
||||
<div class="header-inner">
|
||||
<!-- 1. 商城 Logo 区域 -->
|
||||
<div class="logo-area">
|
||||
<router-link to="/">
|
||||
<img
|
||||
v-if="logoSrc"
|
||||
:src="logoSrc"
|
||||
alt="Logo"
|
||||
class="site-logo"
|
||||
/>
|
||||
<span v-else class="text-logo">LiliShop</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 2. 搜索核心区(紧凑居中,含商品/店铺 Tab、搜索框和热门推荐词) -->
|
||||
<div class="search-main">
|
||||
<!-- 搜索类型切换:商品 / 店铺 -->
|
||||
<div class="search-tabs" v-if="options.showSearchTypes !== false">
|
||||
<span
|
||||
class="tab-item"
|
||||
:class="{ active: currentSearchType === 'goods' }"
|
||||
@click="switchSearchType('goods')"
|
||||
>
|
||||
商品
|
||||
</span>
|
||||
<span
|
||||
class="tab-item"
|
||||
:class="{ active: currentSearchType === 'store' }"
|
||||
@click="switchSearchType('store')"
|
||||
>
|
||||
店铺
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 搜索输入框与按钮 -->
|
||||
<div class="search-box" :class="{ 'tab-store-active': currentSearchType === 'store' }">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
class="search-input"
|
||||
:placeholder="placeholderText"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button type="button" class="search-btn" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 热门推荐词 -->
|
||||
<div class="hot-words" v-if="options.showHotWords !== false && currentSearchType === 'goods' && hotWordsList.length">
|
||||
<span
|
||||
v-for="(word, idx) in hotWordsList"
|
||||
:key="idx"
|
||||
class="hot-w"
|
||||
@click="quickSearch(word)"
|
||||
>
|
||||
{{ word }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 购物车入口(紧凑跟随在搜索框右侧,带数量徽标) -->
|
||||
<div class="cart-entry" v-if="options.showCart !== false">
|
||||
<router-link to="/cart" class="cart-btn" :style="cartBtnStyle">
|
||||
<el-icon class="cart-icon"><ShoppingCart /></el-icon>
|
||||
<span class="cart-text">{{ options.cartText || '购物车' }}</span>
|
||||
<span class="cart-count">{{ cartCount }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { Search, ShoppingCart } from '@element-plus/icons-vue';
|
||||
import { useAppStore } from '@/stores/app.js';
|
||||
import { cartCount as fetchCartCount } from '@/api/cart.js';
|
||||
import storage from '@/plugins/storage.js';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search']);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const currentSearchType = ref(
|
||||
route.path === '/storeList'
|
||||
? 'store'
|
||||
: (props.options?.defaultSearchType || 'goods')
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(p) => {
|
||||
if (p === '/storeList') {
|
||||
currentSearchType.value = 'store';
|
||||
} else if (p === '/goodsList') {
|
||||
currentSearchType.value = 'goods';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function switchSearchType(type) {
|
||||
currentSearchType.value = type;
|
||||
}
|
||||
|
||||
const keyword = ref(route.query?.keyword || '');
|
||||
|
||||
watch(() => route.query?.keyword, (v) => {
|
||||
keyword.value = v || '';
|
||||
});
|
||||
|
||||
const logoSrc = computed(() => {
|
||||
return props.options?.logoUrl || appStore.logoImg || '';
|
||||
});
|
||||
|
||||
const placeholderText = computed(() => {
|
||||
if (currentSearchType.value === 'store') {
|
||||
const sph = props.options?.storePlaceholder;
|
||||
if (!sph || sph === '请输入店铺名称、品牌或经营范围') return '请输入店铺名称';
|
||||
return sph;
|
||||
}
|
||||
const ph = props.options?.placeholder;
|
||||
if (!ph || ph === '搜索品牌或商品' || ph === '请输入商品名称、品牌或型号') {
|
||||
return '请输入商品名称';
|
||||
}
|
||||
return ph;
|
||||
});
|
||||
|
||||
const hotWordsList = computed(() => {
|
||||
if (Array.isArray(props.options?.hotWords) && props.options.hotWords.length) {
|
||||
return props.options.hotWords;
|
||||
}
|
||||
return ['iPhone 16 Pro', '小棕瓶', '戴森吹风机', '华为Mate 70', '五粮液', '跑鞋'];
|
||||
});
|
||||
|
||||
const cartCount = computed(() => {
|
||||
return appStore.cartNum || 0;
|
||||
});
|
||||
|
||||
function handleSearch() {
|
||||
const kw = (keyword.value || '').trim();
|
||||
emit('search', kw);
|
||||
if (currentSearchType.value === 'store') {
|
||||
if (route.path === '/storeList') {
|
||||
const nextQuery = { ...route.query };
|
||||
if (kw) {
|
||||
nextQuery.keyword = kw;
|
||||
} else {
|
||||
delete nextQuery.keyword;
|
||||
}
|
||||
router.push({ path: '/storeList', query: nextQuery });
|
||||
} else {
|
||||
router.push({ path: '/storeList', query: kw ? { keyword: kw } : {} });
|
||||
}
|
||||
} else {
|
||||
if (route.path === '/goodsList') {
|
||||
const nextQuery = { ...route.query };
|
||||
if (kw) {
|
||||
nextQuery.keyword = kw;
|
||||
} else {
|
||||
delete nextQuery.keyword;
|
||||
}
|
||||
router.push({ path: '/goodsList', query: nextQuery });
|
||||
} else {
|
||||
router.push({ path: '/goodsList', query: kw ? { keyword: kw } : {} });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function quickSearch(word) {
|
||||
keyword.value = word;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function getCartList() {
|
||||
if (storage.getItem('userInfo')) {
|
||||
fetchCartCount().then((res) => {
|
||||
appStore.setCartNum(res.result);
|
||||
storage.setItem('cartNum', res.result);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getCartList();
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || '#ffffff',
|
||||
minHeight: `${s.height || 120}px`
|
||||
};
|
||||
});
|
||||
|
||||
const cartBtnStyle = computed(() => {
|
||||
const o = props.options || {};
|
||||
return {
|
||||
backgroundColor: o.cartBgColor || '#ffffff',
|
||||
borderColor: o.cartBorderColor || '#e2e8f0',
|
||||
color: o.cartTextColor || '#334155'
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-search-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
.header-inner {
|
||||
width: 1200px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 16px 0 14px 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.logo-area {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-logo {
|
||||
max-height: 40px;
|
||||
max-width: 180px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.text-logo {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: #409EFF;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-main {
|
||||
width: 580px;
|
||||
max-width: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.search-tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
margin-bottom: 0px;
|
||||
padding-left: 2px;
|
||||
z-index: 2;
|
||||
|
||||
.tab-item {
|
||||
padding: 4px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #ffffff;
|
||||
background: #409EFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
border: 2px solid #409EFF;
|
||||
border-radius: 0 4px 4px 4px;
|
||||
overflow: hidden;
|
||||
height: 40px;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.tab-store-active {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
color: #334155;
|
||||
background: #ffffff;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
background: #409EFF;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hot-words {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.4;
|
||||
|
||||
.hot-w {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cart-entry {
|
||||
flex-shrink: 0;
|
||||
|
||||
.cart-btn {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
background: #ffffff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
|
||||
.cart-icon {
|
||||
font-size: 18px;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.cart-text {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cart-count {
|
||||
background: #409EFF;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #409EFF;
|
||||
color: #409EFF;
|
||||
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
204
buyer/src/components/indexDecorate/components/TitleBar.vue
Normal file
204
buyer/src/components/indexDecorate/components/TitleBar.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div
|
||||
class="decor-title-bar"
|
||||
:class="[
|
||||
`align-${options.align || 'left'}`,
|
||||
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
|
||||
]"
|
||||
>
|
||||
<div class="title-wrap" :class="{ 'with-icon': options.titleImg || options.icon }">
|
||||
<img
|
||||
v-if="options.titleImg || options.icon"
|
||||
:src="options.titleImg || options.icon"
|
||||
class="title-custom-icon"
|
||||
alt="icon"
|
||||
/>
|
||||
<div class="title-text-group">
|
||||
<h2
|
||||
class="main-title"
|
||||
:class="{ 'clickable': Boolean(options.titleUrl) }"
|
||||
@click="options.titleUrl && handleNavigate(options.titleUrl)"
|
||||
>
|
||||
{{ options.mainTitle || '楼层标题' }}
|
||||
</h2>
|
||||
<p class="subtitle" v-if="options.subtitle">{{ options.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="javascript:;"
|
||||
class="more-link"
|
||||
v-if="options.showMore"
|
||||
@click="handleNavigate(options.moreUrl || '/goods')"
|
||||
>
|
||||
{{ options.moreText || '查看更多 >' }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
function handleNavigate(url) {
|
||||
if (!url) return;
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
const isMini = computed(() => props.componentStyle?.widthRatio === '16.67%');
|
||||
const isCompact = computed(() => props.componentStyle?.widthRatio === '33.33%');
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-title-bar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
gap: 12px;
|
||||
|
||||
&.align-center {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
|
||||
.more-link {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.title-custom-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title-text-group {
|
||||
min-width: 0;
|
||||
|
||||
.main-title {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
&:hover {
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
margin: 6px 0 0 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more-link {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #e11d48;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-compact {
|
||||
.title-wrap {
|
||||
.title-custom-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.title-text-group {
|
||||
.main-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.more-link {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-mini {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
|
||||
&.align-center {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
|
||||
.more-link {
|
||||
position: static;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.title-wrap {
|
||||
.title-custom-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.title-text-group {
|
||||
.main-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more-link {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
181
buyer/src/components/indexDecorate/components/TopBar.vue
Normal file
181
buyer/src/components/indexDecorate/components/TopBar.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="decor-top-bar" :style="customStyle">
|
||||
<div class="top-bar-inner">
|
||||
<div class="bar-left">
|
||||
<span class="welcome-txt">{{ options.welcomeText || '欢迎来到商城' }}</span>
|
||||
<span class="auth-links" v-if="!username">
|
||||
<router-link :to="options.loginUrl || '/login'" class="auth-item">
|
||||
[{{ options.loginText || '登录' }}]
|
||||
</router-link>
|
||||
<router-link :to="options.registerUrl || '/signUp'" class="auth-item">
|
||||
[{{ options.registerText || '免费注册' }}]
|
||||
</router-link>
|
||||
</span>
|
||||
<span class="auth-links user-logged" v-else>
|
||||
<router-link :to="options.userCenterUrl || '/home/MyOrder'" class="user-name">
|
||||
欢迎,{{ username }}
|
||||
</router-link>
|
||||
<span class="sep">|</span>
|
||||
<a class="auth-item" @click="handleLogout">{{ options.logoutText || '退出' }}</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="bar-right">
|
||||
<router-link
|
||||
v-for="(menu, idx) in (options.rightMenus || defaultMenus)"
|
||||
:key="idx"
|
||||
:to="menu.url || '/'"
|
||||
class="bar-link"
|
||||
>
|
||||
{{ menu.title }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const defaultMenus = [
|
||||
{ title: '基本信息', url: '/home/UserInfo' },
|
||||
{ title: '我的订单', url: '/home/MyOrder' },
|
||||
{ title: '账户安全', url: '/home/AccountSafe' },
|
||||
{ title: '消息', url: '/home/Msg' }
|
||||
];
|
||||
|
||||
const username = computed(() => {
|
||||
const userInfo = Cookies.get('userInfo');
|
||||
if (userInfo) {
|
||||
try {
|
||||
const u = JSON.parse(userInfo);
|
||||
return u.nickName || u.username || '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
function handleLogout() {
|
||||
Cookies.remove('userInfo');
|
||||
Cookies.remove('accessToken');
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
const opt = props.options || {};
|
||||
const textColor = opt.textColor || s.textColor || '#4b5563';
|
||||
const fontSize = opt.fontSize ? `${opt.fontSize}px` : '12px';
|
||||
|
||||
return {
|
||||
backgroundColor: s.backgroundColor || '#f8fafc',
|
||||
color: textColor,
|
||||
fontSize: fontSize,
|
||||
height: `${s.height || 40}px`,
|
||||
lineHeight: `${s.height || 40}px`
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-top-bar {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
background-color: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.top-bar-inner {
|
||||
width: 1200px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
.bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
white-space: nowrap;
|
||||
|
||||
.welcome-txt {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-links {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
|
||||
.auth-item {
|
||||
color: #409EFF;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.sep {
|
||||
margin: 0 4px;
|
||||
color: #dcdfe6;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: #333333;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
white-space: nowrap;
|
||||
|
||||
.bar-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
330
buyer/src/components/indexDecorate/components/UserBenefits.vue
Normal file
330
buyer/src/components/indexDecorate/components/UserBenefits.vue
Normal file
@@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<div class="decor-user-benefits" :style="customStyle">
|
||||
<!-- 1. 用户头像与信息区 -->
|
||||
<div class="user-main-info">
|
||||
<div class="avatar-box">
|
||||
<img v-if="options.avatar" :src="options.avatar" alt="avatar" />
|
||||
<div v-else class="avatar-placeholder">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="#ffffff">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-greeting">
|
||||
<div class="badge-tag">{{ options.badgeText || '请登录' }}</div>
|
||||
<div class="welcome-text" :title="options.welcomeText || '欢迎来到商城'">
|
||||
{{ options.welcomeText || '欢迎来到商城' }}
|
||||
</div>
|
||||
<div class="sub-text" :title="options.subtitle || '登录后查看专属权益'">
|
||||
{{ options.subtitle || '登录后查看专属权益' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 登录 / 注册按钮组 -->
|
||||
<div class="action-btn-row">
|
||||
<a href="javascript:;" class="action-btn login-btn" @click="handleNavigate(options.loginUrl || '/login')">
|
||||
{{ options.loginText || '登录' }}
|
||||
</a>
|
||||
<a href="javascript:;" class="action-btn register-btn" @click="handleNavigate(options.registerUrl || '/signUp')">
|
||||
{{ options.registerText || '注册' }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 3. 指标数据卡 (购物车 / 优惠券 / 订单) -->
|
||||
<div class="metrics-cards-row">
|
||||
<a href="javascript:;" class="metric-card" v-if="options.showCart !== false" @click="handleNavigate(options.cartUrl || '/cart')">
|
||||
<div class="metric-val">0</div>
|
||||
<div class="metric-label">{{ options.cartText || '购物车' }}</div>
|
||||
</a>
|
||||
<a href="javascript:;" class="metric-card" v-if="options.showCoupons !== false" @click="handleNavigate(options.couponUrl || '/member/coupon')">
|
||||
<div class="metric-val">0</div>
|
||||
<div class="metric-label">{{ options.couponText || '优惠券' }}</div>
|
||||
</a>
|
||||
<a href="javascript:;" class="metric-card" v-if="options.showOrders !== false" @click="handleNavigate(options.orderUrl || '/member/order')">
|
||||
<div class="metric-val">0</div>
|
||||
<div class="metric-label">{{ options.orderText || '订单' }}</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 4. 快捷便民服务入口(底部图标排) -->
|
||||
<div class="quick-service-row">
|
||||
<a
|
||||
v-for="(srv, sIdx) in displayServices"
|
||||
:key="sIdx"
|
||||
href="javascript:;"
|
||||
class="service-item"
|
||||
@click="handleNavigate(srv.url)"
|
||||
>
|
||||
<el-icon class="service-icon"><component :is="getServiceIcon(srv.icon)" /></el-icon>
|
||||
<span class="service-name">{{ srv.name }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Star, Clock, Present, Service } from '@element-plus/icons-vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
rowStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
function handleNavigate(url) {
|
||||
if (!url) return;
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultServices = [
|
||||
{ name: '收藏', url: '/member/favorites', icon: 'Star' },
|
||||
{ name: '足迹', url: '/member/footprint', icon: 'Clock' },
|
||||
{ name: '红包', url: '/couponCenter', icon: 'Present' },
|
||||
{ name: '客服', url: '/article', icon: 'Service' }
|
||||
];
|
||||
|
||||
const iconMap = {
|
||||
Star,
|
||||
Clock,
|
||||
Present,
|
||||
Service
|
||||
};
|
||||
|
||||
function getServiceIcon(iconName) {
|
||||
return iconMap[iconName] || Service;
|
||||
}
|
||||
|
||||
const displayServices = computed(() => {
|
||||
if (Array.isArray(props.options?.services) && props.options.services.length > 0) {
|
||||
return props.options.services;
|
||||
}
|
||||
return defaultServices;
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
|
||||
return {
|
||||
backgroundColor: (s.backgroundColor && s.backgroundColor !== 'transparent') ? s.backgroundColor : '#ffffff',
|
||||
borderRadius: Array.isArray(s.radius) && s.radius.some(r => r > 0)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '8px',
|
||||
height: '100%',
|
||||
minHeight: hasRowHeight ? '0' : '420px'
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-user-benefits {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
padding: 24px 16px 20px;
|
||||
gap: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
|
||||
.user-main-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.avatar-box {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #cbd5e1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.user-greeting {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.badge-tag {
|
||||
align-self: flex-start;
|
||||
padding: 1px 6px;
|
||||
background: #ecf5ff;
|
||||
color: #409EFF;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sub-text {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&.login-btn {
|
||||
background: #409EFF;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.25);
|
||||
|
||||
&:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.register-btn {
|
||||
background: #ffffff;
|
||||
color: #409EFF;
|
||||
border: 1px solid #409EFF;
|
||||
|
||||
&:hover {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-cards-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 12px 0;
|
||||
margin-top: 6px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
|
||||
.metric-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
.metric-val {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #409EFF;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-service-row {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed #f1f5f9;
|
||||
margin-top: 6px;
|
||||
|
||||
.service-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
.service-icon {
|
||||
font-size: 20px;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
|
||||
.service-icon {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
213
buyer/src/components/indexDecorate/components/VideoPlayer.vue
Normal file
213
buyer/src/components/indexDecorate/components/VideoPlayer.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div
|
||||
class="decor-video-player"
|
||||
:class="{ 'has-link': !!options.url }"
|
||||
:style="customStyle"
|
||||
@click="handleClickPlaceholder"
|
||||
>
|
||||
<video
|
||||
v-if="options.videoUrl"
|
||||
:src="options.videoUrl"
|
||||
:poster="options.coverUrl"
|
||||
:autoplay="options.autoPlay"
|
||||
:loop="options.loop"
|
||||
:controls="options.controls !== false"
|
||||
:muted="options.autoPlay"
|
||||
:style="{ height: `${options.height || 450}px` }"
|
||||
class="video-element"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="video-placeholder"
|
||||
:style="{
|
||||
height: `${options.height || 450}px`,
|
||||
backgroundImage: `url(${options.coverUrl || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png'})`
|
||||
}"
|
||||
>
|
||||
<div class="video-play-hint">
|
||||
<span class="play-icon">▶</span>
|
||||
<span class="hint-text">{{ options.title || '官方品牌宣传片' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 跳转行动点徽标(如果配置了链接) -->
|
||||
<div
|
||||
v-if="options.url"
|
||||
class="video-cta-badge"
|
||||
@click.stop="handleNavigate"
|
||||
title="点击查看详情"
|
||||
>
|
||||
<span class="cta-dot"></span>
|
||||
<span class="cta-text">查看活动详情</span>
|
||||
<span class="cta-arrow">></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
componentStyle: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const customStyle = computed(() => {
|
||||
const s = props.componentStyle || {};
|
||||
return {
|
||||
borderRadius: Array.isArray(s.radius)
|
||||
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
|
||||
: '8px'
|
||||
};
|
||||
});
|
||||
|
||||
function handleNavigate() {
|
||||
const url = props.options?.url ? String(props.options.url).trim() : '';
|
||||
if (!url) return;
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickPlaceholder() {
|
||||
if (!props.options?.videoUrl && props.options?.url) {
|
||||
handleNavigate();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.decor-video-player {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.has-link {
|
||||
.video-placeholder {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.video-element {
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
|
||||
.video-play-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 24px;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
|
||||
.play-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #e11d48;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
padding-left: 3px;
|
||||
box-shadow: 0 4px 14px rgba(225, 29, 72, 0.4);
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .video-play-hint {
|
||||
transform: scale(1.04);
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.video-cta-badge {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(8px);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
|
||||
.cta-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #e11d48;
|
||||
box-shadow: 0 0 8px #e11d48;
|
||||
}
|
||||
|
||||
.cta-text {
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.cta-arrow {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(225, 29, 72, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(225, 29, 72, 0.35);
|
||||
|
||||
.cta-arrow {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +1,30 @@
|
||||
<template>
|
||||
<div class="model-carousel">
|
||||
<div class="nav-body clearfix">
|
||||
<!-- 侧边导航占位 -->
|
||||
<div class="nav-side"></div>
|
||||
<div class="nav-body">
|
||||
<!-- 侧边导航占位(与 CateNav 分类栏同宽,勿与 CateNav .nav-side 混用类名) -->
|
||||
<div class="carousel-side-spacer"></div>
|
||||
<div class="nav-content">
|
||||
<!-- 轮播图 -->
|
||||
<!-- autoplay autoplay-speed="10000"-->
|
||||
<Carousel :height="333.9" autoplay :autoplay-speed="5000">
|
||||
<CarouselItem v-for="(item, index) in data.options.list" :key="index">
|
||||
<div class="swiper-img" >
|
||||
<!-- 轮播图:无有效图片时不挂载 el-carousel;勿对 el-carousel 使用随图片变化的 key,否则 Vue3 patch 会报 emitsOptions 错误 -->
|
||||
<el-carousel
|
||||
v-if="displaySlides.length"
|
||||
height="334px"
|
||||
:interval="5000"
|
||||
:loop="displaySlides.length > 1"
|
||||
>
|
||||
<el-carousel-item
|
||||
v-for="(item, index) in displaySlides"
|
||||
:key="carouselItemKey(item, index)"
|
||||
>
|
||||
<div class="swiper-img">
|
||||
<img
|
||||
:src="item.img"
|
||||
@click="linkTo(item.url)"
|
||||
class="hover-pointer"
|
||||
/>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</Carousel>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
<div v-else class="swiper-img carousel-placeholder"></div>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="person-msg">
|
||||
@@ -24,7 +32,7 @@
|
||||
<img class="user-face" :src="userInfo.face || defaultAvatar" alt />
|
||||
<div class="welcome">
|
||||
Hi, {{
|
||||
userInfo.nickName || `欢迎来到${config.title}` | secrecyMobile
|
||||
$filters.secrecyMobile(userInfo.nickName || `欢迎来到${config.title}`)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,18 +51,14 @@
|
||||
</div> -->
|
||||
</div>
|
||||
<div v-else class="flex flex-a-c ">
|
||||
<div class="btns" @click="$router.push('login')" shape="circle"
|
||||
>登录</div
|
||||
>
|
||||
<div class="btns sign-up" @click="$router.push('signUp')" shape="circle"
|
||||
>注册</div
|
||||
>
|
||||
<div class="btns" @click="goLogin">登录</div>
|
||||
<div class="btns sign-up" @click="$router.push('signUp')">注册</div>
|
||||
</div>
|
||||
|
||||
<div class="gray-line"></div>
|
||||
<div class="icon-list flex flex-j-sb" >
|
||||
<div class="icon-item" @click="entryControl(item)" :key="index" v-for="(item, index) in entranceList">
|
||||
<img class="icon" :src="require(`@/assets/iconfont/${item.icon}.png`)">
|
||||
<img class="icon" :src="getIconUrl(item.icon)">
|
||||
<div>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
@@ -62,7 +66,7 @@
|
||||
</div>
|
||||
<div class="icon-list flex flex-j-sb" >
|
||||
<div class="icon-item" :key="index" @click="entryControl(item)" v-for="(item, index) in appendList">
|
||||
<img class="icon" :src="require(`@/assets/iconfont/${item.icon}.png`)">
|
||||
<img class="icon" :src="getIconUrl(item.icon)">
|
||||
<div>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
@@ -78,95 +82,153 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Modal } from "@/utils/message";
|
||||
import storage from "@/plugins/storage";
|
||||
export default {
|
||||
name: "modelCarousel",
|
||||
props: ["data"],
|
||||
data() {
|
||||
return {
|
||||
config: require("@/config"),
|
||||
defaultAvatar: require("@/assets/images/default.png"),
|
||||
userInfo: {}, // 用户信息
|
||||
entranceList: [
|
||||
{
|
||||
icon: "collage",
|
||||
label: "宝贝收藏",
|
||||
path: "/home/Favorites"
|
||||
},
|
||||
{
|
||||
icon: "shop",
|
||||
label: "收藏店铺",
|
||||
path: "/home/Favorites?type=STORE"
|
||||
},
|
||||
{
|
||||
icon: "carts",
|
||||
label: "购物车",
|
||||
path: "/cart"
|
||||
},
|
||||
{
|
||||
icon: "story",
|
||||
label: "我的足迹",
|
||||
path: "/home/MyTracks"
|
||||
},
|
||||
],
|
||||
appendList: [
|
||||
{
|
||||
icon: "support",
|
||||
label: "帮助中心",
|
||||
path: "/article"
|
||||
},
|
||||
{
|
||||
icon: "feedback",
|
||||
label: "我的评论",
|
||||
path: "/home/CommentList"
|
||||
},
|
||||
{
|
||||
icon: "notice",
|
||||
label: "收货地址",
|
||||
path: "/home/MyAddress"
|
||||
},
|
||||
{
|
||||
icon: "notification",
|
||||
label: "我的消息",
|
||||
path: "/home/MsgList"
|
||||
},
|
||||
],
|
||||
recentList: [
|
||||
{
|
||||
value: "0",
|
||||
label: "购物车"
|
||||
},
|
||||
{
|
||||
value: "0",
|
||||
label: "待收货"
|
||||
},
|
||||
{
|
||||
value: "0",
|
||||
label: "待发货"
|
||||
},
|
||||
{
|
||||
value: "0",
|
||||
label: "代付款"
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
// 快捷跳转中心
|
||||
entryControl(val) {
|
||||
console.log("val",val)
|
||||
let url = this.$router.resolve(val.path);
|
||||
window.open(url.href, '_blank');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (storage.getItem("userInfo"))
|
||||
this.userInfo = JSON.parse(storage.getItem("userInfo"));
|
||||
import config from "@/config";
|
||||
import defaultAvatar from "@/assets/images/default.png";
|
||||
import { getIconUrl } from "@/assets/iconfont/iconMap";
|
||||
|
||||
defineOptions({ name: "modelCarousel" });
|
||||
|
||||
const props = defineProps(["data"]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const carouselList = computed(() => {
|
||||
return props.data?.options?.list || [];
|
||||
});
|
||||
|
||||
const displaySlides = computed(() => {
|
||||
return carouselList.value.filter((item) => item && item.img);
|
||||
});
|
||||
|
||||
const userInfo = ref({}); // 用户信息
|
||||
const entranceList = ref([
|
||||
{
|
||||
icon: "collage",
|
||||
label: "宝贝收藏",
|
||||
path: "/home/Favorites"
|
||||
},
|
||||
};
|
||||
{
|
||||
icon: "shop",
|
||||
label: "收藏店铺",
|
||||
path: "/home/Favorites?type=STORE"
|
||||
},
|
||||
{
|
||||
icon: "carts",
|
||||
label: "购物车",
|
||||
path: "/cart"
|
||||
},
|
||||
{
|
||||
icon: "story",
|
||||
label: "我的足迹",
|
||||
path: "/home/MyTracks"
|
||||
},
|
||||
]);
|
||||
const appendList = ref([
|
||||
{
|
||||
icon: "support",
|
||||
label: "帮助中心",
|
||||
path: "/article"
|
||||
},
|
||||
{
|
||||
icon: "feedback",
|
||||
label: "我的评论",
|
||||
path: "/home/CommentList"
|
||||
},
|
||||
{
|
||||
icon: "notice",
|
||||
label: "收货地址",
|
||||
path: "/home/MyAddress"
|
||||
},
|
||||
{
|
||||
icon: "notification",
|
||||
label: "我的消息",
|
||||
path: "/home/MsgList"
|
||||
},
|
||||
]);
|
||||
const recentList = ref([
|
||||
{
|
||||
value: "0",
|
||||
label: "购物车"
|
||||
},
|
||||
{
|
||||
value: "0",
|
||||
label: "待收货"
|
||||
},
|
||||
{
|
||||
value: "0",
|
||||
label: "待发货"
|
||||
},
|
||||
{
|
||||
value: "0",
|
||||
label: "待付款"
|
||||
},
|
||||
]);
|
||||
|
||||
function carouselItemKey(item, index) {
|
||||
return item?.img || item?.url || `slide-${index}`;
|
||||
}
|
||||
|
||||
function resolveRoute(pathStr) {
|
||||
if (!pathStr) return { path: "/" };
|
||||
const [path, search] = pathStr.split("?");
|
||||
if (!search) return { path };
|
||||
return {
|
||||
path,
|
||||
query: Object.fromEntries(new URLSearchParams(search)),
|
||||
};
|
||||
}
|
||||
|
||||
function isMemberRoute(location) {
|
||||
const path = location.path && location.path.charAt(0) === "/" ? location.path : `/${location.path || ""}`;
|
||||
return path === "/home" || path.startsWith("/home/") || path === "/cart";
|
||||
}
|
||||
|
||||
function normalizeLocation(location) {
|
||||
if (!location.path || location.path.charAt(0) === "/") return location;
|
||||
return { ...location, path: `/${location.path}` };
|
||||
}
|
||||
|
||||
function promptLoginThenNavigate(location) {
|
||||
Modal.confirm({
|
||||
title: "温馨提示",
|
||||
content: "请登录后执行此操作",
|
||||
okText: "立即登录",
|
||||
cancelText: "取消",
|
||||
onOk: () => {
|
||||
router.push({
|
||||
path: "/login",
|
||||
query: {
|
||||
rePath: location.path,
|
||||
query: JSON.stringify(location.query || {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
// 快捷跳转中心
|
||||
function entryControl(val) {
|
||||
const location = normalizeLocation(resolveRoute(val.path));
|
||||
if (isMemberRoute(location) && !storage.getItem("userInfo")) {
|
||||
promptLoginThenNavigate(location);
|
||||
return;
|
||||
}
|
||||
router.push(location);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (storage.getItem("userInfo"))
|
||||
userInfo.value = JSON.parse(storage.getItem("userInfo"));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -182,10 +244,15 @@ export default {
|
||||
|
||||
.swiper-img {
|
||||
overflow: hidden;
|
||||
width: 637px;
|
||||
width: 100%;
|
||||
height: 329.9px;
|
||||
}
|
||||
|
||||
.carousel-placeholder {
|
||||
border-radius: 10px;
|
||||
background: #ececec;
|
||||
}
|
||||
|
||||
.icon-list {
|
||||
width: 216px
|
||||
}
|
||||
@@ -265,21 +332,21 @@ export default {
|
||||
}
|
||||
|
||||
.model-carousel {
|
||||
width: 1200px;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
height: 340px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hover-pointer {
|
||||
//display: block;
|
||||
//width: 100%;
|
||||
//height: 100%;
|
||||
width: 637px;
|
||||
width: 100%;
|
||||
height: 329.9px;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.welcome {
|
||||
@@ -334,44 +401,62 @@ export default {
|
||||
|
||||
}
|
||||
|
||||
/* 导航主体 */
|
||||
/* 导航主体:263.2 + 10 + 1fr + 10 + 263.2 = 1200px,与下方楼层右缘对齐 */
|
||||
.nav-body {
|
||||
width: 1200px;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
height: 340px;
|
||||
margin: 0px auto;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 263.2px minmax(0, 1fr) 263.2px;
|
||||
column-gap: 10px;
|
||||
align-items: start;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-side {
|
||||
height: 334px;
|
||||
.carousel-side-spacer {
|
||||
grid-column: 1;
|
||||
width: 263.2px;
|
||||
height: 334px;
|
||||
flex-shrink: 0;
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
float: left;
|
||||
padding: 0px;
|
||||
color: #fff;
|
||||
// background-color: #6e6568;
|
||||
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
/*导航内容*/
|
||||
.nav-content {
|
||||
width: 637px;
|
||||
margin-left: 10.8px;
|
||||
|
||||
grid-column: 2;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-top: 10px;
|
||||
height: 333.9px;
|
||||
|
||||
float: left;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.el-carousel) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__container) {
|
||||
height: 334px;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__item) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
float: left;
|
||||
margin-top: 10px;
|
||||
grid-column: 3;
|
||||
width: 263.2px;
|
||||
margin-left: 10px;
|
||||
min-width: 263.2px;
|
||||
max-width: 263.2px;
|
||||
margin-top: 10px;
|
||||
|
||||
border-radius: 10px;
|
||||
background: #FFFFFF;
|
||||
|
||||
@@ -5,40 +5,38 @@
|
||||
<div class="nav-side"></div>
|
||||
<div class="nav-content">
|
||||
<!-- 轮播图 -->
|
||||
<Carousel autoplay @on-change="autoChange">
|
||||
<CarouselItem v-for="(item, index) in data.options.list" :key="index" >
|
||||
<el-carousel height="470px" :interval="5000" @change="autoChange">
|
||||
<el-carousel-item v-for="(item, index) in data.options.list" :key="index">
|
||||
<div style="overflow: hidden">
|
||||
<img :src="item.img" width="1200" height="470" />
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</Carousel>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'modelCarousel1',
|
||||
props: ['data'],
|
||||
data () {
|
||||
return {
|
||||
showModal: false, // modal显隐
|
||||
selected: null, // 已选数据
|
||||
picModelFlag: false, // 选择图片modal
|
||||
bgColor: '#fff' // 轮播背景色
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
this.bgColor = this.data.options.list[0].bgColor
|
||||
},
|
||||
methods: {
|
||||
// 自动切换时改变背景色
|
||||
autoChange (oVal, val) {
|
||||
this.bgColor = this.data.options.list[val].bgColor
|
||||
}
|
||||
}
|
||||
};
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
defineOptions({ name: 'modelCarousel1' })
|
||||
|
||||
const props = defineProps(['data'])
|
||||
|
||||
const showModal = ref(false) // modal显隐
|
||||
const selected = ref(null) // 已选数据
|
||||
const picModelFlag = ref(false) // 选择图片modal
|
||||
const bgColor = ref('#fff') // 轮播背景色
|
||||
|
||||
onMounted(() => {
|
||||
bgColor.value = props.data.options.list[0].bgColor
|
||||
})
|
||||
|
||||
// 自动切换时改变背景色
|
||||
function autoChange(index) {
|
||||
bgColor.value = props.data.options.list[index].bgColor
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -5,18 +5,17 @@
|
||||
<div class="nav-side"></div>
|
||||
<div class="nav-content">
|
||||
<!-- 轮播图 -->
|
||||
<Carousel autoplay>
|
||||
<CarouselItem v-for="(item, index) in data.options.list" :key="index">
|
||||
<el-carousel height="470px" :interval="5000">
|
||||
<el-carousel-item v-for="(item, index) in data.options.list" :key="index">
|
||||
<div style="overflow: hidden">
|
||||
<img :src="item.img" width="590" height="470" />
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</Carousel>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
<div class="nav-content1">
|
||||
<!-- 轮播图 -->
|
||||
<Carousel autoplay :autoplay-speed="5000">
|
||||
<CarouselItem v-for="(item, index) in data.options.listRight" :key="index">
|
||||
<el-carousel height="470px" :interval="5000">
|
||||
<el-carousel-item v-for="(item, index) in data.options.listRight" :key="index">
|
||||
<div class="mb_10">
|
||||
<img :src="item[0].img" width="190" height="150" />
|
||||
</div>
|
||||
@@ -26,19 +25,19 @@
|
||||
<div>
|
||||
<img :src="item[2].img" width="190" height="150" />
|
||||
</div>
|
||||
</CarouselItem>
|
||||
</Carousel>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="person-msg">
|
||||
<img :src="userInfo.face" v-if="userInfo.face" alt />
|
||||
<Avatar icon="ios-person" class="mb_10" v-else size="80" />
|
||||
<div>Hi,{{ userInfo.nickName || `欢迎来到${config.title}` | secrecyMobile }}</div>
|
||||
<el-avatar v-else :size="80" class="mb_10"><el-icon><User /></el-icon></el-avatar>
|
||||
<div>Hi,{{ $filters.secrecyMobile(userInfo.nickName || `欢迎来到${config.title}`) }}</div>
|
||||
<div v-if="userInfo.id">
|
||||
<Button type="error" shape="circle">会员中心</Button>
|
||||
<el-button type="danger">会员中心</el-button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Button type="error" shape="circle">请登录</Button>
|
||||
<el-button type="danger">请登录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shop-msg">
|
||||
@@ -56,47 +55,48 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {articleList} from '@/api/common.js'
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { articleList as getArticleListApi } from '@/api/common.js'
|
||||
import storage from '@/plugins/storage';
|
||||
export default {
|
||||
name: 'modelCarousel2',
|
||||
props: ['data'],
|
||||
data () {
|
||||
return {
|
||||
config:require('@/config'),
|
||||
userInfo: {}, // 用户信息
|
||||
articleList: [], // 常见问题
|
||||
params: { // 请求常见问题参数
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
type: 'ANNOUNCEMENT',
|
||||
sort: 'sort'
|
||||
}
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getArticleList () { // 获取常见问题列表
|
||||
articleList(this.params).then(res => {
|
||||
if (res.success) {
|
||||
this.articleList = res.result.records
|
||||
}
|
||||
})
|
||||
},
|
||||
goArticle (id) { // 跳转文章详情
|
||||
let routeUrl = this.$router.resolve({
|
||||
path: '/article',
|
||||
query: {id}
|
||||
});
|
||||
window.open(routeUrl.href, '_blank');
|
||||
import { User } from '@element-plus/icons-vue';
|
||||
import config from '@/config';
|
||||
|
||||
defineOptions({ name: 'modelCarousel2' })
|
||||
|
||||
defineProps(['data'])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const userInfo = ref({}) // 用户信息
|
||||
const articleList = ref([]) // 常见问题
|
||||
const params = reactive({ // 请求常见问题参数
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
type: 'ANNOUNCEMENT',
|
||||
sort: 'sort'
|
||||
})
|
||||
|
||||
function getArticleList () { // 获取常见问题列表
|
||||
getArticleListApi(params).then(res => {
|
||||
if (res.success) {
|
||||
articleList.value = res.result.records
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (storage.getItem('userInfo')) this.userInfo = JSON.parse(storage.getItem('userInfo'));
|
||||
this.getArticleList()
|
||||
}
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
function goArticle (id) { // 跳转文章详情
|
||||
router.push({
|
||||
path: '/article',
|
||||
query: {id}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (storage.getItem('userInfo')) userInfo.value = JSON.parse(storage.getItem('userInfo'));
|
||||
getArticleList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -163,7 +163,7 @@ export default {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ivu-btn-default {
|
||||
.el-button--default {
|
||||
color: $theme_color;
|
||||
border-color: $theme_color;
|
||||
}
|
||||
|
||||
@@ -15,24 +15,21 @@
|
||||
<span class="line btm-line"></span>
|
||||
<p>{{ item.describe }}</p>
|
||||
</div>
|
||||
<img :src="item.img" width="170" height="170" alt="" />
|
||||
<img :src="item.img" loading="lazy" decoding="async" width="170" height="170" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
options: this.data.options // 装修数据
|
||||
};
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const options = ref(props.data.options); // 装修数据
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.first-page-advert {
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
<div class="top-header" :style="{ background: options.left.bgColor }">
|
||||
<span>{{ options.left.title }}</span>
|
||||
<span @click="linkTo(options.left.url)" class="hover-pointer"
|
||||
>{{ options.left.secondTitle }} ></span
|
||||
>
|
||||
>{{ options.left.secondTitle }} ></span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div
|
||||
@@ -18,7 +17,7 @@
|
||||
<p>{{ item.name }}</p>
|
||||
<p class="describe">{{ item.describe }}</p>
|
||||
</div>
|
||||
<img :src="item.img" alt="" />
|
||||
<img :src="item.img" loading="lazy" decoding="async" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,8 +26,7 @@
|
||||
<div class="top-header" :style="{ background: options.middle.bgColor }">
|
||||
<span>{{ options.middle.title }}</span>
|
||||
<span class="hover-pointer" @click="linkTo(options.middle.url)"
|
||||
>{{ options.middle.secondTitle }} ></span
|
||||
>
|
||||
>{{ options.middle.secondTitle }} ></span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div
|
||||
@@ -41,7 +39,7 @@
|
||||
<p>{{ item.name }}</p>
|
||||
<p class="describe">{{ item.describe }}</p>
|
||||
</div>
|
||||
<img :src="item.img" alt="" />
|
||||
<img :src="item.img" loading="lazy" decoding="async" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,8 +48,7 @@
|
||||
<div class="top-header" :style="{ background: options.right.bgColor }">
|
||||
<span>{{ options.right.title }}</span>
|
||||
<span @click="linkTo(options.right.url)" class="hover-pointer"
|
||||
>{{ options.right.secondTitle }} ></span
|
||||
>
|
||||
>{{ options.right.secondTitle }} ></span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div
|
||||
@@ -60,134 +57,236 @@
|
||||
class="hover-pointer"
|
||||
@click="linkTo(item.url)"
|
||||
>
|
||||
<img :src="item.img" alt="" />
|
||||
<p>{{ item.name }}</p>
|
||||
<p>{{ item.price | unitPrice("¥") }}</p>
|
||||
<div class="jiaobiao" :class="'jiaobiao' + (index + 1)">
|
||||
{{ index + 1 }}
|
||||
<div class="img-wrap">
|
||||
<img :src="item.img" loading="lazy" decoding="async" alt="" />
|
||||
<div class="jiaobiao" :class="'jiaobiao' + (index + 1)">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
<p>{{ item.name }}</p>
|
||||
<p>{{ $filters.unitPrice(item.price, "¥") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
options: this.data.options // 装修数据
|
||||
};
|
||||
},
|
||||
methods: {}
|
||||
};
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const options = ref(props.data.options) // 装修数据
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.new-goods {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
> div {
|
||||
width: 393px;
|
||||
height: 440px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left > .content {
|
||||
> div:nth-child(1) {
|
||||
height: 240px;
|
||||
flex-direction: column;
|
||||
border: 1px solid #eee;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
justify-content: space-between;
|
||||
img {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
}
|
||||
.describe {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
> div:nth-child(2) {
|
||||
border-right: 1px solid #eee;
|
||||
}
|
||||
> div:nth-child(3),
|
||||
> div:nth-child(4) {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
|
||||
.middle > .content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
flex-direction: unset;
|
||||
overflow: hidden;
|
||||
> div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
padding: 6px 4px 4px 6px;
|
||||
border-style: solid;
|
||||
border-color: #eee;
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
border-right-width: 1px;
|
||||
img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
> div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
p {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.describe {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
> div:nth-child(1) {
|
||||
grid-row: span 2;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 8px 6px;
|
||||
img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
> div {
|
||||
flex: unset;
|
||||
width: 100%;
|
||||
p {
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.describe {
|
||||
margin-top: 6px;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
> div:nth-child(1),
|
||||
> div:nth-child(2),
|
||||
> div:nth-child(3) {
|
||||
> div:nth-child(3),
|
||||
> div:nth-child(5) {
|
||||
border-right-width: 0;
|
||||
}
|
||||
> div:nth-child(4),
|
||||
> div:nth-child(5) {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.middle > .content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
flex-direction: unset;
|
||||
> div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
border-style: solid;
|
||||
border-color: #eee;
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
border-right-width: 1px;
|
||||
}
|
||||
> div:nth-child(6),
|
||||
> div:nth-child(3) {
|
||||
> div:nth-child(2n) {
|
||||
border-right-width: 0;
|
||||
}
|
||||
> div:nth-child(n + 5) {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.right > .content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
flex-direction: unset;
|
||||
flex-wrap: unset;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
> div {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
padding: 5px 10px 0 10px;
|
||||
img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 4px 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid #eee;
|
||||
:nth-child(2) {
|
||||
height: 38px;
|
||||
border-right: 1px solid #eee;
|
||||
.img-wrap {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
.jiaobiao {
|
||||
position: absolute;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
display: block;
|
||||
background: url(../../../assets/images/festival_icon.png) no-repeat;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 23px;
|
||||
font-size: 12px;
|
||||
-webkit-line-clamp: unset;
|
||||
-webkit-box-orient: unset;
|
||||
}
|
||||
.jiaobiao1,
|
||||
.jiaobiao4 {
|
||||
background-position: -2px -30px;
|
||||
}
|
||||
.jiaobiao2,
|
||||
.jiaobiao5 {
|
||||
background-position: -31px -30px;
|
||||
}
|
||||
.jiaobiao3,
|
||||
.jiaobiao6 {
|
||||
background-position: -60px -30px;
|
||||
}
|
||||
}
|
||||
> p:nth-child(2) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 32px;
|
||||
line-height: 16px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
:nth-child(3) {
|
||||
color: $theme_color;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.jiaobiao {
|
||||
position: absolute;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
top: 10px;
|
||||
right: 16px;
|
||||
background: url(../../../assets/images/festival_icon.png);
|
||||
color: #fff;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-align: center;
|
||||
}
|
||||
.jiaobiao1,
|
||||
.jiaobiao4 {
|
||||
background-position: -2px -30px;
|
||||
}
|
||||
.jiaobiao2,
|
||||
.jiaobiao5 {
|
||||
background-position: -31px -30px;
|
||||
}
|
||||
.jiaobiao3,
|
||||
.jiaobiao6 {
|
||||
background-position: -60px -30px;
|
||||
> p:nth-child(3) {
|
||||
color: $theme_color;
|
||||
margin-top: 2px;
|
||||
line-height: 1.2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
> div:nth-child(4),
|
||||
> div:nth-child(5),
|
||||
> div:nth-child(6) {
|
||||
border: none;
|
||||
> div:nth-child(3n) {
|
||||
border-right: none;
|
||||
}
|
||||
> div:nth-child(n + 4) {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +311,8 @@ export default {
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
height: 370px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
.con-item {
|
||||
width: 185px;
|
||||
@@ -225,6 +326,42 @@ export default {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
.middle > .content .con-item {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 4px 4px 6px;
|
||||
img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
> div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
p {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.describe {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.left > .content .con-item {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.describe {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<template>
|
||||
<div class="not-enough" ref="obtain" id="demo">
|
||||
<Affix :offset-top="62" @on-change="change">
|
||||
<ul class="nav-bar" v-show="topSearchShow">
|
||||
<li
|
||||
v-for="(item, index) in conData.options.navList"
|
||||
:class="currentIndex === index ? 'curr' : ''"
|
||||
@click="changeCurr(index)"
|
||||
:key="index"
|
||||
>
|
||||
<p @click="gotoDemo">{{ item.title }}</p>
|
||||
<p @click="gotoDemo">{{ item.desc }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</Affix>
|
||||
<el-affix :offset-top="62" @change="change" class="nav-affix">
|
||||
<div class="nav-bar" v-show="topSearchShow">
|
||||
<ul class="nav-list">
|
||||
<li
|
||||
v-for="(item, index) in conData.options.navList"
|
||||
:class="currentIndex === index ? 'curr' : ''"
|
||||
@click="changeCurr(index)"
|
||||
:key="index"
|
||||
>
|
||||
<p @click="gotoDemo">{{ item.title }}</p>
|
||||
<p @click="gotoDemo">{{ item.desc }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</el-affix>
|
||||
<div class="content" v-if="showContent">
|
||||
<div
|
||||
v-for="(item, index) in conData.options.list[currentIndex]"
|
||||
@@ -20,93 +22,119 @@
|
||||
class="hover-pointer"
|
||||
@click="linkTo(item.url)"
|
||||
>
|
||||
<img :src="item.img" width="210" height="210" :alt="item.name" />
|
||||
<img :src="item.img" loading="lazy" decoding="async" width="210" height="210" :alt="item.name" />
|
||||
<p>{{ item.name }}</p>
|
||||
<p>
|
||||
<span>{{ Number(item.price) | unitPrice("¥") }}</span>
|
||||
<span>{{ $filters.unitPrice(Number(item.price), "¥") }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
mounted() {
|
||||
window.addEventListener('scroll',this.handleScrollx,true)
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
screenHeight:document.body.clientHeight,
|
||||
scrollHieght:0,
|
||||
topSearchShow: true, //展示 导航条
|
||||
topIndex: 0, // 当前滚动条位置 (取整)
|
||||
scrollTops: 0,
|
||||
open:'',
|
||||
currentIndex: 0, // 当前分类下标
|
||||
conData: this.data, // 装修数据
|
||||
showContent: true, // 是否展示内容
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
data: function (val) {
|
||||
this.conData = val;
|
||||
},
|
||||
conData: function (val) {
|
||||
this.$emit("content", val);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleScrollx(){
|
||||
// console.log('滚动高度',window.pageYOffset) // 获取滚动条的高度
|
||||
// console.log(this.$refs.obtain.getBoundingClientRect().top) //获取到距离顶部的距离
|
||||
// this.scrollHieght = Number(window.pageYOffset);//获取到距离顶部的距离
|
||||
// this.scrollTops = Number(this.$refs.obtain.getBoundingClientRect().top); // 获取到距离顶部的距离
|
||||
// this.topSearchShow = true; // 展示图钉
|
||||
// if(this.scrollTops < -660){ // 超过隐藏
|
||||
// this.topSearchShow = false;
|
||||
// }
|
||||
},
|
||||
toguid(path,id){
|
||||
var path =path;
|
||||
var Id = id;
|
||||
localStorage.setItem('toId',Id);
|
||||
this.$router.push(path);
|
||||
},
|
||||
change(status){ //获取是否获取到图钉
|
||||
this.open = status
|
||||
},
|
||||
gotoDemo(){ // 跳转到demo的位置
|
||||
if(this.open){ // 获取到图钉之后在跳转当前位置
|
||||
document.querySelector("#demo").scrollIntoView(true);
|
||||
}
|
||||
//scrollIntoView()可以在所有的HTML元素上调用,通过滚动浏览器窗口或某个容器元素
|
||||
},
|
||||
changeCurr(index) {
|
||||
// 选择分类
|
||||
this.currentIndex = index;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const emit = defineEmits(['content']);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const obtain = ref(null);
|
||||
const screenHeight = ref(document.body.clientHeight);
|
||||
const scrollHieght = ref(0);
|
||||
const topSearchShow = ref(true); //展示 导航条
|
||||
const topIndex = ref(0); // 当前滚动条位置 (取整)
|
||||
const scrollTops = ref(0);
|
||||
const open = ref('');
|
||||
const currentIndex = ref(0); // 当前分类下标
|
||||
const conData = ref(props.data); // 装修数据
|
||||
const showContent = ref(true); // 是否展示内容
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val) => {
|
||||
conData.value = val;
|
||||
}
|
||||
);
|
||||
|
||||
watch(conData, (val) => {
|
||||
emit('content', val);
|
||||
});
|
||||
|
||||
function handleScrollx() {
|
||||
// console.log('滚动高度',window.pageYOffset) // 获取滚动条的高度
|
||||
// console.log(obtain.value.getBoundingClientRect().top) //获取到距离顶部的距离
|
||||
// scrollHieght.value = Number(window.pageYOffset);//获取到距离顶部的距离
|
||||
// scrollTops.value = Number(obtain.value.getBoundingClientRect().top); // 获取到距离顶部的距离
|
||||
// topSearchShow.value = true; // 展示图钉
|
||||
// if(scrollTops.value < -660){ // 超过隐藏
|
||||
// topSearchShow.value = false;
|
||||
// }
|
||||
}
|
||||
|
||||
function toguid(path, id) {
|
||||
var path = path;
|
||||
var Id = id;
|
||||
localStorage.setItem('toId', Id);
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
function change(status) { //获取是否获取到图钉
|
||||
open.value = status
|
||||
}
|
||||
|
||||
function gotoDemo() { // 跳转到demo的位置
|
||||
if (open.value) { // 获取到图钉之后在跳转当前位置
|
||||
document.querySelector("#demo").scrollIntoView(true);
|
||||
}
|
||||
//scrollIntoView()可以在所有的HTML元素上调用,通过滚动浏览器窗口或某个容器元素
|
||||
}
|
||||
|
||||
function changeCurr(index) {
|
||||
// 选择分类
|
||||
currentIndex.value = index;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScrollx, true)
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.not-enough {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:deep(.nav-affix.el-affix) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
background-color: #f8f8f8;
|
||||
height: 60px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
position: sticky;
|
||||
position: -webkit-sticky;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
li {
|
||||
padding: 0 30px;
|
||||
text-align: center;
|
||||
@@ -114,11 +142,15 @@ export default {
|
||||
font-size: 16px;
|
||||
border-radius: 50px;
|
||||
padding: 0 7px;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p:nth-child(2) {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin: 2px 0 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -129,6 +161,7 @@ export default {
|
||||
}
|
||||
border-right: 1px solid #eee;
|
||||
}
|
||||
|
||||
li:last-of-type {
|
||||
border: none;
|
||||
}
|
||||
@@ -136,7 +169,6 @@ export default {
|
||||
.curr {
|
||||
p:nth-child(1) {
|
||||
background-color: $theme_color;
|
||||
|
||||
color: #fff;
|
||||
}
|
||||
p:nth-child(2) {
|
||||
@@ -146,6 +178,8 @@ export default {
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -7,21 +7,21 @@
|
||||
</div>
|
||||
<div class="content-left">
|
||||
<div>
|
||||
<img class="hover-pointer" @click="linkTo(msgLeft.list[0].url)" :src="msgLeft.list[0].img" width="160" height="160" alt="" />
|
||||
<img class="hover-pointer" @click="linkTo(msgLeft.list[0].url)" :src="msgLeft.list[0].img" loading="lazy" decoding="async" width="160" height="160" alt="" />
|
||||
<div class="margin-left">{{ msgLeft.list[0].name }}</div>
|
||||
<div class="margin-left">{{ msgLeft.list[0].describe }}</div>
|
||||
<Button
|
||||
<el-button
|
||||
size="small"
|
||||
:style="{ background: msgLeft.bgColor }"
|
||||
@click="linkTo(msgLeft.list[0].url)"
|
||||
class="fz_12 view-btn"
|
||||
>点击查看</Button
|
||||
>点击查看</el-button
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<template v-for="(item, index) in msgLeft.list">
|
||||
<div v-if="index != 0" :key="index" @click="linkTo(item.url)" class="hover-pointer">
|
||||
<img :src="item.img" width="80" height="80" alt="" />
|
||||
<img :src="item.img" loading="lazy" decoding="async" width="80" height="80" alt="" />
|
||||
<div>
|
||||
<div>{{ item.name }}</div>
|
||||
<div>{{ item.describe }}</div>
|
||||
@@ -36,8 +36,7 @@
|
||||
<div class="head-recommend" :style="{ background: msgRight.bgColor }">
|
||||
<span>{{ msgRight.title }}</span>
|
||||
<span @click="linkTo(msgRight.url)" class="hover-pointer"
|
||||
>{{ msgRight.secondTitle }}></span
|
||||
>
|
||||
>{{ msgRight.secondTitle }}></span>
|
||||
</div>
|
||||
<div class="content-right">
|
||||
<div
|
||||
@@ -45,13 +44,13 @@
|
||||
:key="index"
|
||||
@click="linkTo(item.url)" class="hover-pointer"
|
||||
>
|
||||
<div class="right-item" :style="{'border': index===2 || index===3 ?'none': ''}">
|
||||
<div class="right-item">
|
||||
<div>
|
||||
<span :style="{ background: msgRight.bgColor }">{{item.name}}</span>
|
||||
<span>{{ item.describe }}</span>
|
||||
</div>
|
||||
<div class="right-img">
|
||||
<img :src="item.img" alt="" />
|
||||
<img :src="item.img" loading="lazy" decoding="async" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,22 +58,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
default: {}
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
msgLeft: this.data.options.contentLeft, // 左侧数据
|
||||
msgRight: this.data.options.contentRight // 右侧数据
|
||||
};
|
||||
},
|
||||
methods: {}
|
||||
};
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const msgLeft = ref(props.data.options.contentLeft); // 左侧数据
|
||||
const msgRight = ref(props.data.options.contentRight); // 右侧数据
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.recommend {
|
||||
@@ -84,12 +79,17 @@ export default {
|
||||
width: 595px;
|
||||
.content-left {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding-top: 10px;
|
||||
font-size: 12px;
|
||||
box-sizing: content-box;
|
||||
> div:nth-child(1) {
|
||||
width: 189px;
|
||||
border-right: 1px solid #eee;
|
||||
height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
img {
|
||||
margin: 40px 0 0 15px;
|
||||
}
|
||||
@@ -107,15 +107,20 @@ export default {
|
||||
color: #999;
|
||||
}
|
||||
.view-btn {
|
||||
margin-left: 15px;
|
||||
margin-top: 10px;
|
||||
align-self: center;
|
||||
width: 145px;
|
||||
margin-top: auto;
|
||||
margin-bottom: 15px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
> div:nth-child(2) {
|
||||
width: 405px;
|
||||
height: 360px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -136,65 +141,85 @@ export default {
|
||||
|
||||
.recommend-right {
|
||||
width: 595px;
|
||||
height: 360px;
|
||||
|
||||
.head-recommend {
|
||||
background: #a25684;
|
||||
}
|
||||
.content-right {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
height: 360px;
|
||||
padding-top: 10px;
|
||||
box-sizing: content-box;
|
||||
|
||||
> div {
|
||||
width: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
height: 180px;
|
||||
padding-top: 10px;
|
||||
padding: 0;
|
||||
border-right: 1px solid #eee;
|
||||
border-bottom: 1px solid #eee;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:nth-child(2n) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&:nth-child(n + 3) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.right-item {
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: none;
|
||||
display: flex;
|
||||
margin-top: 30px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
height: 150px;
|
||||
padding: 0 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 10px 15px;
|
||||
font-size: 12px;
|
||||
>div:nth-child(1) {
|
||||
box-sizing: border-box;
|
||||
|
||||
margin-top: 30px;
|
||||
span:nth-child(1){
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 0 5px;
|
||||
background-color: #a25684;
|
||||
display: block;
|
||||
> div:nth-child(1) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-top: 0;
|
||||
text-align: left;
|
||||
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
margin: 0 10px 10px 0;
|
||||
span:nth-child(1) {
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 0 5px;
|
||||
background-color: #a25684;
|
||||
display: block;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
span:nth-child(2) {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.right-img {
|
||||
width: 100;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
img{
|
||||
max-height: 100px;
|
||||
max-width: 100px;
|
||||
margin: 0;
|
||||
|
||||
img {
|
||||
max-height: 100px;
|
||||
max-width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
> div:nth-child(n + 1) {
|
||||
border-right: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="seckill" v-if="goodsList.length">
|
||||
<div class="seckill" v-if="goodsList && goodsList.length">
|
||||
<div class="aside hover-pointer" @click="goToSeckill">
|
||||
<div class="title">{{ actName }}</div>
|
||||
<div class="hour">
|
||||
@@ -12,156 +12,114 @@
|
||||
</div>
|
||||
<div class="act-status" v-else>未开始</div>
|
||||
</div>
|
||||
<swiper :options="swiperOption" ref="mySwiper">
|
||||
<swiper-slide v-for="(item,index) in goodsList" :key="index">
|
||||
<div class="content hover-pointer" @click.stop="goToSeckill">
|
||||
<img :src="item.goodsImage" width="140" height="140" :alt="item.goodsName">
|
||||
<div class="ellipsis">{{item.goodsName}}</div>
|
||||
<div>
|
||||
<span>{{ item.price | unitPrice('¥') }}</span>
|
||||
<span>{{ item.originalPrice | unitPrice('¥') }}</span>
|
||||
</div>
|
||||
<div class="goods-scroll">
|
||||
<div
|
||||
v-for="(item, index) in goodsList"
|
||||
:key="index"
|
||||
class="content hover-pointer"
|
||||
@click.stop="goToSeckill"
|
||||
>
|
||||
<img :src="item.goodsImage" loading="lazy" decoding="async" width="140" height="140" :alt="item.goodsName" />
|
||||
<div class="ellipsis">{{ item.goodsName }}</div>
|
||||
<div>
|
||||
<span>{{ $filters.unitPrice(item.price, "¥") }}</span>
|
||||
<span>{{ $filters.unitPrice(item.originalPrice, "¥") }}</span>
|
||||
</div>
|
||||
</swiper-slide>
|
||||
<div class="swiper-button-prev" slot="button-prev">
|
||||
<Icon type="ios-arrow-back" />
|
||||
</div>
|
||||
<div class="swiper-button-next" slot="button-next">
|
||||
<Icon type="ios-arrow-forward" />
|
||||
</div>
|
||||
</swiper>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
// 引入插件
|
||||
import { swiper, swiperSlide } from 'vue-awesome-swiper';
|
||||
import 'swiper/dist/css/swiper.css';
|
||||
export default {
|
||||
components: {
|
||||
swiper,
|
||||
swiperSlide
|
||||
},
|
||||
props: {
|
||||
data: Object
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
list: [], // 秒杀时段列表
|
||||
goodsList: [], // 商品列表
|
||||
actStatus: 0, // 0 未开始 1 进行中
|
||||
actName: '限时秒杀', // 活动名称
|
||||
currIndex: 0, // 当前时间段的下标
|
||||
currHour: '00', // 当前秒杀场
|
||||
diffSeconds: 0, // 倒计时秒数
|
||||
hours: 0, // 小时
|
||||
minutes: 0, // 分钟
|
||||
seconds: 0, // 秒
|
||||
interval: null, // 定时器
|
||||
swiperOption: { // 轮播图参数
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
slidesPerView: 5,
|
||||
// 设置点击箭头
|
||||
navigation: {
|
||||
nextEl: '.swiper-button-next',
|
||||
prevEl: '.swiper-button-prev'
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
currIndex (val) {
|
||||
clearInterval(this.interval)
|
||||
this.interval = null
|
||||
this.countDown(val)
|
||||
this.goodsList = this.list[val].seckillGoodsList
|
||||
},
|
||||
diffSeconds (val) {
|
||||
const hours = Math.floor(val / 3600);
|
||||
// 当前秒数 / 60,向下取整
|
||||
// 获取到所有分钟数 3600 / 60 = 60分钟
|
||||
// 对60取模,超过小时数的分钟数
|
||||
const minutes = Math.floor(val / 60) % 60;
|
||||
// 当前的秒数 % 60,获取到 超过小时数、分钟数的秒数(秒数)
|
||||
const seconds = val % 60;
|
||||
this.hours = filteTime(hours)
|
||||
this.minutes = filteTime(minutes)
|
||||
this.seconds = filteTime(seconds)
|
||||
if (val <= 0) {
|
||||
clearInterval(this.interval)
|
||||
this.interval = null
|
||||
}
|
||||
function filteTime (time) {
|
||||
if (time < 10) {
|
||||
return '0' + time
|
||||
} else {
|
||||
return time
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
swiper () { // 轮播组件
|
||||
return this.$refs.mySwiper.swiper;
|
||||
}
|
||||
},
|
||||
beforeDestroy () {
|
||||
// 销毁前清除定时器
|
||||
clearInterval(this.interval);
|
||||
},
|
||||
mounted () {
|
||||
this.getListByDay()
|
||||
},
|
||||
methods: {
|
||||
goToSeckill () { // 跳转秒杀页面
|
||||
let routeUrl = this.$router.resolve({
|
||||
path: '/seckill'
|
||||
});
|
||||
window.open(routeUrl.href, '_blank');
|
||||
},
|
||||
countDown (currIndex) { // 倒计时
|
||||
// 0点时间戳
|
||||
let zeroTime = new Date(new Date().toLocaleDateString()).getTime();
|
||||
let currTime = new Date().getTime()
|
||||
let actTime = 0;
|
||||
let nowHour = new Date().getHours(); // 当前小时数
|
||||
if (this.list[currIndex].timeLine > nowHour) { // 活动未开始
|
||||
this.actStatus = 0;
|
||||
actTime = zeroTime + this.list[currIndex].timeLine * 3600 * 1000
|
||||
} else if (this.list[currIndex].timeLine <= nowHour) { // 活动进行中
|
||||
this.actStatus = 1;
|
||||
if (currIndex === this.list.length - 1) { // 如果是最后一个活动,直到24点结束
|
||||
actTime = zeroTime + 24 * 3600 * 1000
|
||||
} else {
|
||||
actTime = zeroTime + this.list[currIndex + 1].timeLine * 3600 * 1000
|
||||
}
|
||||
}
|
||||
this.currHour = this.list[this.currIndex].timeLine
|
||||
this.diffSeconds = Math.floor((actTime - currTime) / 1000)
|
||||
this.interval = setInterval(() => {
|
||||
this.diffSeconds--
|
||||
}, 1000)
|
||||
},
|
||||
getListByDay () { // 当天秒杀活动
|
||||
// const list = [
|
||||
// {
|
||||
// timeLine: 18,
|
||||
// seckillGoodsList: [
|
||||
// {goodsImage: 'https://lilishop-oss.oss-cn-beijing.aliyuncs.com/a9593607de404546953055f279fd5d54.png', goodsName: 'dfsdgsdf', originalPrice: 39, price: 12},
|
||||
// {goodsImage: 'https://lilishop-oss.oss-cn-beijing.aliyuncs.com/a9593607de404546953055f279fd5d54.png', goodsName: 'dfsdgsdf', originalPrice: 39, price: 12},
|
||||
// {goodsImage: 'https://lilishop-oss.oss-cn-beijing.aliyuncs.com/a9593607de404546953055f279fd5d54.png', goodsName: 'dfsdgsdf', originalPrice: 39, price: 12},
|
||||
// {goodsImage: 'https://lilishop-oss.oss-cn-beijing.aliyuncs.com/a9593607de404546953055f279fd5d54.png', goodsName: 'dfsdgsdf', originalPrice: 39, price: 12},
|
||||
// {goodsImage: 'https://lilishop-oss.oss-cn-beijing.aliyuncs.com/a9593607de404546953055f279fd5d54.png', goodsName: 'dfsdgsdf', originalPrice: 39, price: 12},
|
||||
// {goodsImage: 'https://lilishop-oss.oss-cn-beijing.aliyuncs.com/a9593607de404546953055f279fd5d54.png', goodsName: 'dfsdgsdf', originalPrice: 39, price: 12}
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
this.list = this.data.options.list
|
||||
this.goodsList = this.list[0].seckillGoodsList
|
||||
console.log( this.goodsList)
|
||||
this.countDown(this.currIndex)
|
||||
const props = defineProps({
|
||||
data: Object,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const list = ref([]);
|
||||
const goodsList = ref([]);
|
||||
const actStatus = ref(0);
|
||||
const actName = ref("限时秒杀");
|
||||
const currIndex = ref(0);
|
||||
const currHour = ref("00");
|
||||
const diffSeconds = ref(0);
|
||||
const hours = ref(0);
|
||||
const minutes = ref(0);
|
||||
const seconds = ref(0);
|
||||
const interval = ref(null);
|
||||
|
||||
watch(currIndex, (val) => {
|
||||
clearInterval(interval.value);
|
||||
interval.value = null;
|
||||
countDown(val);
|
||||
goodsList.value = list.value[val]?.seckillGoodsList || [];
|
||||
});
|
||||
|
||||
watch(diffSeconds, (val) => {
|
||||
const h = Math.floor(val / 3600);
|
||||
const m = Math.floor(val / 60) % 60;
|
||||
const s = val % 60;
|
||||
hours.value = filteTime(h);
|
||||
minutes.value = filteTime(m);
|
||||
seconds.value = filteTime(s);
|
||||
if (val <= 0) {
|
||||
clearInterval(interval.value);
|
||||
interval.value = null;
|
||||
}
|
||||
function filteTime(time) {
|
||||
return time < 10 ? "0" + time : time;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(interval.value);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
getListByDay();
|
||||
});
|
||||
|
||||
function goToSeckill() {
|
||||
router.push("/seckill");
|
||||
}
|
||||
|
||||
function countDown(index) {
|
||||
const zeroTime = new Date(new Date().toLocaleDateString()).getTime();
|
||||
const currTime = new Date().getTime();
|
||||
let actTime = 0;
|
||||
const nowHour = new Date().getHours();
|
||||
if (list.value[index].timeLine > nowHour) {
|
||||
actStatus.value = 0;
|
||||
actTime = zeroTime + list.value[index].timeLine * 3600 * 1000;
|
||||
} else if (list.value[index].timeLine <= nowHour) {
|
||||
actStatus.value = 1;
|
||||
if (index === list.value.length - 1) {
|
||||
actTime = zeroTime + 24 * 3600 * 1000;
|
||||
} else {
|
||||
actTime = zeroTime + list.value[index + 1].timeLine * 3600 * 1000;
|
||||
}
|
||||
}
|
||||
};
|
||||
currHour.value = list.value[currIndex.value].timeLine;
|
||||
diffSeconds.value = Math.floor((actTime - currTime) / 1000);
|
||||
interval.value = setInterval(() => {
|
||||
diffSeconds.value--;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function getListByDay() {
|
||||
list.value = props.data?.options?.list || [];
|
||||
if (!list.value.length) {
|
||||
goodsList.value = [];
|
||||
return;
|
||||
}
|
||||
goodsList.value = list.value[0]?.seckillGoodsList || [];
|
||||
if (goodsList.value.length) {
|
||||
countDown(currIndex.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.seckill {
|
||||
@@ -174,15 +132,13 @@ export default {
|
||||
width: 190px;
|
||||
height: 100%;
|
||||
color: #fff;
|
||||
background-image: url("../../../assets/images/seckillBg.png");
|
||||
|
||||
background: linear-gradient(180deg, var(--theme-color, #F31947) 0%, var(--theme-light, #ff6b35) 100%);
|
||||
.title {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
margin-top: 31px;
|
||||
}
|
||||
|
||||
.hour {
|
||||
margin-top: 90px;
|
||||
text-align: center;
|
||||
@@ -190,7 +146,6 @@ export default {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.count-down {
|
||||
margin: 10px 0 0 30px;
|
||||
> span {
|
||||
@@ -219,15 +174,21 @@ export default {
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
.act-status {
|
||||
margin: 10px 0 0 65px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.goods-scroll {
|
||||
height: 260px;
|
||||
flex: 1;
|
||||
margin-left: 10px;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.content {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -262,26 +223,12 @@ export default {
|
||||
line-height: 24px;
|
||||
display: flex;
|
||||
text-align: center;
|
||||
|
||||
span:nth-child(1) {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
width: 92px;
|
||||
background-color: $theme_color;
|
||||
position: relative;
|
||||
&::before {
|
||||
content: " ";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-color: transparent white transparent transparent;
|
||||
border-style: solid;
|
||||
border-width: 24px 8px 0 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 84px;
|
||||
}
|
||||
}
|
||||
|
||||
span:nth-child(2) {
|
||||
color: #999;
|
||||
width: 66px;
|
||||
@@ -290,35 +237,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
.swiper-container {
|
||||
height: 260px;
|
||||
width: 1000px;
|
||||
margin-left: 10px;
|
||||
background-color: #fff;
|
||||
}
|
||||
.swiper-button-prev, .swiper-button-next {
|
||||
background: #ccc;
|
||||
width: 25px;
|
||||
height: 35px;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.swiper-button-prev:hover, .swiper-button-next:hover {
|
||||
background: #aaa;
|
||||
}
|
||||
.swiper-button-prev {
|
||||
left: 0;
|
||||
border-bottom-right-radius: 18px;
|
||||
border-top-right-radius: 18px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.swiper-button-next {
|
||||
right: 0;
|
||||
border-top-left-radius: 18px;
|
||||
border-bottom-left-radius: 18px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<div class="model" v-if="data">
|
||||
<div class="for-your">{{ data.options.title }}</div>
|
||||
<div class="flex card">
|
||||
<div class="left">
|
||||
<img :src="data.options.data.image.src" alt="" />
|
||||
<div class="left hover-pointer" @click="linkTo(data.options.data.image.url)">
|
||||
<img :src="data.options.data.image.src" loading="lazy" decoding="async" alt="" />
|
||||
</div>
|
||||
<div class="right flex">
|
||||
<!-- 商品区 -->
|
||||
<div class="goods-list">
|
||||
<div class="for-your-goods-list">
|
||||
<div
|
||||
class="goods-item"
|
||||
class="for-your-goods-item"
|
||||
:key="index"
|
||||
v-for="(item, index) in data.options.data.list"
|
||||
@click="linkTo(item.url)"
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="goods-name">{{ item.title }}</div>
|
||||
<div class="goods-desc">{{ item.desc }}</div>
|
||||
</div>
|
||||
<img class="goods-img" :src="item.img" alt="" />
|
||||
<img class="goods-img" :src="item.img" loading="lazy" decoding="async" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 热卖区 -->
|
||||
@@ -30,11 +30,11 @@
|
||||
:key="index"
|
||||
@click="linkTo(item.url)"
|
||||
>
|
||||
<img :src="item.img" alt="" />
|
||||
<img :src="item.img" loading="lazy" decoding="async" alt="" />
|
||||
<div class="hot-goods">
|
||||
<div class="hot-goods-title">{{ item.title }}</div>
|
||||
<div>
|
||||
<div class="hot-price">¥{{ item.price | unitPrice }}</div>
|
||||
<div class="hot-price">¥{{ $filters.unitPrice(item.price) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,35 +44,34 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "forYour",
|
||||
data() {
|
||||
return {
|
||||
current: "",
|
||||
};
|
||||
},
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
mounted() {},
|
||||
methods: {},
|
||||
};
|
||||
defineOptions({ name: 'forYour' })
|
||||
|
||||
defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const current = ref('')
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.goods-list {
|
||||
.for-your-goods-list {
|
||||
display: flex;
|
||||
flex: 0 0 558px;
|
||||
width: 558px;
|
||||
flex-wrap: wrap;
|
||||
column-gap: 0;
|
||||
}
|
||||
.goods-item {
|
||||
.for-your-goods-item {
|
||||
flex: 0 0 278px;
|
||||
width: 278px;
|
||||
height: 277px;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
@@ -84,10 +83,8 @@ export default {
|
||||
border-top: 1.4px solid #e2e2e2;
|
||||
}
|
||||
&:hover {
|
||||
/deep/ .goods-name {
|
||||
color: $theme_color;
|
||||
}
|
||||
/deep/ .goods-desc {
|
||||
.goods-name,
|
||||
.goods-desc {
|
||||
color: $theme_color;
|
||||
}
|
||||
}
|
||||
@@ -105,10 +102,12 @@ export default {
|
||||
color: #333333;
|
||||
}
|
||||
.goods-img {
|
||||
display: block;
|
||||
width: 190px;
|
||||
height: 156px;
|
||||
margin-top: 19px;
|
||||
margin: 19px auto 0;
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.goods-desc {
|
||||
font-size: 14px;
|
||||
@@ -137,6 +136,8 @@ export default {
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0px 1px 13px 0px #E5E5E5;
|
||||
|
||||
&:hover {
|
||||
@@ -159,20 +160,27 @@ export default {
|
||||
}
|
||||
}
|
||||
.left {
|
||||
flex: 0 0 346px;
|
||||
width: 346px;
|
||||
height: 554px;
|
||||
border-radius: 9.8px 0px 0px 9.8px;
|
||||
overflow: hidden;
|
||||
> img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
.right {
|
||||
flex: 0 0 839px;
|
||||
width: 839px;
|
||||
height: 554px;
|
||||
border-radius: 0px 9.8px 9.8px 0px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hot-list {
|
||||
flex: 0 0 279px;
|
||||
width: 279px;
|
||||
}
|
||||
.hot-title {
|
||||
@@ -201,8 +209,11 @@ export default {
|
||||
}
|
||||
|
||||
> img {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
width: 76.3px;
|
||||
height: 77.7px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
.hot-goods {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user