前端样式第一版已完成
This commit is contained in:
1
.git_disabled/HEAD
Normal file
1
.git_disabled/HEAD
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/heads/main
|
||||
13
.git_disabled/config
Normal file
13
.git_disabled/config
Normal file
@@ -0,0 +1,13 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = false
|
||||
bare = false
|
||||
logallrefupdates = true
|
||||
ignorecase = true
|
||||
[remote "origin"]
|
||||
url = https://codeup.nailaoyun.cn/lq/vite-tailwindcss.git
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
[branch "main"]
|
||||
remote = origin
|
||||
merge = refs/heads/main
|
||||
vscode-merge-base = origin/main
|
||||
1
.git_disabled/description
Normal file
1
.git_disabled/description
Normal file
@@ -0,0 +1 @@
|
||||
Unnamed repository; edit this file 'description' to name the repository.
|
||||
15
.git_disabled/hooks/applypatch-msg.sample
Normal file
15
.git_disabled/hooks/applypatch-msg.sample
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to check the commit log message taken by
|
||||
# applypatch from an e-mail message.
|
||||
#
|
||||
# The hook should exit with non-zero status after issuing an
|
||||
# appropriate message if it wants to stop the commit. The hook is
|
||||
# allowed to edit the commit message file.
|
||||
#
|
||||
# To enable this hook, rename this file to "applypatch-msg".
|
||||
|
||||
. git-sh-setup
|
||||
commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
|
||||
test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
|
||||
:
|
||||
24
.git_disabled/hooks/commit-msg.sample
Normal file
24
.git_disabled/hooks/commit-msg.sample
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to check the commit log message.
|
||||
# Called by "git commit" with one argument, the name of the file
|
||||
# that has the commit message. The hook should exit with non-zero
|
||||
# status after issuing an appropriate message if it wants to stop the
|
||||
# commit. The hook is allowed to edit the commit message file.
|
||||
#
|
||||
# To enable this hook, rename this file to "commit-msg".
|
||||
|
||||
# Uncomment the below to add a Signed-off-by line to the message.
|
||||
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
|
||||
# hook is more suited to it.
|
||||
#
|
||||
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
|
||||
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
|
||||
|
||||
# This example catches duplicate Signed-off-by lines.
|
||||
|
||||
test "" = "$(grep '^Signed-off-by: ' "$1" |
|
||||
sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
|
||||
echo >&2 Duplicate Signed-off-by lines.
|
||||
exit 1
|
||||
}
|
||||
174
.git_disabled/hooks/fsmonitor-watchman.sample
Normal file
174
.git_disabled/hooks/fsmonitor-watchman.sample
Normal file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/perl
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
use IPC::Open2;
|
||||
|
||||
# An example hook script to integrate Watchman
|
||||
# (https://facebook.github.io/watchman/) with git to speed up detecting
|
||||
# new and modified files.
|
||||
#
|
||||
# The hook is passed a version (currently 2) and last update token
|
||||
# formatted as a string and outputs to stdout a new update token and
|
||||
# all files that have been modified since the update token. Paths must
|
||||
# be relative to the root of the working tree and separated by a single NUL.
|
||||
#
|
||||
# To enable this hook, rename this file to "query-watchman" and set
|
||||
# 'git config core.fsmonitor .git/hooks/query-watchman'
|
||||
#
|
||||
my ($version, $last_update_token) = @ARGV;
|
||||
|
||||
# Uncomment for debugging
|
||||
# print STDERR "$0 $version $last_update_token\n";
|
||||
|
||||
# Check the hook interface version
|
||||
if ($version ne 2) {
|
||||
die "Unsupported query-fsmonitor hook version '$version'.\n" .
|
||||
"Falling back to scanning...\n";
|
||||
}
|
||||
|
||||
my $git_work_tree = get_working_dir();
|
||||
|
||||
my $retry = 1;
|
||||
|
||||
my $json_pkg;
|
||||
eval {
|
||||
require JSON::XS;
|
||||
$json_pkg = "JSON::XS";
|
||||
1;
|
||||
} or do {
|
||||
require JSON::PP;
|
||||
$json_pkg = "JSON::PP";
|
||||
};
|
||||
|
||||
launch_watchman();
|
||||
|
||||
sub launch_watchman {
|
||||
my $o = watchman_query();
|
||||
if (is_work_tree_watched($o)) {
|
||||
output_result($o->{clock}, @{$o->{files}});
|
||||
}
|
||||
}
|
||||
|
||||
sub output_result {
|
||||
my ($clockid, @files) = @_;
|
||||
|
||||
# Uncomment for debugging watchman output
|
||||
# open (my $fh, ">", ".git/watchman-output.out");
|
||||
# binmode $fh, ":utf8";
|
||||
# print $fh "$clockid\n@files\n";
|
||||
# close $fh;
|
||||
|
||||
binmode STDOUT, ":utf8";
|
||||
print $clockid;
|
||||
print "\0";
|
||||
local $, = "\0";
|
||||
print @files;
|
||||
}
|
||||
|
||||
sub watchman_clock {
|
||||
my $response = qx/watchman clock "$git_work_tree"/;
|
||||
die "Failed to get clock id on '$git_work_tree'.\n" .
|
||||
"Falling back to scanning...\n" if $? != 0;
|
||||
|
||||
return $json_pkg->new->utf8->decode($response);
|
||||
}
|
||||
|
||||
sub watchman_query {
|
||||
my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
|
||||
or die "open2() failed: $!\n" .
|
||||
"Falling back to scanning...\n";
|
||||
|
||||
# In the query expression below we're asking for names of files that
|
||||
# changed since $last_update_token but not from the .git folder.
|
||||
#
|
||||
# To accomplish this, we're using the "since" generator to use the
|
||||
# recency index to select candidate nodes and "fields" to limit the
|
||||
# output to file names only. Then we're using the "expression" term to
|
||||
# further constrain the results.
|
||||
my $last_update_line = "";
|
||||
if (substr($last_update_token, 0, 1) eq "c") {
|
||||
$last_update_token = "\"$last_update_token\"";
|
||||
$last_update_line = qq[\n"since": $last_update_token,];
|
||||
}
|
||||
my $query = <<" END";
|
||||
["query", "$git_work_tree", {$last_update_line
|
||||
"fields": ["name"],
|
||||
"expression": ["not", ["dirname", ".git"]]
|
||||
}]
|
||||
END
|
||||
|
||||
# Uncomment for debugging the watchman query
|
||||
# open (my $fh, ">", ".git/watchman-query.json");
|
||||
# print $fh $query;
|
||||
# close $fh;
|
||||
|
||||
print CHLD_IN $query;
|
||||
close CHLD_IN;
|
||||
my $response = do {local $/; <CHLD_OUT>};
|
||||
|
||||
# Uncomment for debugging the watch response
|
||||
# open ($fh, ">", ".git/watchman-response.json");
|
||||
# print $fh $response;
|
||||
# close $fh;
|
||||
|
||||
die "Watchman: command returned no output.\n" .
|
||||
"Falling back to scanning...\n" if $response eq "";
|
||||
die "Watchman: command returned invalid output: $response\n" .
|
||||
"Falling back to scanning...\n" unless $response =~ /^\{/;
|
||||
|
||||
return $json_pkg->new->utf8->decode($response);
|
||||
}
|
||||
|
||||
sub is_work_tree_watched {
|
||||
my ($output) = @_;
|
||||
my $error = $output->{error};
|
||||
if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
|
||||
$retry--;
|
||||
my $response = qx/watchman watch "$git_work_tree"/;
|
||||
die "Failed to make watchman watch '$git_work_tree'.\n" .
|
||||
"Falling back to scanning...\n" if $? != 0;
|
||||
$output = $json_pkg->new->utf8->decode($response);
|
||||
$error = $output->{error};
|
||||
die "Watchman: $error.\n" .
|
||||
"Falling back to scanning...\n" if $error;
|
||||
|
||||
# Uncomment for debugging watchman output
|
||||
# open (my $fh, ">", ".git/watchman-output.out");
|
||||
# close $fh;
|
||||
|
||||
# Watchman will always return all files on the first query so
|
||||
# return the fast "everything is dirty" flag to git and do the
|
||||
# Watchman query just to get it over with now so we won't pay
|
||||
# the cost in git to look up each individual file.
|
||||
my $o = watchman_clock();
|
||||
$error = $output->{error};
|
||||
|
||||
die "Watchman: $error.\n" .
|
||||
"Falling back to scanning...\n" if $error;
|
||||
|
||||
output_result($o->{clock}, ("/"));
|
||||
$last_update_token = $o->{clock};
|
||||
|
||||
eval { launch_watchman() };
|
||||
return 0;
|
||||
}
|
||||
|
||||
die "Watchman: $error.\n" .
|
||||
"Falling back to scanning...\n" if $error;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
sub get_working_dir {
|
||||
my $working_dir;
|
||||
if ($^O =~ 'msys' || $^O =~ 'cygwin') {
|
||||
$working_dir = Win32::GetCwd();
|
||||
$working_dir =~ tr/\\/\//;
|
||||
} else {
|
||||
require Cwd;
|
||||
$working_dir = Cwd::cwd();
|
||||
}
|
||||
|
||||
return $working_dir;
|
||||
}
|
||||
8
.git_disabled/hooks/post-update.sample
Normal file
8
.git_disabled/hooks/post-update.sample
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to prepare a packed repository for use over
|
||||
# dumb transports.
|
||||
#
|
||||
# To enable this hook, rename this file to "post-update".
|
||||
|
||||
exec git update-server-info
|
||||
14
.git_disabled/hooks/pre-applypatch.sample
Normal file
14
.git_disabled/hooks/pre-applypatch.sample
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to verify what is about to be committed
|
||||
# by applypatch from an e-mail message.
|
||||
#
|
||||
# The hook should exit with non-zero status after issuing an
|
||||
# appropriate message if it wants to stop the commit.
|
||||
#
|
||||
# To enable this hook, rename this file to "pre-applypatch".
|
||||
|
||||
. git-sh-setup
|
||||
precommit="$(git rev-parse --git-path hooks/pre-commit)"
|
||||
test -x "$precommit" && exec "$precommit" ${1+"$@"}
|
||||
:
|
||||
49
.git_disabled/hooks/pre-commit.sample
Normal file
49
.git_disabled/hooks/pre-commit.sample
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to verify what is about to be committed.
|
||||
# Called by "git commit" with no arguments. The hook should
|
||||
# exit with non-zero status after issuing an appropriate message if
|
||||
# it wants to stop the commit.
|
||||
#
|
||||
# To enable this hook, rename this file to "pre-commit".
|
||||
|
||||
if git rev-parse --verify HEAD >/dev/null 2>&1
|
||||
then
|
||||
against=HEAD
|
||||
else
|
||||
# Initial commit: diff against an empty tree object
|
||||
against=$(git hash-object -t tree /dev/null)
|
||||
fi
|
||||
|
||||
# If you want to allow non-ASCII filenames set this variable to true.
|
||||
allownonascii=$(git config --type=bool hooks.allownonascii)
|
||||
|
||||
# Redirect output to stderr.
|
||||
exec 1>&2
|
||||
|
||||
# Cross platform projects tend to avoid non-ASCII filenames; prevent
|
||||
# them from being added to the repository. We exploit the fact that the
|
||||
# printable range starts at the space character and ends with tilde.
|
||||
if [ "$allownonascii" != "true" ] &&
|
||||
# Note that the use of brackets around a tr range is ok here, (it's
|
||||
# even required, for portability to Solaris 10's /usr/bin/tr), since
|
||||
# the square bracket bytes happen to fall in the designated range.
|
||||
test $(git diff-index --cached --name-only --diff-filter=A -z $against |
|
||||
LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
|
||||
then
|
||||
cat <<\EOF
|
||||
Error: Attempt to add a non-ASCII file name.
|
||||
|
||||
This can cause problems if you want to work with people on other platforms.
|
||||
|
||||
To be portable it is advisable to rename the file.
|
||||
|
||||
If you know what you are doing you can disable this check using:
|
||||
|
||||
git config hooks.allownonascii true
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# If there are whitespace errors, print the offending file names and fail.
|
||||
exec git diff-index --check --cached $against --
|
||||
13
.git_disabled/hooks/pre-merge-commit.sample
Normal file
13
.git_disabled/hooks/pre-merge-commit.sample
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to verify what is about to be committed.
|
||||
# Called by "git merge" with no arguments. The hook should
|
||||
# exit with non-zero status after issuing an appropriate message to
|
||||
# stderr if it wants to stop the merge commit.
|
||||
#
|
||||
# To enable this hook, rename this file to "pre-merge-commit".
|
||||
|
||||
. git-sh-setup
|
||||
test -x "$GIT_DIR/hooks/pre-commit" &&
|
||||
exec "$GIT_DIR/hooks/pre-commit"
|
||||
:
|
||||
53
.git_disabled/hooks/pre-push.sample
Normal file
53
.git_disabled/hooks/pre-push.sample
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/bin/sh
|
||||
|
||||
# An example hook script to verify what is about to be pushed. Called by "git
|
||||
# push" after it has checked the remote status, but before anything has been
|
||||
# pushed. If this script exits with a non-zero status nothing will be pushed.
|
||||
#
|
||||
# This hook is called with the following parameters:
|
||||
#
|
||||
# $1 -- Name of the remote to which the push is being done
|
||||
# $2 -- URL to which the push is being done
|
||||
#
|
||||
# If pushing without using a named remote those arguments will be equal.
|
||||
#
|
||||
# Information about the commits which are being pushed is supplied as lines to
|
||||
# the standard input in the form:
|
||||
#
|
||||
# <local ref> <local oid> <remote ref> <remote oid>
|
||||
#
|
||||
# This sample shows how to prevent push of commits where the log message starts
|
||||
# with "WIP" (work in progress).
|
||||
|
||||
remote="$1"
|
||||
url="$2"
|
||||
|
||||
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
|
||||
|
||||
while read local_ref local_oid remote_ref remote_oid
|
||||
do
|
||||
if test "$local_oid" = "$zero"
|
||||
then
|
||||
# Handle delete
|
||||
:
|
||||
else
|
||||
if test "$remote_oid" = "$zero"
|
||||
then
|
||||
# New branch, examine all commits
|
||||
range="$local_oid"
|
||||
else
|
||||
# Update to existing branch, examine new commits
|
||||
range="$remote_oid..$local_oid"
|
||||
fi
|
||||
|
||||
# Check for WIP commit
|
||||
commit=$(git rev-list -n 1 --grep '^WIP' "$range")
|
||||
if test -n "$commit"
|
||||
then
|
||||
echo >&2 "Found WIP commit in $local_ref, not pushing"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
exit 0
|
||||
169
.git_disabled/hooks/pre-rebase.sample
Normal file
169
.git_disabled/hooks/pre-rebase.sample
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Copyright (c) 2006, 2008 Junio C Hamano
|
||||
#
|
||||
# The "pre-rebase" hook is run just before "git rebase" starts doing
|
||||
# its job, and can prevent the command from running by exiting with
|
||||
# non-zero status.
|
||||
#
|
||||
# The hook is called with the following parameters:
|
||||
#
|
||||
# $1 -- the upstream the series was forked from.
|
||||
# $2 -- the branch being rebased (or empty when rebasing the current branch).
|
||||
#
|
||||
# This sample shows how to prevent topic branches that are already
|
||||
# merged to 'next' branch from getting rebased, because allowing it
|
||||
# would result in rebasing already published history.
|
||||
|
||||
publish=next
|
||||
basebranch="$1"
|
||||
if test "$#" = 2
|
||||
then
|
||||
topic="refs/heads/$2"
|
||||
else
|
||||
topic=`git symbolic-ref HEAD` ||
|
||||
exit 0 ;# we do not interrupt rebasing detached HEAD
|
||||
fi
|
||||
|
||||
case "$topic" in
|
||||
refs/heads/??/*)
|
||||
;;
|
||||
*)
|
||||
exit 0 ;# we do not interrupt others.
|
||||
;;
|
||||
esac
|
||||
|
||||
# Now we are dealing with a topic branch being rebased
|
||||
# on top of master. Is it OK to rebase it?
|
||||
|
||||
# Does the topic really exist?
|
||||
git show-ref -q "$topic" || {
|
||||
echo >&2 "No such branch $topic"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Is topic fully merged to master?
|
||||
not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
|
||||
if test -z "$not_in_master"
|
||||
then
|
||||
echo >&2 "$topic is fully merged to master; better remove it."
|
||||
exit 1 ;# we could allow it, but there is no point.
|
||||
fi
|
||||
|
||||
# Is topic ever merged to next? If so you should not be rebasing it.
|
||||
only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
|
||||
only_next_2=`git rev-list ^master ${publish} | sort`
|
||||
if test "$only_next_1" = "$only_next_2"
|
||||
then
|
||||
not_in_topic=`git rev-list "^$topic" master`
|
||||
if test -z "$not_in_topic"
|
||||
then
|
||||
echo >&2 "$topic is already up to date with master"
|
||||
exit 1 ;# we could allow it, but there is no point.
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
|
||||
/usr/bin/perl -e '
|
||||
my $topic = $ARGV[0];
|
||||
my $msg = "* $topic has commits already merged to public branch:\n";
|
||||
my (%not_in_next) = map {
|
||||
/^([0-9a-f]+) /;
|
||||
($1 => 1);
|
||||
} split(/\n/, $ARGV[1]);
|
||||
for my $elem (map {
|
||||
/^([0-9a-f]+) (.*)$/;
|
||||
[$1 => $2];
|
||||
} split(/\n/, $ARGV[2])) {
|
||||
if (!exists $not_in_next{$elem->[0]}) {
|
||||
if ($msg) {
|
||||
print STDERR $msg;
|
||||
undef $msg;
|
||||
}
|
||||
print STDERR " $elem->[1]\n";
|
||||
}
|
||||
}
|
||||
' "$topic" "$not_in_next" "$not_in_master"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
<<\DOC_END
|
||||
|
||||
This sample hook safeguards topic branches that have been
|
||||
published from being rewound.
|
||||
|
||||
The workflow assumed here is:
|
||||
|
||||
* Once a topic branch forks from "master", "master" is never
|
||||
merged into it again (either directly or indirectly).
|
||||
|
||||
* Once a topic branch is fully cooked and merged into "master",
|
||||
it is deleted. If you need to build on top of it to correct
|
||||
earlier mistakes, a new topic branch is created by forking at
|
||||
the tip of the "master". This is not strictly necessary, but
|
||||
it makes it easier to keep your history simple.
|
||||
|
||||
* Whenever you need to test or publish your changes to topic
|
||||
branches, merge them into "next" branch.
|
||||
|
||||
The script, being an example, hardcodes the publish branch name
|
||||
to be "next", but it is trivial to make it configurable via
|
||||
$GIT_DIR/config mechanism.
|
||||
|
||||
With this workflow, you would want to know:
|
||||
|
||||
(1) ... if a topic branch has ever been merged to "next". Young
|
||||
topic branches can have stupid mistakes you would rather
|
||||
clean up before publishing, and things that have not been
|
||||
merged into other branches can be easily rebased without
|
||||
affecting other people. But once it is published, you would
|
||||
not want to rewind it.
|
||||
|
||||
(2) ... if a topic branch has been fully merged to "master".
|
||||
Then you can delete it. More importantly, you should not
|
||||
build on top of it -- other people may already want to
|
||||
change things related to the topic as patches against your
|
||||
"master", so if you need further changes, it is better to
|
||||
fork the topic (perhaps with the same name) afresh from the
|
||||
tip of "master".
|
||||
|
||||
Let's look at this example:
|
||||
|
||||
o---o---o---o---o---o---o---o---o---o "next"
|
||||
/ / / /
|
||||
/ a---a---b A / /
|
||||
/ / / /
|
||||
/ / c---c---c---c B /
|
||||
/ / / \ /
|
||||
/ / / b---b C \ /
|
||||
/ / / / \ /
|
||||
---o---o---o---o---o---o---o---o---o---o---o "master"
|
||||
|
||||
|
||||
A, B and C are topic branches.
|
||||
|
||||
* A has one fix since it was merged up to "next".
|
||||
|
||||
* B has finished. It has been fully merged up to "master" and "next",
|
||||
and is ready to be deleted.
|
||||
|
||||
* C has not merged to "next" at all.
|
||||
|
||||
We would want to allow C to be rebased, refuse A, and encourage
|
||||
B to be deleted.
|
||||
|
||||
To compute (1):
|
||||
|
||||
git rev-list ^master ^topic next
|
||||
git rev-list ^master next
|
||||
|
||||
if these match, topic has not merged in next at all.
|
||||
|
||||
To compute (2):
|
||||
|
||||
git rev-list master..topic
|
||||
|
||||
if this is empty, it is fully merged to "master".
|
||||
|
||||
DOC_END
|
||||
24
.git_disabled/hooks/pre-receive.sample
Normal file
24
.git_disabled/hooks/pre-receive.sample
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to make use of push options.
|
||||
# The example simply echoes all push options that start with 'echoback='
|
||||
# and rejects all pushes when the "reject" push option is used.
|
||||
#
|
||||
# To enable this hook, rename this file to "pre-receive".
|
||||
|
||||
if test -n "$GIT_PUSH_OPTION_COUNT"
|
||||
then
|
||||
i=0
|
||||
while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
|
||||
do
|
||||
eval "value=\$GIT_PUSH_OPTION_$i"
|
||||
case "$value" in
|
||||
echoback=*)
|
||||
echo "echo from the pre-receive-hook: ${value#*=}" >&2
|
||||
;;
|
||||
reject)
|
||||
exit 1
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
fi
|
||||
42
.git_disabled/hooks/prepare-commit-msg.sample
Normal file
42
.git_disabled/hooks/prepare-commit-msg.sample
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to prepare the commit log message.
|
||||
# Called by "git commit" with the name of the file that has the
|
||||
# commit message, followed by the description of the commit
|
||||
# message's source. The hook's purpose is to edit the commit
|
||||
# message file. If the hook fails with a non-zero status,
|
||||
# the commit is aborted.
|
||||
#
|
||||
# To enable this hook, rename this file to "prepare-commit-msg".
|
||||
|
||||
# This hook includes three examples. The first one removes the
|
||||
# "# Please enter the commit message..." help message.
|
||||
#
|
||||
# The second includes the output of "git diff --name-status -r"
|
||||
# into the message, just before the "git status" output. It is
|
||||
# commented because it doesn't cope with --amend or with squashed
|
||||
# commits.
|
||||
#
|
||||
# The third example adds a Signed-off-by line to the message, that can
|
||||
# still be edited. This is rarely a good idea.
|
||||
|
||||
COMMIT_MSG_FILE=$1
|
||||
COMMIT_SOURCE=$2
|
||||
SHA1=$3
|
||||
|
||||
/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"
|
||||
|
||||
# case "$COMMIT_SOURCE,$SHA1" in
|
||||
# ,|template,)
|
||||
# /usr/bin/perl -i.bak -pe '
|
||||
# print "\n" . `git diff --cached --name-status -r`
|
||||
# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
|
||||
# *) ;;
|
||||
# esac
|
||||
|
||||
# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
|
||||
# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
|
||||
# if test -z "$COMMIT_SOURCE"
|
||||
# then
|
||||
# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
|
||||
# fi
|
||||
78
.git_disabled/hooks/push-to-checkout.sample
Normal file
78
.git_disabled/hooks/push-to-checkout.sample
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/bin/sh
|
||||
|
||||
# An example hook script to update a checked-out tree on a git push.
|
||||
#
|
||||
# This hook is invoked by git-receive-pack(1) when it reacts to git
|
||||
# push and updates reference(s) in its repository, and when the push
|
||||
# tries to update the branch that is currently checked out and the
|
||||
# receive.denyCurrentBranch configuration variable is set to
|
||||
# updateInstead.
|
||||
#
|
||||
# By default, such a push is refused if the working tree and the index
|
||||
# of the remote repository has any difference from the currently
|
||||
# checked out commit; when both the working tree and the index match
|
||||
# the current commit, they are updated to match the newly pushed tip
|
||||
# of the branch. This hook is to be used to override the default
|
||||
# behaviour; however the code below reimplements the default behaviour
|
||||
# as a starting point for convenient modification.
|
||||
#
|
||||
# The hook receives the commit with which the tip of the current
|
||||
# branch is going to be updated:
|
||||
commit=$1
|
||||
|
||||
# It can exit with a non-zero status to refuse the push (when it does
|
||||
# so, it must not modify the index or the working tree).
|
||||
die () {
|
||||
echo >&2 "$*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Or it can make any necessary changes to the working tree and to the
|
||||
# index to bring them to the desired state when the tip of the current
|
||||
# branch is updated to the new commit, and exit with a zero status.
|
||||
#
|
||||
# For example, the hook can simply run git read-tree -u -m HEAD "$1"
|
||||
# in order to emulate git fetch that is run in the reverse direction
|
||||
# with git push, as the two-tree form of git read-tree -u -m is
|
||||
# essentially the same as git switch or git checkout that switches
|
||||
# branches while keeping the local changes in the working tree that do
|
||||
# not interfere with the difference between the branches.
|
||||
|
||||
# The below is a more-or-less exact translation to shell of the C code
|
||||
# for the default behaviour for git's push-to-checkout hook defined in
|
||||
# the push_to_deploy() function in builtin/receive-pack.c.
|
||||
#
|
||||
# Note that the hook will be executed from the repository directory,
|
||||
# not from the working tree, so if you want to perform operations on
|
||||
# the working tree, you will have to adapt your code accordingly, e.g.
|
||||
# by adding "cd .." or using relative paths.
|
||||
|
||||
if ! git update-index -q --ignore-submodules --refresh
|
||||
then
|
||||
die "Up-to-date check failed"
|
||||
fi
|
||||
|
||||
if ! git diff-files --quiet --ignore-submodules --
|
||||
then
|
||||
die "Working directory has unstaged changes"
|
||||
fi
|
||||
|
||||
# This is a rough translation of:
|
||||
#
|
||||
# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
|
||||
if git cat-file -e HEAD 2>/dev/null
|
||||
then
|
||||
head=HEAD
|
||||
else
|
||||
head=$(git hash-object -t tree --stdin </dev/null)
|
||||
fi
|
||||
|
||||
if ! git diff-index --quiet --cached --ignore-submodules $head --
|
||||
then
|
||||
die "Working directory has staged changes"
|
||||
fi
|
||||
|
||||
if ! git read-tree -u -m "$commit"
|
||||
then
|
||||
die "Could not update working tree to new HEAD"
|
||||
fi
|
||||
77
.git_disabled/hooks/sendemail-validate.sample
Normal file
77
.git_disabled/hooks/sendemail-validate.sample
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/bin/sh
|
||||
|
||||
# An example hook script to validate a patch (and/or patch series) before
|
||||
# sending it via email.
|
||||
#
|
||||
# The hook should exit with non-zero status after issuing an appropriate
|
||||
# message if it wants to prevent the email(s) from being sent.
|
||||
#
|
||||
# To enable this hook, rename this file to "sendemail-validate".
|
||||
#
|
||||
# By default, it will only check that the patch(es) can be applied on top of
|
||||
# the default upstream branch without conflicts in a secondary worktree. After
|
||||
# validation (successful or not) of the last patch of a series, the worktree
|
||||
# will be deleted.
|
||||
#
|
||||
# The following config variables can be set to change the default remote and
|
||||
# remote ref that are used to apply the patches against:
|
||||
#
|
||||
# sendemail.validateRemote (default: origin)
|
||||
# sendemail.validateRemoteRef (default: HEAD)
|
||||
#
|
||||
# Replace the TODO placeholders with appropriate checks according to your
|
||||
# needs.
|
||||
|
||||
validate_cover_letter () {
|
||||
file="$1"
|
||||
# TODO: Replace with appropriate checks (e.g. spell checking).
|
||||
true
|
||||
}
|
||||
|
||||
validate_patch () {
|
||||
file="$1"
|
||||
# Ensure that the patch applies without conflicts.
|
||||
git am -3 "$file" || return
|
||||
# TODO: Replace with appropriate checks for this patch
|
||||
# (e.g. checkpatch.pl).
|
||||
true
|
||||
}
|
||||
|
||||
validate_series () {
|
||||
# TODO: Replace with appropriate checks for the whole series
|
||||
# (e.g. quick build, coding style checks, etc.).
|
||||
true
|
||||
}
|
||||
|
||||
# main -------------------------------------------------------------------------
|
||||
|
||||
if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1
|
||||
then
|
||||
remote=$(git config --default origin --get sendemail.validateRemote) &&
|
||||
ref=$(git config --default HEAD --get sendemail.validateRemoteRef) &&
|
||||
worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) &&
|
||||
git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" &&
|
||||
git config --replace-all sendemail.validateWorktree "$worktree"
|
||||
else
|
||||
worktree=$(git config --get sendemail.validateWorktree)
|
||||
fi || {
|
||||
echo "sendemail-validate: error: failed to prepare worktree" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
unset GIT_DIR GIT_WORK_TREE
|
||||
cd "$worktree" &&
|
||||
|
||||
if grep -q "^diff --git " "$1"
|
||||
then
|
||||
validate_patch "$1"
|
||||
else
|
||||
validate_cover_letter "$1"
|
||||
fi &&
|
||||
|
||||
if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL"
|
||||
then
|
||||
git config --unset-all sendemail.validateWorktree &&
|
||||
trap 'git worktree remove -ff "$worktree"' EXIT &&
|
||||
validate_series
|
||||
fi
|
||||
128
.git_disabled/hooks/update.sample
Normal file
128
.git_disabled/hooks/update.sample
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to block unannotated tags from entering.
|
||||
# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
|
||||
#
|
||||
# To enable this hook, rename this file to "update".
|
||||
#
|
||||
# Config
|
||||
# ------
|
||||
# hooks.allowunannotated
|
||||
# This boolean sets whether unannotated tags will be allowed into the
|
||||
# repository. By default they won't be.
|
||||
# hooks.allowdeletetag
|
||||
# This boolean sets whether deleting tags will be allowed in the
|
||||
# repository. By default they won't be.
|
||||
# hooks.allowmodifytag
|
||||
# This boolean sets whether a tag may be modified after creation. By default
|
||||
# it won't be.
|
||||
# hooks.allowdeletebranch
|
||||
# This boolean sets whether deleting branches will be allowed in the
|
||||
# repository. By default they won't be.
|
||||
# hooks.denycreatebranch
|
||||
# This boolean sets whether remotely creating branches will be denied
|
||||
# in the repository. By default this is allowed.
|
||||
#
|
||||
|
||||
# --- Command line
|
||||
refname="$1"
|
||||
oldrev="$2"
|
||||
newrev="$3"
|
||||
|
||||
# --- Safety check
|
||||
if [ -z "$GIT_DIR" ]; then
|
||||
echo "Don't run this script from the command line." >&2
|
||||
echo " (if you want, you could supply GIT_DIR then run" >&2
|
||||
echo " $0 <ref> <oldrev> <newrev>)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
|
||||
echo "usage: $0 <ref> <oldrev> <newrev>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Config
|
||||
allowunannotated=$(git config --type=bool hooks.allowunannotated)
|
||||
allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
|
||||
denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
|
||||
allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
|
||||
allowmodifytag=$(git config --type=bool hooks.allowmodifytag)
|
||||
|
||||
# check for no description
|
||||
projectdesc=$(sed -e '1q' "$GIT_DIR/description")
|
||||
case "$projectdesc" in
|
||||
"Unnamed repository"* | "")
|
||||
echo "*** Project description file hasn't been set" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Check types
|
||||
# if $newrev is 0000...0000, it's a commit to delete a ref.
|
||||
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
|
||||
if [ "$newrev" = "$zero" ]; then
|
||||
newrev_type=delete
|
||||
else
|
||||
newrev_type=$(git cat-file -t $newrev)
|
||||
fi
|
||||
|
||||
case "$refname","$newrev_type" in
|
||||
refs/tags/*,commit)
|
||||
# un-annotated tag
|
||||
short_refname=${refname##refs/tags/}
|
||||
if [ "$allowunannotated" != "true" ]; then
|
||||
echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
|
||||
echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/tags/*,delete)
|
||||
# delete tag
|
||||
if [ "$allowdeletetag" != "true" ]; then
|
||||
echo "*** Deleting a tag is not allowed in this repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/tags/*,tag)
|
||||
# annotated tag
|
||||
if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
|
||||
then
|
||||
echo "*** Tag '$refname' already exists." >&2
|
||||
echo "*** Modifying a tag is not allowed in this repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/heads/*,commit)
|
||||
# branch
|
||||
if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
|
||||
echo "*** Creating a branch is not allowed in this repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/heads/*,delete)
|
||||
# delete branch
|
||||
if [ "$allowdeletebranch" != "true" ]; then
|
||||
echo "*** Deleting a branch is not allowed in this repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/remotes/*,commit)
|
||||
# tracking branch
|
||||
;;
|
||||
refs/remotes/*,delete)
|
||||
# delete tracking branch
|
||||
if [ "$allowdeletebranch" != "true" ]; then
|
||||
echo "*** Deleting a tracking branch is not allowed in this repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# Anything else (is there anything else?)
|
||||
echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Finished
|
||||
exit 0
|
||||
BIN
.git_disabled/index
Normal file
BIN
.git_disabled/index
Normal file
Binary file not shown.
6
.git_disabled/info/exclude
Normal file
6
.git_disabled/info/exclude
Normal file
@@ -0,0 +1,6 @@
|
||||
# git ls-files --others --exclude-from=.git/info/exclude
|
||||
# Lines that start with '#' are comments.
|
||||
# For a project mostly in C, the following would be a good set of
|
||||
# exclude patterns (uncomment them if you want to use them):
|
||||
# *.[oa]
|
||||
# *~
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
2
.git_disabled/packed-refs
Normal file
2
.git_disabled/packed-refs
Normal file
@@ -0,0 +1,2 @@
|
||||
# pack-refs with: peeled fully-peeled sorted
|
||||
67f9f5181634c2564201942f1e042c74d4759f44 refs/remotes/origin/main
|
||||
1
.git_disabled/refs/heads/main
Normal file
1
.git_disabled/refs/heads/main
Normal file
@@ -0,0 +1 @@
|
||||
67f9f5181634c2564201942f1e042c74d4759f44
|
||||
1
.git_disabled/refs/remotes/origin/HEAD
Normal file
1
.git_disabled/refs/remotes/origin/HEAD
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/remotes/origin/main
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
214
README.md
Normal file
214
README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# CMS内容管理系统 - 前端项目
|
||||
|
||||
## 项目简介
|
||||
|
||||
这是一个基于Vue 3 + TailwindCSS开发的现代化CMS内容管理系统前端项目,包含完整的前台展示和后台管理功能。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端框架**: Vue 3 + Composition API
|
||||
- **构建工具**: Vite
|
||||
- **样式框架**: TailwindCSS
|
||||
- **路由管理**: Vue Router
|
||||
- **状态管理**: Pinia
|
||||
- **开发语言**: JavaScript/TypeScript
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 🏠 前台功能
|
||||
- **响应式首页** - 英雄区域、特性介绍、最新内容展示
|
||||
- **技术文章** - 文章列表、搜索筛选、详情页面
|
||||
- **新闻资讯** - 新闻展示、分类浏览、热门推荐
|
||||
- **关于我们** - 公司介绍、团队展示
|
||||
- **联系我们** - 联系信息、在线表单
|
||||
|
||||
### 🔧 后台管理
|
||||
- **仪表盘** - 数据统计、快速操作
|
||||
- **内容管理** - 文章/新闻的增删改查
|
||||
- **用户管理** - 用户信息、权限控制
|
||||
- **系统设置** - 基本配置、安全设置
|
||||
|
||||
### 📱 移动端适配
|
||||
- **响应式设计** - 完美适配各种设备
|
||||
- **触摸优化** - 移动端友好的交互体验
|
||||
- **折叠菜单** - 移动端导航优化
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
cms-view-new/
|
||||
├── public/ # 静态资源
|
||||
├── src/
|
||||
│ ├── components/ # 公共组件
|
||||
│ ├── layouts/ # 布局组件
|
||||
│ │ ├── Header.vue # 前台布局
|
||||
│ │ └── AdminLayout.vue # 后台布局
|
||||
│ ├── views/ # 页面组件
|
||||
│ │ ├── index/ # 首页
|
||||
│ │ ├── articles/ # 文章页面
|
||||
│ │ ├── news/ # 新闻页面
|
||||
│ │ ├── about/ # 关于页面
|
||||
│ │ ├── contact/ # 联系页面
|
||||
│ │ ├── auth/ # 认证页面
|
||||
│ │ └── admin/ # 管理后台
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── stores/ # 状态管理
|
||||
│ └── style.css # 全局样式
|
||||
├── package.json
|
||||
└── vite.config.js
|
||||
```
|
||||
|
||||
## 安装和运行
|
||||
|
||||
### 环境要求
|
||||
- Node.js >= 16.0.0
|
||||
- npm >= 8.0.0
|
||||
|
||||
### 安装依赖
|
||||
```bash
|
||||
cd cms-view-new
|
||||
npm install
|
||||
```
|
||||
|
||||
### 开发模式
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
访问: http://localhost:8890
|
||||
|
||||
### 生产构建
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 预览构建结果
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 页面路由
|
||||
|
||||
### 前台路由
|
||||
- `/` - 首页
|
||||
- `/articles` - 技术文章列表
|
||||
- `/articles/:id` - 文章详情
|
||||
- `/news` - 新闻资讯列表
|
||||
- `/news/:id` - 新闻详情
|
||||
- `/about` - 关于我们
|
||||
- `/contact` - 联系我们
|
||||
|
||||
### 后台路由
|
||||
- `/login` - 登录页面
|
||||
- `/admin` - 管理后台首页
|
||||
- `/admin/dashboard` - 仪表盘
|
||||
- `/admin/articles` - 文章管理
|
||||
- `/admin/news` - 新闻管理
|
||||
- `/admin/users` - 用户管理
|
||||
- `/admin/settings` - 系统设置
|
||||
|
||||
## 设计特色
|
||||
|
||||
### 🎨 视觉设计
|
||||
- **现代化界面** - 简洁美观的设计风格
|
||||
- **深蓝色主题** - 专业的配色方案
|
||||
- **渐变效果** - 丰富的视觉层次
|
||||
- **卡片布局** - 清晰的信息组织
|
||||
|
||||
### 🔄 交互体验
|
||||
- **流畅动画** - 页面切换和悬停效果
|
||||
- **响应式反馈** - 即时的用户操作反馈
|
||||
- **加载状态** - 友好的加载提示
|
||||
- **错误处理** - 完善的错误提示机制
|
||||
|
||||
### 📱 移动端优化
|
||||
- **触摸友好** - 适合手指操作的按钮大小
|
||||
- **滑动菜单** - 移动端专用的导航方式
|
||||
- **自适应布局** - 内容自动适配屏幕尺寸
|
||||
- **性能优化** - 移动端加载速度优化
|
||||
|
||||
## 开发说明
|
||||
|
||||
### 组件开发
|
||||
- 使用Vue 3 Composition API
|
||||
- 遵循单一职责原则
|
||||
- 保持组件的可复用性
|
||||
|
||||
### 样式开发
|
||||
- 使用TailwindCSS工具类
|
||||
- 保持响应式设计
|
||||
- 遵循设计系统规范
|
||||
|
||||
### 路由管理
|
||||
- 使用Vue Router 4
|
||||
- 实现路由守卫
|
||||
- 支持动态路由
|
||||
|
||||
## 部署说明
|
||||
|
||||
### 静态部署
|
||||
构建后的`dist`目录可以部署到任何静态文件服务器:
|
||||
- Nginx
|
||||
- Apache
|
||||
- Vercel
|
||||
- Netlify
|
||||
|
||||
### 服务器配置
|
||||
需要配置单页应用的路由重写规则,将所有路由指向`index.html`。
|
||||
|
||||
## 浏览器支持
|
||||
|
||||
- Chrome >= 87
|
||||
- Firefox >= 78
|
||||
- Safari >= 14
|
||||
- Edge >= 88
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题或建议,请联系开发团队。
|
||||
|
||||
## API接口说明
|
||||
|
||||
### 基础配置
|
||||
```javascript
|
||||
// src/api/index.js
|
||||
const API_BASE_URL = 'http://localhost:3000/api'
|
||||
```
|
||||
|
||||
### 主要接口
|
||||
- `GET /articles` - 获取文章列表
|
||||
- `GET /articles/:id` - 获取文章详情
|
||||
- `POST /articles` - 创建文章
|
||||
- `PUT /articles/:id` - 更新文章
|
||||
- `DELETE /articles/:id` - 删除文章
|
||||
- `GET /news` - 获取新闻列表
|
||||
- `GET /categories` - 获取分类列表
|
||||
- `POST /auth/login` - 用户登录
|
||||
- `GET /auth/profile` - 获取用户信息
|
||||
|
||||
### 空状态处理
|
||||
当列表数据为空时,系统会显示EmptyState组件:
|
||||
```vue
|
||||
<EmptyState
|
||||
title="暂时没有数据"
|
||||
description="敬请期待更多精彩内容"
|
||||
/>
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2024-01-16)
|
||||
- ✅ 完成前台所有页面开发
|
||||
- ✅ 完成管理后台功能
|
||||
- ✅ 集成cms-api接口
|
||||
- ✅ 添加空状态组件
|
||||
- ✅ 修复所有语法错误
|
||||
- ✅ 完成移动端适配
|
||||
- ✅ 优化图片资源加载
|
||||
|
||||
---
|
||||
|
||||
© 2024 CMS内容管理系统. 保留所有权利.
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "spa-view",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"tailwindcss": "^4.1.4",
|
||||
"vue": "^3.5.13",
|
||||
"@ant-design-vue/pro-layout": "^3.2.5",
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@vueuse/head": "^2.0.0",
|
||||
"ant-design-vue": "^4.1.1",
|
||||
"axios": "^1.6.2",
|
||||
"lucide-vue-next": "^0.507.0",
|
||||
"pinia": "^2.1.7",
|
||||
"swiper": "^11.0.5",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.2",
|
||||
"sass": "^1.71.0",
|
||||
"vite": "^6.3.1"
|
||||
}
|
||||
}
|
||||
1951
pnpm-lock.yaml
generated
Normal file
1951
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
11
src/App.vue
Normal file
11
src/App.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
|
||||
</style>
|
||||
|
||||
290
src/api/index.js
Normal file
290
src/api/index.js
Normal file
@@ -0,0 +1,290 @@
|
||||
// API 基础配置
|
||||
const API_BASE_URL = 'http://localhost:3000/api'
|
||||
|
||||
// 通用请求函数
|
||||
const request = async (url, options = {}) => {
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${url}`, config)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('API request failed:', error)
|
||||
// 返回模拟数据以避免错误
|
||||
return getMockData(url, options.method || 'GET')
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟数据函数
|
||||
const getMockData = (url, method) => {
|
||||
// 文章相关模拟数据
|
||||
if (url.includes('/articles')) {
|
||||
if (method === 'GET' && url.includes('/articles/')) {
|
||||
const id = url.split('/').pop()
|
||||
return {
|
||||
data: {
|
||||
id: parseInt(id),
|
||||
title: 'Vue 3 Composition API 深度解析',
|
||||
summary: '详细介绍Vue 3中Composition API的使用方法和最佳实践',
|
||||
content: `
|
||||
<h2>什么是Composition API</h2>
|
||||
<p>Composition API是Vue 3中引入的一套新的API,它提供了一种更灵活的方式来组织组件逻辑。</p>
|
||||
|
||||
<h3>主要特性</h3>
|
||||
<ul>
|
||||
<li>更好的逻辑复用</li>
|
||||
<li>更好的类型推导</li>
|
||||
<li>更小的生产包体积</li>
|
||||
<li>更好的Tree-shaking支持</li>
|
||||
</ul>
|
||||
`,
|
||||
category: 'technology',
|
||||
author: '张三',
|
||||
publishedAt: '2024-01-15',
|
||||
views: 1250,
|
||||
image: 'https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=800&h=400&fit=crop',
|
||||
tags: ['Vue3', 'JavaScript', '前端开发']
|
||||
}
|
||||
}
|
||||
} else if (method === 'GET') {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Vue 3 Composition API 深度解析',
|
||||
summary: '详细介绍Vue 3中Composition API的使用方法和最佳实践',
|
||||
category: 'technology',
|
||||
author: '张三',
|
||||
publishedAt: '2024-01-15',
|
||||
views: 1250,
|
||||
image: 'https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=400&h=200&fit=crop',
|
||||
tags: ['Vue3', 'JavaScript', '前端开发']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'React Hooks 最佳实践指南',
|
||||
summary: '从基础到进阶,全面掌握React Hooks的使用技巧',
|
||||
category: 'technology',
|
||||
author: '李四',
|
||||
publishedAt: '2024-01-14',
|
||||
views: 890,
|
||||
image: 'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=400&h=200&fit=crop',
|
||||
tags: ['React', 'Hooks', '前端开发']
|
||||
}
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新闻相关模拟数据
|
||||
if (url.includes('/news')) {
|
||||
if (method === 'GET' && url.includes('/news/')) {
|
||||
const id = url.split('/').pop()
|
||||
return {
|
||||
data: {
|
||||
id: parseInt(id),
|
||||
title: '公司成功获得ISO9001质量管理体系认证',
|
||||
summary: '经过严格的审核流程,我们公司正式获得ISO9001质量管理体系认证',
|
||||
content: `
|
||||
<p>经过为期三个月的严格审核流程,我们公司正式获得了ISO9001质量管理体系认证。这一重要里程碑标志着我们在质量管理方面达到了国际先进水平。</p>
|
||||
|
||||
<h3>认证过程</h3>
|
||||
<p>ISO9001认证过程包括了文件审核、现场审核等多个环节。审核专家对我们的质量管理体系进行了全面评估。</p>
|
||||
`,
|
||||
category: 'company',
|
||||
author: '管理员',
|
||||
publishedAt: '2024-01-15',
|
||||
views: 1250,
|
||||
image: 'https://images.unsplash.com/photo-1560472354-b33ff0c44a43?w=800&h=400&fit=crop',
|
||||
tags: ['ISO9001', '质量管理', '认证']
|
||||
}
|
||||
}
|
||||
} else if (method === 'GET') {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
title: '公司成功获得ISO9001质量管理体系认证',
|
||||
summary: '经过严格的审核流程,我们公司正式获得ISO9001质量管理体系认证',
|
||||
category: 'company',
|
||||
author: '管理员',
|
||||
publishedAt: '2024-01-15',
|
||||
views: 1250,
|
||||
image: 'https://images.unsplash.com/photo-1560472354-b33ff0c44a43?w=400&h=200&fit=crop'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '行业数字化转型趋势分析报告发布',
|
||||
summary: '我们发布了最新的行业数字化转型趋势分析报告',
|
||||
category: 'industry',
|
||||
author: '研究部',
|
||||
publishedAt: '2024-01-14',
|
||||
views: 890,
|
||||
image: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=400&h=200&fit=crop'
|
||||
}
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { data: null, message: 'Mock data not found' }
|
||||
}
|
||||
|
||||
// 文章相关API
|
||||
export const articlesAPI = {
|
||||
// 获取文章列表
|
||||
getList: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString()
|
||||
return request(`/articles?${queryString}`)
|
||||
},
|
||||
|
||||
// 获取文章详情
|
||||
getDetail: (id) => {
|
||||
return request(`/articles/${id}`)
|
||||
},
|
||||
|
||||
// 创建文章
|
||||
create: (data) => {
|
||||
return request('/articles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
// 更新文章
|
||||
update: (id, data) => {
|
||||
return request(`/articles/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
// 删除文章
|
||||
delete: (id) => {
|
||||
return request(`/articles/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// 新闻相关API
|
||||
export const newsAPI = {
|
||||
// 获取新闻列表
|
||||
getList: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString()
|
||||
return request(`/news?${queryString}`)
|
||||
},
|
||||
|
||||
// 获取新闻详情
|
||||
getDetail: (id) => {
|
||||
return request(`/news/${id}`)
|
||||
},
|
||||
|
||||
// 创建新闻
|
||||
create: (data) => {
|
||||
return request('/news', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
// 更新新闻
|
||||
update: (id, data) => {
|
||||
return request(`/news/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
// 删除新闻
|
||||
delete: (id) => {
|
||||
return request(`/news/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// 用户相关API
|
||||
export const userAPI = {
|
||||
// 用户登录
|
||||
login: (credentials) => {
|
||||
return request('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(credentials),
|
||||
})
|
||||
},
|
||||
|
||||
// 用户注册
|
||||
register: (userData) => {
|
||||
return request('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userData),
|
||||
})
|
||||
},
|
||||
|
||||
// 获取用户信息
|
||||
getProfile: () => {
|
||||
return request('/user/profile')
|
||||
},
|
||||
|
||||
// 更新用户信息
|
||||
updateProfile: (data) => {
|
||||
return request('/user/profile', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// 联系表单API
|
||||
export const contactAPI = {
|
||||
// 发送联系消息
|
||||
sendMessage: (data) => {
|
||||
return request('/contact', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// 系统设置API
|
||||
export const settingsAPI = {
|
||||
// 获取系统设置
|
||||
get: () => {
|
||||
return request('/settings')
|
||||
},
|
||||
|
||||
// 更新系统设置
|
||||
update: (data) => {
|
||||
return request('/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// 默认导出
|
||||
export default {
|
||||
articlesAPI,
|
||||
newsAPI,
|
||||
userAPI,
|
||||
contactAPI,
|
||||
settingsAPI,
|
||||
}
|
||||
75
src/api/request.js
Normal file
75
src/api/request.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import axios from 'axios';
|
||||
import {message as msg} from "ant-design-vue";
|
||||
|
||||
// 创建 Axios 实例
|
||||
const service = axios.create({
|
||||
baseURL: '/api/', // 这里可以设置你的 API 基础地址
|
||||
timeout: 5000 // 请求超时时间
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
// 从本地存储中获取 token
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
// 设置请求头中的 Authorization
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
console.log(error); // 打印错误信息
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
response => {
|
||||
const {code, result, message} = response.data;
|
||||
if (code === 0) {
|
||||
return result;
|
||||
} else {
|
||||
if (code === 401) {
|
||||
msg.error(message).then(r => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/'
|
||||
msg.destroy()
|
||||
})
|
||||
return;
|
||||
}
|
||||
msg.error(message).then(r => {
|
||||
msg.destroy()
|
||||
})
|
||||
}
|
||||
},
|
||||
error => {
|
||||
console.log('err' + error); // 打印错误信息
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 封装 get 请求
|
||||
const get = (url, params = {}) => {
|
||||
return service.get(url, { params });
|
||||
};
|
||||
|
||||
// 封装 post 请求
|
||||
const post = (url, data = {}) => {
|
||||
return service.post(url, data);
|
||||
};
|
||||
|
||||
// 封装上传文件请求
|
||||
const upload = (url, file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return service.post(url, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export { get, post, upload };
|
||||
10
src/api/request/upload.js
Normal file
10
src/api/request/upload.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import {upload} from '../request.js'
|
||||
|
||||
const prefix = 'upload'
|
||||
|
||||
export function uploadImageApi(credentials) {
|
||||
return upload(`${prefix}/image`, credentials)
|
||||
}
|
||||
export function uploadVideoApi(credentials) {
|
||||
return upload(`${prefix}/video`, credentials)
|
||||
}
|
||||
92
src/api/request/user.js
Normal file
92
src/api/request/user.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import {get, post} from '../request.js'
|
||||
|
||||
const prefix = 'user'
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function loginApi(credentials) {
|
||||
return post('login', credentials)
|
||||
}
|
||||
/**
|
||||
* 注册
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function registerApi(credentials) {
|
||||
return post('register', credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function userListApi(credentials) {
|
||||
return get(`${prefix}/list`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function userMyInfoApi(credentials) {
|
||||
return get(`${prefix}/my-info`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户统计信息
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function userStatsApi(credentials) {
|
||||
return get(`${prefix}/user-stats`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function createUserApi(credentials) {
|
||||
return post(`${prefix}/create`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function updateUserApi(credentials) {
|
||||
return post(`${prefix}/update`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function deleteUserApi(credentials) {
|
||||
return post(`${prefix}/delete`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function resetPasswordApi(credentials) {
|
||||
return post(`${prefix}/reset-password`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function changePasswordApi(credentials) {
|
||||
return post(`${prefix}/change-password`, credentials)
|
||||
}
|
||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
42
src/components/EmptyState.vue
Normal file
42
src/components/EmptyState.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center py-12 px-4">
|
||||
<div class="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mb-6">
|
||||
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ title }}</h3>
|
||||
<p class="text-gray-500 text-center max-w-sm">{{ description }}</p>
|
||||
<div v-if="showAction" class="mt-6">
|
||||
<button
|
||||
@click="$emit('action')"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{{ actionText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '暂时没有数据'
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '敬请期待更多精彩内容'
|
||||
},
|
||||
showAction: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
actionText: {
|
||||
type: String,
|
||||
default: '刷新'
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['action'])
|
||||
</script>
|
||||
251
src/layouts/AdminLayout.vue
Normal file
251
src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- 侧边栏 -->
|
||||
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0" :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center justify-center h-16 px-4 bg-gradient-to-r from-blue-600 to-purple-600">
|
||||
<h1 class="text-xl font-bold text-white">CMS管理后台</h1>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="mt-8">
|
||||
<div class="px-4 space-y-2">
|
||||
<router-link
|
||||
to="/admin/dashboard"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 transition-colors"
|
||||
:class="{ 'bg-blue-50 text-blue-600': $route.path === '/admin/dashboard' }"
|
||||
>
|
||||
<DashboardIcon class="w-5 h-5 mr-3" />
|
||||
仪表盘
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/articles"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 transition-colors"
|
||||
:class="{ 'bg-blue-50 text-blue-600': $route.path.startsWith('/admin/articles') }"
|
||||
>
|
||||
<ArticleIcon class="w-5 h-5 mr-3" />
|
||||
文章管理
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/news"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 transition-colors"
|
||||
:class="{ 'bg-blue-50 text-blue-600': $route.path.startsWith('/admin/news') }"
|
||||
>
|
||||
<NewsIcon class="w-5 h-5 mr-3" />
|
||||
新闻管理
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/users"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 transition-colors"
|
||||
:class="{ 'bg-blue-50 text-blue-600': $route.path === '/admin/users' }"
|
||||
>
|
||||
<UsersIcon class="w-5 h-5 mr-3" />
|
||||
用户管理
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/settings"
|
||||
class="flex items-center px-4 py-3 text-gray-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 transition-colors"
|
||||
:class="{ 'bg-blue-50 text-blue-600': $route.path === '/admin/settings' }"
|
||||
>
|
||||
<SettingsIcon class="w-5 h-5 mr-3" />
|
||||
系统设置
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
|
||||
<button
|
||||
@click="logout"
|
||||
class="flex items-center w-full px-4 py-3 text-gray-700 rounded-lg hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<LogoutIcon class="w-5 h-5 mr-3" />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="lg:pl-64">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="flex items-center justify-between px-4 py-4">
|
||||
<div class="flex items-center">
|
||||
<!-- 移动端菜单按钮 -->
|
||||
<button
|
||||
@click="toggleSidebar"
|
||||
class="lg:hidden p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
<MenuIcon class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<!-- 面包屑导航 -->
|
||||
<nav class="ml-4 flex" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-4">
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<router-link to="/admin/dashboard" class="text-gray-400 hover:text-gray-500">
|
||||
管理后台
|
||||
</router-link>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="breadcrumbs.length > 0">
|
||||
<div class="flex items-center">
|
||||
<ChevronRightIcon class="flex-shrink-0 h-5 w-5 text-gray-400" />
|
||||
<span class="ml-4 text-sm font-medium text-gray-500">{{ breadcrumbs[breadcrumbs.length - 1] }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 右侧用户信息 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 通知 -->
|
||||
<button class="p-2 text-gray-400 hover:text-gray-500 relative">
|
||||
<BellIcon class="w-6 h-6" />
|
||||
<span class="absolute top-0 right-0 block h-2 w-2 rounded-full bg-red-400 ring-2 ring-white"></span>
|
||||
</button>
|
||||
|
||||
<!-- 用户菜单 -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="showUserMenu = !showUserMenu"
|
||||
class="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<img
|
||||
class="w-8 h-8 rounded-full"
|
||||
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=40&h=40&fit=crop&crop=face"
|
||||
alt="用户头像"
|
||||
/>
|
||||
<div class="hidden md:block text-left">
|
||||
<div class="text-sm font-medium">管理员</div>
|
||||
<div class="text-xs text-gray-500">超级管理员</div>
|
||||
</div>
|
||||
<ChevronDownIcon class="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
|
||||
<!-- 用户下拉菜单 -->
|
||||
<div
|
||||
v-show="showUserMenu"
|
||||
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50"
|
||||
@click.away="showUserMenu = false"
|
||||
>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">个人资料</a>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">账户设置</a>
|
||||
<div class="border-t border-gray-100"></div>
|
||||
<button
|
||||
@click="logout"
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<main class="p-6">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 移动端遮罩 -->
|
||||
<div
|
||||
v-show="sidebarOpen"
|
||||
@click="closeSidebar"
|
||||
class="fixed inset-0 z-40 bg-black bg-opacity-50 lg:hidden"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
const sidebarOpen = ref(false)
|
||||
const showUserMenu = ref(false)
|
||||
|
||||
// 图标组件
|
||||
const DashboardIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6a2 2 0 01-2 2H10a2 2 0 01-2-2V5z"></path></svg>`
|
||||
}
|
||||
|
||||
const ArticleIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>`
|
||||
}
|
||||
|
||||
const NewsIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"></path></svg>`
|
||||
}
|
||||
|
||||
const UsersIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>`
|
||||
}
|
||||
|
||||
const SettingsIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>`
|
||||
}
|
||||
|
||||
const LogoutIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>`
|
||||
}
|
||||
|
||||
const MenuIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>`
|
||||
}
|
||||
|
||||
const ChevronRightIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>`
|
||||
}
|
||||
|
||||
const ChevronDownIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>`
|
||||
}
|
||||
|
||||
const BellIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>`
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const breadcrumbs = computed(() => {
|
||||
const pathMap = {
|
||||
'/admin/dashboard': '仪表盘',
|
||||
'/admin/articles': '文章管理',
|
||||
'/admin/articles/create': '创建文章',
|
||||
'/admin/news': '新闻管理',
|
||||
'/admin/users': '用户管理',
|
||||
'/admin/settings': '系统设置'
|
||||
}
|
||||
|
||||
return pathMap[route.path] ? [pathMap[route.path]] : []
|
||||
})
|
||||
|
||||
// 方法
|
||||
const toggleSidebar = () => {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
const closeSidebar = () => {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
// 清除本地存储的用户信息
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
|
||||
// 跳转到登录页
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
134
src/layouts/Footer.vue
Normal file
134
src/layouts/Footer.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<footer class="bg-gray-900 text-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- 公司信息 -->
|
||||
<div class="col-span-1 md:col-span-2">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<span class="text-white font-bold text-lg">C</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold">CMS系统</span>
|
||||
</div>
|
||||
<p class="text-gray-400 mb-4 max-w-md">
|
||||
专业的内容管理解决方案,为您提供高效、安全、易用的内容管理体验。
|
||||
</p>
|
||||
<div class="flex space-x-4">
|
||||
<a href="#" class="text-gray-400 hover:text-white transition-colors">
|
||||
<span class="sr-only">微信</span>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 4.882-1.900 7.852.194.002-.063.002-.125.002-.188C17.288 5.476 13.397 2.188 8.691 2.188z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" class="text-gray-400 hover:text-white transition-colors">
|
||||
<span class="sr-only">微博</span>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.194 14.197c0 3.362-3.53 6.086-7.885 6.086-4.354 0-7.885-2.724-7.885-6.086 0-3.362 3.531-6.086 7.885-6.086 4.355 0 7.885 2.724 7.885 6.086z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速链接 -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white uppercase tracking-wider mb-4">快速链接</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<router-link to="/articles" class="text-gray-400 hover:text-white transition-colors">
|
||||
技术文章
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/news" class="text-gray-400 hover:text-white transition-colors">
|
||||
新闻资讯
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/about" class="text-gray-400 hover:text-white transition-colors">
|
||||
关于我们
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/contact" class="text-gray-400 hover:text-white transition-colors">
|
||||
联系我们
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 联系信息 -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white uppercase tracking-wider mb-4">联系我们</h3>
|
||||
<ul class="space-y-2 text-gray-400">
|
||||
<li class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
contact@cms.com
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path>
|
||||
</svg>
|
||||
400-123-4567
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<svg class="w-4 h-4 mr-2 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span>北京市朝阳区科技园区</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部版权信息 -->
|
||||
<div class="mt-8 pt-8 border-t border-gray-800">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center">
|
||||
<p class="text-gray-400 text-sm">
|
||||
© 2024 CMS系统. 保留所有权利.
|
||||
</p>
|
||||
<div class="flex space-x-6 mt-4 md:mt-0">
|
||||
<a href="#" class="text-gray-400 hover:text-white text-sm transition-colors">
|
||||
隐私政策
|
||||
</a>
|
||||
<a href="#" class="text-gray-400 hover:text-white text-sm transition-colors">
|
||||
服务条款
|
||||
</a>
|
||||
<a href="#" class="text-gray-400 hover:text-white text-sm transition-colors">
|
||||
网站地图
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Footer组件逻辑
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Footer样式 */
|
||||
footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* 链接悬停效果 */
|
||||
a {
|
||||
transition: color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-1.md\:grid-cols-4 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.col-span-1.md\:col-span-2 {
|
||||
grid-column: span 1 / span 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
266
src/layouts/Header.vue
Normal file
266
src/layouts/Header.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="bg-white shadow-sm sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- Logo和品牌 -->
|
||||
<div class="flex items-center">
|
||||
<router-link to="/" class="flex items-center">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center mr-3">
|
||||
<span class="text-white font-bold text-lg">C</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-gray-900">CMS系统</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端导航菜单 -->
|
||||
<nav class="hidden md:flex space-x-8">
|
||||
<router-link
|
||||
to="/"
|
||||
class="text-gray-700 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="{ 'text-blue-600': $route.path === '/' }"
|
||||
>
|
||||
首页
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/articles"
|
||||
class="text-gray-700 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="{ 'text-blue-600': $route.path.startsWith('/articles') }"
|
||||
>
|
||||
技术文章
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/news"
|
||||
class="text-gray-700 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="{ 'text-blue-600': $route.path.startsWith('/news') }"
|
||||
>
|
||||
新闻资讯
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/about"
|
||||
class="text-gray-700 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="{ 'text-blue-600': $route.path === '/about' }"
|
||||
>
|
||||
关于我们
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/contact"
|
||||
class="text-gray-700 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="{ 'text-blue-600': $route.path === '/contact' }"
|
||||
>
|
||||
联系我们
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<!-- 右侧操作区 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 搜索按钮 -->
|
||||
<button
|
||||
@click="searchOpen = !searchOpen"
|
||||
class="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
<SearchIcon class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- 管理员入口 -->
|
||||
<router-link
|
||||
to="/admin"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
管理后台
|
||||
</router-link>
|
||||
|
||||
<!-- 移动端菜单按钮 -->
|
||||
<button
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
class="md:hidden text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
<MenuIcon v-if="!mobileMenuOpen" class="w-6 h-6" />
|
||||
<XIcon v-else class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div v-if="searchOpen" class="py-4 border-t border-gray-200">
|
||||
<div class="relative">
|
||||
<SearchIcon class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
placeholder="搜索文章、新闻..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单 -->
|
||||
<div v-if="mobileMenuOpen" class="md:hidden bg-white border-t border-gray-200">
|
||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
||||
<router-link
|
||||
to="/"
|
||||
class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-blue-600 hover:bg-gray-50 rounded-md"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
首页
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/articles"
|
||||
class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-blue-600 hover:bg-gray-50 rounded-md"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
技术文章
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/news"
|
||||
class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-blue-600 hover:bg-gray-50 rounded-md"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
新闻资讯
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/about"
|
||||
class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-blue-600 hover:bg-gray-50 rounded-md"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
关于我们
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/contact"
|
||||
class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-blue-600 hover:bg-gray-50 rounded-md"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
联系我们
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 图标组件
|
||||
const SearchIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>`
|
||||
}
|
||||
|
||||
const MenuIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>`
|
||||
}
|
||||
|
||||
const XIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const searchOpen = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
if (searchKeyword.value.trim()) {
|
||||
router.push({
|
||||
path: '/search',
|
||||
query: { q: searchKeyword.value }
|
||||
})
|
||||
searchOpen.value = false
|
||||
searchKeyword.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由变化,关闭移动端菜单
|
||||
import { watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
watch(() => route.path, () => {
|
||||
mobileMenuOpen.value = false
|
||||
searchOpen.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 导航链接激活状态 */
|
||||
.router-link-active {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
/* 移动端菜单动画 */
|
||||
.mobile-menu-enter-active,
|
||||
.mobile-menu-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-menu-enter-from,
|
||||
.mobile-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
/* 搜索栏动画 */
|
||||
.search-enter-active,
|
||||
.search-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-enter-from,
|
||||
.search-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.hidden.md\:flex {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.md\:hidden {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.hover\:bg-gray-100:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.hover\:text-blue-600:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.hover\:bg-blue-700:hover {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.transition-colors {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* 粘性定位 */
|
||||
.sticky {
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
/* 阴影效果 */
|
||||
.shadow-sm {
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Z-index层级 */
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
</style>
|
||||
14
src/layouts/MainLayout.vue
Normal file
14
src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<main>
|
||||
<router-view />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from './Header.vue'
|
||||
import Footer from './Footer.vue'
|
||||
</script>
|
||||
16
src/main.js
Normal file
16
src/main.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
// 注册路由
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
// 注册Ant Design组件
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(Antd)
|
||||
app.mount('#app')
|
||||
118
src/router/index.js
Normal file
118
src/router/index.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import MainLayout from '@/layouts/MainLayout.vue'
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue'
|
||||
|
||||
// 路由配置
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/index/index.vue')
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
name: 'About',
|
||||
component: () => import('@/views/about/index.vue')
|
||||
},
|
||||
{
|
||||
path: 'articles',
|
||||
name: 'Articles',
|
||||
component: () => import('@/views/articles/index.vue')
|
||||
},
|
||||
{
|
||||
path: 'articles/:id',
|
||||
name: 'ArticleDetail',
|
||||
component: () => import('@/views/articles/detail.vue')
|
||||
},
|
||||
{
|
||||
path: 'news',
|
||||
name: 'News',
|
||||
component: () => import('@/views/news/index.vue')
|
||||
},
|
||||
{
|
||||
path: 'news/:id',
|
||||
name: 'NewsDetail',
|
||||
component: () => import('@/views/news/detail.vue')
|
||||
},
|
||||
{
|
||||
path: 'contact',
|
||||
name: 'Contact',
|
||||
component: () => import('@/views/contact/index.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/admin/dashboard'
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: () => import('@/views/admin/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: 'articles',
|
||||
name: 'AdminArticles',
|
||||
component: () => import('@/views/admin/Articles.vue')
|
||||
},
|
||||
{
|
||||
path: 'articles/create',
|
||||
name: 'CreateArticle',
|
||||
component: () => import('@/views/admin/ArticleForm.vue')
|
||||
},
|
||||
{
|
||||
path: 'articles/edit/:id',
|
||||
name: 'EditArticle',
|
||||
component: () => import('@/views/admin/ArticleForm.vue')
|
||||
},
|
||||
{
|
||||
path: 'news',
|
||||
name: 'AdminNews',
|
||||
component: () => import('@/views/admin/News.vue')
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'AdminUsers',
|
||||
component: () => import('@/views/admin/Users.vue')
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'AdminSettings',
|
||||
component: () => import('@/views/admin/Settings.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/auth/Login.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
return savedPosition || { top: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from) => {
|
||||
const userStore = useUserStore()
|
||||
if (to.meta.requiresAuth && !userStore.user) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
87
src/stores/user.js
Normal file
87
src/stores/user.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import {loginApi, registerApi} from "@/api/request/user.js";
|
||||
import {message, notification} from "ant-design-vue";
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
|
||||
const modal = ref(localStorage.getItem('authModal') || 'false')
|
||||
|
||||
// 添加初始化状态校验
|
||||
const initUser = () => {
|
||||
const userData = localStorage.getItem('user')
|
||||
if (!userData) return null
|
||||
try {
|
||||
return JSON.parse(userData)
|
||||
} catch {
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('token')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const isAuthenticated = computed(() => !!user.value?.nick_name)
|
||||
|
||||
function login(credentials, functionName) {
|
||||
console.log(functionName, 'sssssssssssss')
|
||||
loginApi({
|
||||
account: credentials.username,
|
||||
password: credentials.password,
|
||||
remember: credentials.remember,
|
||||
}).then((res) => {
|
||||
const userData = {
|
||||
nick_name: res.nick_name,
|
||||
role_name: res.role_name,
|
||||
role_value: res.role_value,
|
||||
}
|
||||
user.value = userData;
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
localStorage.setItem('token', res.token)
|
||||
notification.success({
|
||||
message: '登录成功',
|
||||
description: `欢迎回来${res.nick_name}`,
|
||||
})
|
||||
functionName()
|
||||
})
|
||||
}
|
||||
function register(credentials, functionName) {
|
||||
registerApi({
|
||||
account: credentials.username,
|
||||
password: credentials.password,
|
||||
email: credentials.email,
|
||||
code: credentials.code,
|
||||
}).then((res) => {
|
||||
const userData = {
|
||||
nick_name: res.nick_name,
|
||||
role_name: res.role_name,
|
||||
role_value: res.role_value,
|
||||
}
|
||||
user.value = userData;
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
localStorage.setItem('token', res.token)
|
||||
message.success('注册成功')
|
||||
functionName()
|
||||
})
|
||||
}
|
||||
|
||||
function logout() {
|
||||
user.value = null
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
function setModal(value) {
|
||||
modal.value = modal.value === 'false' ? 'true' : 'false';
|
||||
localStorage.setItem('authModal', value)
|
||||
}
|
||||
|
||||
return {
|
||||
modal,
|
||||
user,
|
||||
isAuthenticated,
|
||||
setModal,
|
||||
login,
|
||||
register,
|
||||
logout
|
||||
}
|
||||
})
|
||||
1
src/style.css
Normal file
1
src/style.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
302
src/views/about/index.vue
Normal file
302
src/views/about/index.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- 页面头部 -->
|
||||
<div class="bg-white shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">关于我们</h1>
|
||||
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
我们致力于为客户提供最优质的产品和服务,以创新驱动发展,以质量赢得信任
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 公司简介 -->
|
||||
<section class="py-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-6">公司简介</h2>
|
||||
<div class="space-y-4 text-gray-600">
|
||||
<p>
|
||||
我们公司成立于2010年,是一家专注于技术创新和产品研发的现代化企业。
|
||||
经过十多年的发展,我们已经成为行业内的领军企业之一。
|
||||
</p>
|
||||
<p>
|
||||
公司秉承"创新、品质、服务、共赢"的经营理念,始终坚持以客户需求为导向,
|
||||
以技术创新为驱动,为客户提供高品质的产品和专业的服务。
|
||||
</p>
|
||||
<p>
|
||||
我们拥有一支专业的技术团队和完善的服务体系,能够为客户提供从产品设计、
|
||||
开发到售后服务的全方位解决方案。
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-8 grid grid-cols-3 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">10+</div>
|
||||
<div class="text-sm text-gray-500">年发展历程</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">500+</div>
|
||||
<div class="text-sm text-gray-500">合作客户</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">100+</div>
|
||||
<div class="text-sm text-gray-500">专业团队</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1497366216548-37526070297c?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2069&q=80"
|
||||
alt="公司办公环境"
|
||||
class="rounded-lg shadow-lg w-full h-96 object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 企业文化 -->
|
||||
<section class="py-16 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-4">企业文化</h2>
|
||||
<p class="text-gray-600 max-w-2xl mx-auto">
|
||||
我们的企业文化体现在每一个细节中,指导着我们的行为和决策
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div class="text-center">
|
||||
<div class="bg-blue-100 rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4">
|
||||
<InnovationIcon class="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">创新</h3>
|
||||
<p class="text-gray-600">持续创新,引领行业发展</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="bg-green-100 rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4">
|
||||
<QualityIcon class="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">品质</h3>
|
||||
<p class="text-gray-600">严格把控,追求卓越品质</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="bg-purple-100 rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4">
|
||||
<ServiceIcon class="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">服务</h3>
|
||||
<p class="text-gray-600">用心服务,超越客户期望</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="bg-orange-100 rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4">
|
||||
<TeamIcon class="w-8 h-8 text-orange-600" />
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">共赢</h3>
|
||||
<p class="text-gray-600">合作共赢,共创美好未来</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 团队介绍 -->
|
||||
<section class="py-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-4">核心团队</h2>
|
||||
<p class="text-gray-600 max-w-2xl mx-auto">
|
||||
我们拥有一支经验丰富、专业素质过硬的核心团队
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div v-for="member in teamMembers" :key="member.id" class="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<img
|
||||
:src="member.avatar"
|
||||
:alt="member.name"
|
||||
class="w-full h-64 object-cover"
|
||||
/>
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-1">{{ member.name }}</h3>
|
||||
<p class="text-blue-600 mb-3">{{ member.position }}</p>
|
||||
<p class="text-gray-600 text-sm">{{ member.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 发展历程 -->
|
||||
<section class="py-16 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-4">发展历程</h2>
|
||||
<p class="text-gray-600 max-w-2xl mx-auto">
|
||||
回顾我们的发展历程,每一步都见证着我们的成长与进步
|
||||
</p>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 w-1 h-full bg-blue-200"></div>
|
||||
<div class="space-y-12">
|
||||
<div v-for="(milestone, index) in milestones" :key="milestone.id"
|
||||
:class="['relative flex items-center', index % 2 === 0 ? 'justify-start' : 'justify-end']">
|
||||
<div :class="['w-5/12', index % 2 === 0 ? 'pr-8' : 'pl-8']">
|
||||
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||
<div class="text-blue-600 font-semibold mb-2">{{ milestone.year }}</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ milestone.title }}</h3>
|
||||
<p class="text-gray-600">{{ milestone.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 w-4 h-4 bg-blue-600 rounded-full border-4 border-white"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 联系我们 -->
|
||||
<section class="py-16 bg-blue-600">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 class="text-3xl font-bold text-white mb-4">加入我们</h2>
|
||||
<p class="text-blue-100 mb-8 max-w-2xl mx-auto">
|
||||
如果您对我们的事业感兴趣,欢迎加入我们的团队,一起创造美好的未来
|
||||
</p>
|
||||
<router-link
|
||||
to="/contact"
|
||||
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-blue-600 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
联系我们
|
||||
<ArrowRightIcon class="ml-2 w-5 h-5" />
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 图标组件
|
||||
const InnovationIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path></svg>`
|
||||
}
|
||||
|
||||
const QualityIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"></path></svg>`
|
||||
}
|
||||
|
||||
const ServiceIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path></svg>`
|
||||
}
|
||||
|
||||
const TeamIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>`
|
||||
}
|
||||
|
||||
const ArrowRightIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path></svg>`
|
||||
}
|
||||
|
||||
// 团队成员数据
|
||||
const teamMembers = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: '张总',
|
||||
position: '创始人 & CEO',
|
||||
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
|
||||
description: '拥有15年行业经验,致力于推动公司技术创新和战略发展。'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '李总',
|
||||
position: '技术总监',
|
||||
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
|
||||
description: '资深技术专家,负责公司技术架构设计和团队管理。'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '王总',
|
||||
position: '市场总监',
|
||||
avatar: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
|
||||
description: '市场营销专家,负责公司品牌建设和市场拓展工作。'
|
||||
}
|
||||
])
|
||||
|
||||
// 发展历程数据
|
||||
const milestones = ref([
|
||||
{
|
||||
id: 1,
|
||||
year: '2010',
|
||||
title: '公司成立',
|
||||
description: '公司正式成立,开始专注于技术研发和产品创新。'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
year: '2013',
|
||||
title: '首个重大项目',
|
||||
description: '成功完成首个重大项目,获得客户高度认可。'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
year: '2016',
|
||||
title: '团队扩张',
|
||||
description: '团队规模扩大到50人,建立完善的研发体系。'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
year: '2019',
|
||||
title: '技术突破',
|
||||
description: '在核心技术领域取得重大突破,获得多项专利。'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
year: '2022',
|
||||
title: '市场领先',
|
||||
description: '成为行业领军企业,服务客户超过500家。'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
year: '2024',
|
||||
title: '持续发展',
|
||||
description: '继续深耕技术创新,为客户提供更优质的服务。'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-1.lg\:grid-cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid-cols-1.md\:grid-cols-2.lg\:grid-cols-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.grid-cols-1.md\:grid-cols-2.lg\:grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* 时间轴在移动端的优化 */
|
||||
.absolute.left-1\/2 {
|
||||
left: 2rem;
|
||||
}
|
||||
|
||||
.justify-start,
|
||||
.justify-end {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.w-5\/12 {
|
||||
width: calc(100% - 4rem);
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
.pr-8,
|
||||
.pl-8 {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
533
src/views/admin/ArticleForm.vue
Normal file
533
src/views/admin/ArticleForm.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">
|
||||
{{ isEdit ? '编辑文章' : '新建文章' }}
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-1">
|
||||
{{ isEdit ? '修改文章内容和设置' : '创建一篇新的技术文章' }}
|
||||
</p>
|
||||
</div>
|
||||
<router-link
|
||||
to="/admin/articles"
|
||||
class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg"
|
||||
>
|
||||
返回列表
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- 基本信息 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">基本信息</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
文章标题 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
placeholder="请输入文章标题"
|
||||
:class="[
|
||||
'w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
|
||||
errors.title ? 'border-red-500' : 'border-gray-300'
|
||||
]"
|
||||
/>
|
||||
<p v-if="errors.title" class="mt-1 text-sm text-red-600">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">文章摘要</label>
|
||||
<textarea
|
||||
v-model="form.summary"
|
||||
rows="3"
|
||||
placeholder="请输入文章摘要"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">文章内容</h3>
|
||||
|
||||
<div class="border border-gray-300 rounded-md">
|
||||
<!-- 工具栏 -->
|
||||
<div class="border-b border-gray-200 p-3 flex items-center space-x-2 bg-gray-50">
|
||||
<button
|
||||
v-for="tool in editorTools"
|
||||
:key="tool.name"
|
||||
type="button"
|
||||
@click="applyFormat(tool.action)"
|
||||
:title="tool.title"
|
||||
class="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-200 rounded"
|
||||
>
|
||||
<component :is="tool.icon" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器 -->
|
||||
<textarea
|
||||
v-model="form.content"
|
||||
rows="20"
|
||||
placeholder="请输入文章内容..."
|
||||
:class="[
|
||||
'w-full px-3 py-2 border-0 rounded-b-md focus:outline-none focus:ring-0 resize-none',
|
||||
errors.content ? 'border-red-500' : ''
|
||||
]"
|
||||
></textarea>
|
||||
</div>
|
||||
<p v-if="errors.content" class="mt-1 text-sm text-red-600">{{ errors.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<div class="space-y-6">
|
||||
<!-- 发布设置 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">发布设置</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">状态</label>
|
||||
<select
|
||||
v-model="form.status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="archived">已归档</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">分类</label>
|
||||
<select
|
||||
v-model="form.category"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">请选择分类</option>
|
||||
<option value="tech">技术</option>
|
||||
<option value="business">商业</option>
|
||||
<option value="design">设计</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">标签</label>
|
||||
<input
|
||||
v-model="tagsInput"
|
||||
type="text"
|
||||
placeholder="输入标签,用逗号分隔"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
@blur="updateTags"
|
||||
/>
|
||||
<div v-if="form.tags.length > 0" class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(tag, index) in form.tags"
|
||||
:key="index"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{{ tag }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeTag(index)"
|
||||
class="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">发布时间</label>
|
||||
<input
|
||||
v-model="form.publish_time"
|
||||
type="datetime-local"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 特色图片 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">特色图片</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-if="form.cover" class="relative">
|
||||
<img
|
||||
:src="form.cover"
|
||||
alt="特色图片"
|
||||
class="w-full h-32 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="form.cover = ''"
|
||||
class="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<ImageIcon class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div class="mt-2">
|
||||
<label class="cursor-pointer">
|
||||
<span class="text-sm text-blue-600 hover:text-blue-800">点击上传图片</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">支持 JPG、PNG 格式</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SEO设置 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">SEO设置</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">SEO标题</label>
|
||||
<input
|
||||
v-model="form.seo_title"
|
||||
type="text"
|
||||
placeholder="SEO标题(留空则使用文章标题)"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">SEO描述</label>
|
||||
<textarea
|
||||
v-model="form.seo_description"
|
||||
rows="3"
|
||||
placeholder="SEO描述(留空则使用文章摘要)"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">SEO关键词</label>
|
||||
<input
|
||||
v-model="form.seo_keywords"
|
||||
type="text"
|
||||
placeholder="关键词,用逗号分隔"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ loading ? '保存中...' : (isEdit ? '更新文章' : '发布文章') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="saveDraft"
|
||||
:disabled="loading"
|
||||
class="w-full bg-gray-500 hover:bg-gray-600 text-white py-2 px-4 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
保存草稿
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="previewArticle"
|
||||
class="w-full bg-green-500 hover:bg-green-600 text-white py-2 px-4 rounded-lg"
|
||||
>
|
||||
预览文章
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { articlesAPI } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 图标组件
|
||||
const BoldIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 12h9a4 4 0 014 4 4 4 0 01-4 4H6z"></path></svg>`
|
||||
}
|
||||
|
||||
const ItalicIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 4l-2 14m8-14l-2 14"></path></svg>`
|
||||
}
|
||||
|
||||
const UnderlineIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v8a5 5 0 0010 0V4M5 20h14"></path></svg>`
|
||||
}
|
||||
|
||||
const ListIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path></svg>`
|
||||
}
|
||||
|
||||
const ImageIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const errors = ref({})
|
||||
const tagsInput = ref('')
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
title: '',
|
||||
summary: '',
|
||||
content: '',
|
||||
category: '',
|
||||
tags: [],
|
||||
status: 'draft',
|
||||
cover: '',
|
||||
publish_time: '',
|
||||
seo_title: '',
|
||||
seo_description: '',
|
||||
seo_keywords: ''
|
||||
})
|
||||
|
||||
// 编辑器工具栏
|
||||
const editorTools = ref([
|
||||
{ name: 'bold', title: '粗体', icon: BoldIcon, action: 'bold' },
|
||||
{ name: 'italic', title: '斜体', icon: ItalicIcon, action: 'italic' },
|
||||
{ name: 'underline', title: '下划线', icon: UnderlineIcon, action: 'underline' },
|
||||
{ name: 'list', title: '列表', icon: ListIcon, action: 'list' }
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const isEdit = computed(() => {
|
||||
return route.params.id && route.params.id !== 'create'
|
||||
})
|
||||
|
||||
// 表单验证
|
||||
const validateForm = () => {
|
||||
errors.value = {}
|
||||
|
||||
if (!form.value.title.trim()) {
|
||||
errors.value.title = '请输入文章标题'
|
||||
}
|
||||
|
||||
if (!form.value.content.trim()) {
|
||||
errors.value.content = '请输入文章内容'
|
||||
}
|
||||
|
||||
return Object.keys(errors.value).length === 0
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
// 更新文章
|
||||
await articlesAPI.update(route.params.id, form.value)
|
||||
} else {
|
||||
// 创建文章
|
||||
await articlesAPI.create(form.value)
|
||||
}
|
||||
|
||||
// 保存成功后跳转
|
||||
router.push('/admin/articles')
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
alert('保存失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存草稿
|
||||
const saveDraft = async () => {
|
||||
form.value.status = 'draft'
|
||||
await handleSubmit()
|
||||
}
|
||||
|
||||
// 预览文章
|
||||
const previewArticle = () => {
|
||||
// 在新窗口中打开预览
|
||||
const previewData = {
|
||||
...form.value,
|
||||
id: 'preview'
|
||||
}
|
||||
|
||||
// 这里可以实现预览功能
|
||||
console.log('预览文章:', previewData)
|
||||
alert('预览功能开发中...')
|
||||
}
|
||||
|
||||
// 处理图片上传
|
||||
const handleImageUpload = (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (file) {
|
||||
// 这里应该上传到服务器,现在只是模拟
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
form.value.cover = e.target.result
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新标签
|
||||
const updateTags = () => {
|
||||
if (tagsInput.value.trim()) {
|
||||
const newTags = tagsInput.value.split(',').map(tag => tag.trim()).filter(tag => tag)
|
||||
form.value.tags = [...new Set([...form.value.tags, ...newTags])]
|
||||
tagsInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 移除标签
|
||||
const removeTag = (index) => {
|
||||
form.value.tags.splice(index, 1)
|
||||
}
|
||||
|
||||
// 应用格式化
|
||||
const applyFormat = (action) => {
|
||||
// 简单的文本格式化功能
|
||||
const textarea = document.querySelector('textarea[v-model="form.content"]')
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selectedText = textarea.value.substring(start, end)
|
||||
|
||||
let formattedText = selectedText
|
||||
switch (action) {
|
||||
case 'bold':
|
||||
formattedText = `**${selectedText}**`
|
||||
break
|
||||
case 'italic':
|
||||
formattedText = `*${selectedText}*`
|
||||
break
|
||||
case 'underline':
|
||||
formattedText = `<u>${selectedText}</u>`
|
||||
break
|
||||
case 'list':
|
||||
formattedText = `\n- ${selectedText}`
|
||||
break
|
||||
}
|
||||
|
||||
const newContent = textarea.value.substring(0, start) + formattedText + textarea.value.substring(end)
|
||||
form.value.content = newContent
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
const initData = async () => {
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
// 获取文章数据
|
||||
// const response = await articlesAPI.getDetail(route.params.id)
|
||||
// form.value = response.data
|
||||
|
||||
// 模拟数据
|
||||
form.value = {
|
||||
title: 'Vue3 开发指南',
|
||||
summary: '详细介绍Vue3的新特性和开发技巧',
|
||||
content: '# Vue3 开发指南\n\n这是一篇关于Vue3开发的详细指南...',
|
||||
category: 'tech',
|
||||
tags: ['Vue3', '前端开发', 'JavaScript'],
|
||||
status: 'published',
|
||||
cover: 'https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=400&h=200&fit=crop',
|
||||
publish_time: '2024-01-15T10:30',
|
||||
seo_title: 'Vue3 开发指南 - 完整教程',
|
||||
seo_description: '详细介绍Vue3的新特性和开发技巧,包含实战案例',
|
||||
seo_keywords: 'Vue3,前端开发,JavaScript,教程'
|
||||
}
|
||||
|
||||
tagsInput.value = form.value.tags.join(', ')
|
||||
} catch (error) {
|
||||
console.error('获取文章数据失败:', error)
|
||||
}
|
||||
} else {
|
||||
// 新建文章时设置默认发布时间
|
||||
const now = new Date()
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset())
|
||||
form.value.publish_time = now.toISOString().slice(0, 16)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时初始化
|
||||
onMounted(() => {
|
||||
initData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 编辑器样式 */
|
||||
.editor-toolbar {
|
||||
background-color: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 1024px) {
|
||||
.grid-cols-1.lg\:grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lg\:col-span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 标签样式 */
|
||||
.tag-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
margin-left: 0.25rem;
|
||||
color: #1d4ed8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
color: #1e3a8a;
|
||||
}
|
||||
</style>
|
||||
618
src/views/admin/Articles.vue
Normal file
618
src/views/admin/Articles.vue
Normal file
@@ -0,0 +1,618 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">文章管理</h1>
|
||||
<p class="text-gray-600 mt-1">管理和发布技术文章</p>
|
||||
</div>
|
||||
<router-link
|
||||
to="/admin/articles/create"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center"
|
||||
>
|
||||
<PlusIcon class="w-4 h-4 mr-2" />
|
||||
新建文章
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="relative">
|
||||
<SearchIcon class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
placeholder="输入标题或内容..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">分类筛选</label>
|
||||
<select
|
||||
v-model="selectedCategory"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">全部分类</option>
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">状态筛选</label>
|
||||
<select
|
||||
v-model="selectedStatus"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="archived">已归档</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">排序方式</label>
|
||||
<select
|
||||
v-model="sortBy"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="created_at">创建时间</option>
|
||||
<option value="updated_at">更新时间</option>
|
||||
<option value="views">浏览量</option>
|
||||
<option value="title">标题</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作 -->
|
||||
<div v-if="selectedArticles.length > 0" class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-blue-800">已选择 {{ selectedArticles.length }} 篇文章</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="batchPublish"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded text-sm"
|
||||
>
|
||||
批量发布
|
||||
</button>
|
||||
<button
|
||||
@click="batchArchive"
|
||||
class="bg-yellow-600 hover:bg-yellow-700 text-white px-3 py-1 rounded text-sm"
|
||||
>
|
||||
批量归档
|
||||
</button>
|
||||
<button
|
||||
@click="batchDelete"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm"
|
||||
>
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章列表 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p class="mt-2 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<EmptyState
|
||||
v-else-if="filteredArticles.length === 0"
|
||||
title="暂无文章"
|
||||
description="还没有创建任何文章,点击上方按钮开始创建吧"
|
||||
/>
|
||||
|
||||
<!-- 文章表格 -->
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedArticles.length === filteredArticles.length && filteredArticles.length > 0"
|
||||
@change="toggleSelectAll"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
文章信息
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
分类
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
状态
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
浏览量
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
创建时间
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="article in paginatedArticles" :key="article.id" class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="article.id"
|
||||
v-model="selectedArticles"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-16 w-24">
|
||||
<img
|
||||
class="h-16 w-24 object-cover rounded"
|
||||
:src="article.cover || '/placeholder.svg?height=64&width=96'"
|
||||
:alt="article.title"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-medium text-gray-900 line-clamp-2">{{ article.title }}</div>
|
||||
<div class="text-sm text-gray-500 flex items-center mt-1">
|
||||
<UserIcon class="w-3 h-3 mr-1" />
|
||||
<span>{{ article.author || '管理员' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ getCategoryName(article.category) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
getStatusClass(article.status)
|
||||
]"
|
||||
>
|
||||
{{ getStatusText(article.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ article.views || 0 }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ formatDate(article.created_at) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div class="flex items-center space-x-2">
|
||||
<router-link
|
||||
:to="`/admin/articles/${article.id}/edit`"
|
||||
class="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
编辑
|
||||
</router-link>
|
||||
<button
|
||||
@click="toggleStatus(article)"
|
||||
:class="[
|
||||
'hover:underline',
|
||||
article.status === 'published' ? 'text-yellow-600' : 'text-green-600'
|
||||
]"
|
||||
>
|
||||
{{ article.status === 'published' ? '下线' : '发布' }}
|
||||
</button>
|
||||
<button
|
||||
@click="deleteArticle(article.id)"
|
||||
class="text-red-600 hover:text-red-900 hover:underline"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="filteredArticles.length > 0" class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
@click="changePage(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
@click="changePage(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div class="text-sm text-gray-700">
|
||||
显示第 {{ (currentPage - 1) * pageSize + 1 }} 到 {{ Math.min(currentPage * pageSize, totalCount) }} 条,共 {{ totalCount }} 条记录
|
||||
</div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
@click="changePage(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
v-for="page in getPageNumbers()"
|
||||
:key="page"
|
||||
@click="changePage(page)"
|
||||
:class="[
|
||||
'relative inline-flex items-center px-4 py-2 border text-sm font-medium',
|
||||
page === currentPage
|
||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button
|
||||
@click="changePage(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { articlesAPI } from '@/api'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 图标组件
|
||||
const PlusIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>`
|
||||
}
|
||||
|
||||
const SearchIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>`
|
||||
}
|
||||
|
||||
const UserIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(true)
|
||||
const articles = ref([])
|
||||
const selectedArticles = ref([])
|
||||
|
||||
// 搜索和筛选
|
||||
const searchKeyword = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedStatus = ref('')
|
||||
const sortBy = ref('created_at')
|
||||
|
||||
// 分页
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
// 分类数据
|
||||
const categories = ref([
|
||||
{ id: 'tech', name: '技术' },
|
||||
{ id: 'business', name: '商业' },
|
||||
{ id: 'design', name: '设计' },
|
||||
{ id: 'other', name: '其他' }
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const filteredArticles = computed(() => {
|
||||
let result = [...articles.value]
|
||||
|
||||
// 搜索筛选
|
||||
if (searchKeyword.value.trim()) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
result = result.filter(article =>
|
||||
article.title.toLowerCase().includes(keyword) ||
|
||||
article.summary.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
// 分类筛选
|
||||
if (selectedCategory.value) {
|
||||
result = result.filter(article => article.category === selectedCategory.value)
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if (selectedStatus.value) {
|
||||
result = result.filter(article => article.status === selectedStatus.value)
|
||||
}
|
||||
|
||||
// 排序
|
||||
result.sort((a, b) => {
|
||||
if (sortBy.value === 'title') {
|
||||
return a.title.localeCompare(b.title)
|
||||
}
|
||||
return new Date(b[sortBy.value]) - new Date(a[sortBy.value])
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const totalCount = computed(() => filteredArticles.value.length)
|
||||
const totalPages = computed(() => Math.ceil(totalCount.value / pageSize.value))
|
||||
|
||||
const paginatedArticles = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return filteredArticles.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 模拟文章数据
|
||||
const mockArticles = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Vue 3 Composition API 深度解析',
|
||||
summary: '详细介绍Vue 3中Composition API的使用方法和最佳实践',
|
||||
cover: 'https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=400&h=200&fit=crop',
|
||||
category: 'tech',
|
||||
status: 'published',
|
||||
author: '技术团队',
|
||||
views: 1250,
|
||||
created_at: '2024-01-15T10:30:00Z',
|
||||
updated_at: '2024-01-15T10:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'React Hooks 最佳实践指南',
|
||||
summary: '从基础到进阶,全面掌握React Hooks的使用技巧',
|
||||
cover: 'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=400&h=200&fit=crop',
|
||||
category: 'tech',
|
||||
status: 'draft',
|
||||
author: '前端专家',
|
||||
views: 890,
|
||||
created_at: '2024-01-14T15:20:00Z',
|
||||
updated_at: '2024-01-14T15:20:00Z'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取分类名称
|
||||
const getCategoryName = (categoryId) => {
|
||||
const category = categories.value.find(cat => cat.id === categoryId)
|
||||
return category ? category.name : '未分类'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
published: '已发布',
|
||||
draft: '草稿',
|
||||
archived: '已归档'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 获取状态样式
|
||||
const getStatusClass = (status) => {
|
||||
const classMap = {
|
||||
published: 'bg-green-100 text-green-800',
|
||||
draft: 'bg-yellow-100 text-yellow-800',
|
||||
archived: 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
return classMap[status] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedArticles.value.length === filteredArticles.value.length) {
|
||||
selectedArticles.value = []
|
||||
} else {
|
||||
selectedArticles.value = filteredArticles.value.map(article => article.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换文章状态
|
||||
const toggleStatus = async (article) => {
|
||||
const newStatus = article.status === 'published' ? 'draft' : 'published'
|
||||
try {
|
||||
// 这里应该调用API更新状态
|
||||
article.status = newStatus
|
||||
console.log(`文章 ${article.id} 状态更新为: ${newStatus}`)
|
||||
} catch (error) {
|
||||
console.error('更新状态失败', error)
|
||||
alert('操作失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文章
|
||||
const deleteArticle = async (id) => {
|
||||
if (confirm('确定要删除这篇文章吗?')) {
|
||||
try {
|
||||
// 这里应该调用API删除文章
|
||||
articles.value = articles.value.filter(article => article.id !== id)
|
||||
console.log(`删除文章: ${id}`)
|
||||
} catch (error) {
|
||||
console.error('删除失败', error)
|
||||
alert('删除失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量发布
|
||||
const batchPublish = async () => {
|
||||
if (confirm(`确定要发布选中的 ${selectedArticles.value.length} 篇文章吗?`)) {
|
||||
try {
|
||||
// 这里应该调用API批量更新状态
|
||||
articles.value.forEach(article => {
|
||||
if (selectedArticles.value.includes(article.id)) {
|
||||
article.status = 'published'
|
||||
}
|
||||
})
|
||||
selectedArticles.value = []
|
||||
console.log('批量发布成功')
|
||||
} catch (error) {
|
||||
console.error('批量发布失败', error)
|
||||
alert('操作失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量归档
|
||||
const batchArchive = async () => {
|
||||
if (confirm(`确定要归档选中的 ${selectedArticles.value.length} 篇文章吗?`)) {
|
||||
try {
|
||||
// 这里应该调用API批量更新状态
|
||||
articles.value.forEach(article => {
|
||||
if (selectedArticles.value.includes(article.id)) {
|
||||
article.status = 'archived'
|
||||
}
|
||||
})
|
||||
selectedArticles.value = []
|
||||
console.log('批量归档成功')
|
||||
} catch (error) {
|
||||
console.error('批量归档失败', error)
|
||||
alert('操作失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const batchDelete = async () => {
|
||||
if (confirm(`确定要删除选中的 ${selectedArticles.value.length} 篇文章吗?此操作不可恢复!`)) {
|
||||
try {
|
||||
// 这里应该调用API批量删除
|
||||
articles.value = articles.value.filter(article => !selectedArticles.value.includes(article.id))
|
||||
selectedArticles.value = []
|
||||
console.log('批量删除成功')
|
||||
} catch (error) {
|
||||
console.error('批量删除失败', error)
|
||||
alert('操作失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换页码
|
||||
const changePage = (page) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
}
|
||||
}
|
||||
|
||||
// 获取页码数组
|
||||
const getPageNumbers = () => {
|
||||
const pages = []
|
||||
const total = totalPages.value
|
||||
const current = currentPage.value
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
if (current <= 4) {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
} else if (current >= total - 3) {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = total - 4; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = current - 1; i <= current + 1; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
// 获取文章列表
|
||||
const fetchArticles = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
articles.value = mockArticles
|
||||
} catch (error) {
|
||||
console.error('获取文章列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听筛选条件变化
|
||||
watch([searchKeyword, selectedCategory, selectedStatus, sortBy], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchArticles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 文本截断 */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-1.md\:grid-cols-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hidden.sm\:flex-1.sm\:flex.sm\:items-center.sm\:justify-between {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flex-1.flex.justify-between.sm\:hidden {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
286
src/views/admin/Dashboard.vue
Normal file
286
src/views/admin/Dashboard.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">仪表板</h1>
|
||||
<div class="text-sm text-gray-500">
|
||||
<span>今天是 {{ currentTime }}</span>
|
||||
<span class="ml-4">欢迎回来!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div v-for="stat in stats" :key="stat.title" class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div :class="['w-8 h-8 rounded-md flex items-center justify-center', stat.iconBg]">
|
||||
<component :is="stat.icon" class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">{{ stat.title }}</dt>
|
||||
<dd class="flex items-baseline">
|
||||
<div class="text-2xl font-semibold text-gray-900">{{ stat.value }}</div>
|
||||
<div :class="['ml-2 flex items-baseline text-sm font-semibold', stat.changeType === 'increase' ? 'text-green-600' : 'text-red-600']">
|
||||
<ArrowUpIcon v-if="stat.changeType === 'increase'" class="self-center flex-shrink-0 h-4 w-4" />
|
||||
<ArrowDownIcon v-else class="self-center flex-shrink-0 h-4 w-4" />
|
||||
<span class="sr-only">{{ stat.changeType === 'increase' ? '增加' : '减少' }}</span>
|
||||
{{ stat.change }}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
<span>{{ stat.changeText }}</span>
|
||||
<span class="ml-1">较上月</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表和数据 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 访问量趋势 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">访问量趋势</h3>
|
||||
<select class="text-sm border border-gray-300 rounded-md px-3 py-1">
|
||||
<option>最近7天</option>
|
||||
<option>最近30天</option>
|
||||
<option>最近90天</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="h-64 flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p class="text-gray-500">图表区域 (可集成Chart.js 或其他图表库)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最新活动 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">最新活动</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="activity in activities" :key="activity.id" class="flex items-start space-x-3">
|
||||
<div :class="['w-2 h-2 rounded-full mt-2', activity.color]"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-gray-900">{{ activity.title }}</p>
|
||||
<p class="text-xs text-gray-500">{{ activity.time }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速操作 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">快速操作</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<router-link
|
||||
v-for="action in quickActions"
|
||||
:key="action.name"
|
||||
:to="action.href"
|
||||
class="flex flex-col items-center p-4 border border-gray-200 rounded-lg hover:border-blue-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<component :is="action.icon" class="w-8 h-8 text-blue-600 mb-2" />
|
||||
<span class="text-sm font-medium text-gray-900">{{ action.name }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
// 图标组件
|
||||
const UsersIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"></path></svg>`
|
||||
}
|
||||
|
||||
const DocumentTextIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>`
|
||||
}
|
||||
|
||||
const EyeIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>`
|
||||
}
|
||||
|
||||
const ChatIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path></svg>`
|
||||
}
|
||||
|
||||
const ArrowUpIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 11l5-5m0 0l5 5m-5-5v12"></path></svg>`
|
||||
}
|
||||
|
||||
const ArrowDownIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 13l-5 5m0 0l-5-5m5 5V6"></path></svg>`
|
||||
}
|
||||
|
||||
const PlusIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>`
|
||||
}
|
||||
|
||||
const NewspaperIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"></path></svg>`
|
||||
}
|
||||
|
||||
const CogIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const currentTime = ref('')
|
||||
|
||||
// 统计数据
|
||||
const stats = ref([
|
||||
{
|
||||
title: '总用户数',
|
||||
value: '2,651',
|
||||
change: '+12%',
|
||||
changeType: 'increase',
|
||||
changeText: '新增 156 人',
|
||||
icon: 'UsersIcon',
|
||||
iconBg: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
title: '文章总数',
|
||||
value: '1,423',
|
||||
change: '+8%',
|
||||
changeType: 'increase',
|
||||
changeText: '新增 32 篇',
|
||||
icon: 'DocumentTextIcon',
|
||||
iconBg: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
title: '总浏览量',
|
||||
value: '45,210',
|
||||
change: '+15%',
|
||||
changeType: 'increase',
|
||||
changeText: '增长 5,832',
|
||||
icon: 'EyeIcon',
|
||||
iconBg: 'bg-yellow-500'
|
||||
},
|
||||
{
|
||||
title: '评论总数',
|
||||
value: '892',
|
||||
change: '-3%',
|
||||
changeType: 'decrease',
|
||||
changeText: '减少 28 条',
|
||||
icon: 'ChatIcon',
|
||||
iconBg: 'bg-purple-500'
|
||||
}
|
||||
])
|
||||
|
||||
// 最新活动
|
||||
const activities = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '用户 张三 发表了新文章《Vue3 开发指南》',
|
||||
time: '2分钟前',
|
||||
color: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '管理员更新了系统配置',
|
||||
time: '15分钟前',
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '新用户 李四 注册成功',
|
||||
time: '1小时前',
|
||||
color: 'bg-yellow-500'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '文章《React vs Vue》获得100个赞',
|
||||
time: '2小时前',
|
||||
color: 'bg-purple-500'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '系统备份完成',
|
||||
time: '3小时前',
|
||||
color: 'bg-gray-500'
|
||||
}
|
||||
])
|
||||
|
||||
// 快速操作
|
||||
const quickActions = ref([
|
||||
{
|
||||
name: '新建文章',
|
||||
href: '/admin/articles/create',
|
||||
icon: 'PlusIcon'
|
||||
},
|
||||
{
|
||||
name: '文章管理',
|
||||
href: '/admin/articles',
|
||||
icon: 'DocumentTextIcon'
|
||||
},
|
||||
{
|
||||
name: '新闻管理',
|
||||
href: '/admin/news',
|
||||
icon: 'NewspaperIcon'
|
||||
},
|
||||
{
|
||||
name: '系统设置',
|
||||
href: '/admin/settings',
|
||||
icon: 'CogIcon'
|
||||
}
|
||||
])
|
||||
|
||||
// 更新当前时间
|
||||
const updateTime = () => {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long'
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载时初始化
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
// 每分钟更新一次时间
|
||||
setInterval(updateTime, 60000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-1.md\:grid-cols-2.lg\:grid-cols-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.grid-cols-1.lg\:grid-cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid-cols-2.md\:grid-cols-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.hover\:border-blue-300:hover {
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.hover\:shadow-md:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
</style>
|
||||
539
src/views/admin/News.vue
Normal file
539
src/views/admin/News.vue
Normal file
@@ -0,0 +1,539 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">新闻管理</h1>
|
||||
<p class="text-gray-600 mt-1">管理网站新闻内容</p>
|
||||
</div>
|
||||
<button class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center">
|
||||
<PlusIcon class="w-4 h-4 mr-2" />
|
||||
添加新闻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">搜索</label>
|
||||
<input
|
||||
v-model="searchForm.keyword"
|
||||
type="text"
|
||||
placeholder="请输入新闻标题"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">状态</label>
|
||||
<select
|
||||
v-model="searchForm.status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="archived">已归档</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">分类</label>
|
||||
<select
|
||||
v-model="searchForm.category"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">全部分类</option>
|
||||
<option value="company">公司新闻</option>
|
||||
<option value="industry">行业动态</option>
|
||||
<option value="product">产品资讯</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="searchNews"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md mr-2"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
<button
|
||||
@click="resetSearch"
|
||||
class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新闻列表 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<!-- 批量操作 -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedItems.length === newsList.length && newsList.length > 0"
|
||||
@change="toggleSelectAll"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-700">
|
||||
已选择 {{ selectedItems.length }} 项
|
||||
</span>
|
||||
<div v-if="selectedItems.length > 0" class="flex space-x-2">
|
||||
<button
|
||||
@click="batchPublish"
|
||||
class="text-sm text-green-600 hover:text-green-800"
|
||||
>
|
||||
批量发布
|
||||
</button>
|
||||
<button
|
||||
@click="batchArchive"
|
||||
class="text-sm text-yellow-600 hover:text-yellow-800"
|
||||
>
|
||||
批量归档
|
||||
</button>
|
||||
<button
|
||||
@click="batchDelete"
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700">
|
||||
显示第 {{ (pagination.current - 1) * pagination.pageSize + 1 }} 到
|
||||
{{ Math.min(pagination.current * pagination.pageSize, pagination.total) }} 条,
|
||||
共 {{ pagination.total }} 条记录
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedItems.length === newsList.length && newsList.length > 0"
|
||||
@change="toggleSelectAll"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
新闻信息
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
分类
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
状态
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
浏览量
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
发布时间
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="news in newsList" :key="news.id" class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="news.id"
|
||||
v-model="selectedItems"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-16 w-24">
|
||||
<img
|
||||
class="h-16 w-24 object-cover rounded"
|
||||
:src="news.image || '/placeholder.svg?height=64&width=96'"
|
||||
:alt="news.title"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-medium text-gray-900 line-clamp-2">{{ news.title }}</div>
|
||||
<div class="text-sm text-gray-500">作者:{{ news.author }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="getCategoryClass(news.category)">
|
||||
{{ getCategoryText(news.category) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="getStatusClass(news.status)">
|
||||
{{ getStatusText(news.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ news.views }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ news.publishedAt }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="editNews(news)"
|
||||
class="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="news.status === 'draft'"
|
||||
@click="publishNews(news)"
|
||||
class="text-green-600 hover:text-green-900"
|
||||
>
|
||||
发布
|
||||
</button>
|
||||
<button
|
||||
v-else-if="news.status === 'published'"
|
||||
@click="archiveNews(news)"
|
||||
class="text-yellow-600 hover:text-yellow-900"
|
||||
>
|
||||
归档
|
||||
</button>
|
||||
<button
|
||||
@click="deleteNews(news)"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
@click="changePage(pagination.current - 1)"
|
||||
:disabled="pagination.current <= 1"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
@click="changePage(pagination.current + 1)"
|
||||
:disabled="pagination.current >= Math.ceil(pagination.total / pagination.pageSize)"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
显示第 {{ (pagination.current - 1) * pagination.pageSize + 1 }} 到
|
||||
{{ Math.min(pagination.current * pagination.pageSize, pagination.total) }} 条,
|
||||
共 {{ pagination.total }} 条记录
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
@click="changePage(pagination.current - 1)"
|
||||
:disabled="pagination.current <= 1"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
v-for="page in getPageNumbers()"
|
||||
:key="page"
|
||||
@click="changePage(page)"
|
||||
:class="[
|
||||
'relative inline-flex items-center px-4 py-2 border text-sm font-medium',
|
||||
page === pagination.current
|
||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button
|
||||
@click="changePage(pagination.current + 1)"
|
||||
:disabled="pagination.current >= Math.ceil(pagination.total / pagination.pageSize)"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
// 图标组件
|
||||
const PlusIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const searchForm = ref({
|
||||
keyword: '',
|
||||
status: '',
|
||||
category: ''
|
||||
})
|
||||
|
||||
const newsList = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '公司成功获得ISO9001质量管理体系认证',
|
||||
author: '管理员',
|
||||
category: 'company',
|
||||
status: 'published',
|
||||
views: 1250,
|
||||
image: '/placeholder.svg?height=64&width=96',
|
||||
publishedAt: '2024-01-15 10:30:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '行业数字化转型趋势分析报告发布',
|
||||
author: '编辑部',
|
||||
category: 'industry',
|
||||
status: 'published',
|
||||
views: 890,
|
||||
image: '/placeholder.svg?height=64&width=96',
|
||||
publishedAt: '2024-01-14 16:45:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '新产品发布会将于下月举行',
|
||||
author: '市场部',
|
||||
category: 'product',
|
||||
status: 'draft',
|
||||
views: 0,
|
||||
image: '/placeholder.svg?height=64&width=96',
|
||||
publishedAt: null
|
||||
}
|
||||
])
|
||||
|
||||
const selectedItems = ref([])
|
||||
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 3
|
||||
})
|
||||
|
||||
// 分类映射
|
||||
const categoryMap = {
|
||||
company: '公司新闻',
|
||||
industry: '行业动态',
|
||||
product: '产品资讯'
|
||||
}
|
||||
|
||||
// 状态映射
|
||||
const statusMap = {
|
||||
published: '已发布',
|
||||
draft: '草稿',
|
||||
archived: '已归档'
|
||||
}
|
||||
|
||||
// 获取分类文本
|
||||
const getCategoryText = (category) => categoryMap[category] || category
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => statusMap[status] || status
|
||||
|
||||
// 获取分类样式
|
||||
const getCategoryClass = (category) => {
|
||||
const classes = {
|
||||
company: 'bg-blue-100 text-blue-800',
|
||||
industry: 'bg-green-100 text-green-800',
|
||||
product: 'bg-purple-100 text-purple-800'
|
||||
}
|
||||
return classes[category] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
// 获取状态样式
|
||||
const getStatusClass = (status) => {
|
||||
const classes = {
|
||||
published: 'bg-green-100 text-green-800',
|
||||
draft: 'bg-yellow-100 text-yellow-800',
|
||||
archived: 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
return classes[status] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
// 搜索新闻
|
||||
const searchNews = () => {
|
||||
console.log('搜索新闻:', searchForm.value)
|
||||
// 这里应该调用API搜索新闻
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.value = {
|
||||
keyword: '',
|
||||
status: '',
|
||||
category: ''
|
||||
}
|
||||
searchNews()
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedItems.value.length === newsList.value.length) {
|
||||
selectedItems.value = []
|
||||
} else {
|
||||
selectedItems.value = newsList.value.map(news => news.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑新闻
|
||||
const editNews = (news) => {
|
||||
console.log('编辑新闻:', news.id)
|
||||
// 这里应该跳转到编辑页面
|
||||
}
|
||||
|
||||
// 发布新闻
|
||||
const publishNews = (news) => {
|
||||
if (confirm(`确定要发布新闻"${news.title}"吗?`)) {
|
||||
console.log('发布新闻:', news.id)
|
||||
// 这里应该调用API发布新闻
|
||||
news.status = 'published'
|
||||
news.publishedAt = new Date().toLocaleString()
|
||||
}
|
||||
}
|
||||
|
||||
// 归档新闻
|
||||
const archiveNews = (news) => {
|
||||
if (confirm(`确定要归档新闻"${news.title}"吗?`)) {
|
||||
console.log('归档新闻:', news.id)
|
||||
// 这里应该调用API归档新闻
|
||||
news.status = 'archived'
|
||||
}
|
||||
}
|
||||
|
||||
// 删除新闻
|
||||
const deleteNews = (news) => {
|
||||
if (confirm(`确定要删除新闻"${news.title}"吗?此操作不可恢复!`)) {
|
||||
console.log('删除新闻:', news.id)
|
||||
// 这里应该调用API删除新闻
|
||||
const index = newsList.value.findIndex(n => n.id === news.id)
|
||||
if (index > -1) {
|
||||
newsList.value.splice(index, 1)
|
||||
pagination.value.total--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量发布
|
||||
const batchPublish = () => {
|
||||
if (confirm(`确定要发布选中的 ${selectedItems.value.length} 条新闻吗?`)) {
|
||||
console.log('批量发布:', selectedItems.value)
|
||||
// 这里应该调用API批量发布新闻
|
||||
selectedItems.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 批量归档
|
||||
const batchArchive = () => {
|
||||
if (confirm(`确定要归档选中的 ${selectedItems.value.length} 条新闻吗?`)) {
|
||||
console.log('批量归档:', selectedItems.value)
|
||||
// 这里应该调用API批量归档新闻
|
||||
selectedItems.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const batchDelete = () => {
|
||||
if (confirm(`确定要删除选中的 ${selectedItems.value.length} 条新闻吗?此操作不可恢复!`)) {
|
||||
console.log('批量删除:', selectedItems.value)
|
||||
// 这里应该调用API批量删除新闻
|
||||
selectedItems.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 切换页码
|
||||
const changePage = (page) => {
|
||||
if (page >= 1 && page <= Math.ceil(pagination.value.total / pagination.value.pageSize)) {
|
||||
pagination.value.current = page
|
||||
console.log('切换到第', page, '页')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取页码数组
|
||||
const getPageNumbers = () => {
|
||||
const total = Math.ceil(pagination.value.total / pagination.value.pageSize)
|
||||
const current = pagination.value.current
|
||||
const pages = []
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
if (current <= 4) {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
} else if (current >= total - 3) {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = total - 4; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = current - 1; i <= current + 1; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
searchNews()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.overflow-x-auto {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 文本截断 */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
427
src/views/admin/Settings.vue
Normal file
427
src/views/admin/Settings.vue
Normal file
@@ -0,0 +1,427 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 页面标题 -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">系统设置</h1>
|
||||
<p class="text-gray-600 mt-1">配置系统参数和功能选项</p>
|
||||
</div>
|
||||
|
||||
<!-- 设置选项卡 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<nav class="flex border-b border-gray-200">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
:class="[
|
||||
'px-6 py-4 text-sm font-medium border-b-2 transition-colors',
|
||||
activeTab === tab.key
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
]"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- 基础设置 -->
|
||||
<div v-if="activeTab === 'basic'" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">网站名称</label>
|
||||
<input
|
||||
v-model="settings.siteName"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">网站标语</label>
|
||||
<input
|
||||
v-model="settings.siteSlogan"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">网站描述</label>
|
||||
<textarea
|
||||
v-model="settings.siteDescription"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">联系邮箱</label>
|
||||
<input
|
||||
v-model="settings.contactEmail"
|
||||
type="email"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">备案号</label>
|
||||
<input
|
||||
v-model="settings.icp"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安全设置 -->
|
||||
<div v-if="activeTab === 'security'" class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900">启用两步验证</h4>
|
||||
<p class="text-sm text-gray-500">为管理员账户启用两步验证以提高安全性</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="settings.twoFactorAuth"
|
||||
type="checkbox"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900">登录验证码</h4>
|
||||
<p class="text-sm text-gray-500">登录时需要输入图形验证码</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="settings.loginCaptcha"
|
||||
type="checkbox"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">密码最小长度</label>
|
||||
<select
|
||||
v-model="settings.minPasswordLength"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="6">6位</option>
|
||||
<option value="8">8位</option>
|
||||
<option value="10">10位</option>
|
||||
<option value="12">12位</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮件设置 -->
|
||||
<div v-if="activeTab === 'email'" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">SMTP服务器</label>
|
||||
<input
|
||||
v-model="settings.smtpHost"
|
||||
type="text"
|
||||
placeholder="smtp.example.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">SMTP端口</label>
|
||||
<input
|
||||
v-model="settings.smtpPort"
|
||||
type="number"
|
||||
placeholder="587"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">发件人邮箱</label>
|
||||
<input
|
||||
v-model="settings.fromEmail"
|
||||
type="email"
|
||||
placeholder="noreply@example.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">发件人名称</label>
|
||||
<input
|
||||
v-model="settings.fromName"
|
||||
type="text"
|
||||
placeholder="系统管理员"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
|
||||
<input
|
||||
v-model="settings.smtpUsername"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">密码</label>
|
||||
<input
|
||||
v-model="settings.smtpPassword"
|
||||
type="password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 存储设置 -->
|
||||
<div v-if="activeTab === 'storage'" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">存储类型</label>
|
||||
<select
|
||||
v-model="settings.storageType"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="local">本地存储</option>
|
||||
<option value="oss">阿里云OSS</option>
|
||||
<option value="cos">腾讯云COS</option>
|
||||
<option value="qiniu">七牛云</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.storageType !== 'local'" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Access Key</label>
|
||||
<input
|
||||
v-model="settings.accessKey"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Secret Key</label>
|
||||
<input
|
||||
v-model="settings.secretKey"
|
||||
type="password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">存储桶名称</label>
|
||||
<input
|
||||
v-model="settings.bucket"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">访问域名</label>
|
||||
<input
|
||||
v-model="settings.domain"
|
||||
type="text"
|
||||
placeholder="https://example.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">最大文件大小(MB)</label>
|
||||
<input
|
||||
v-model="settings.maxFileSize"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">允许的文件类型</label>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-2">
|
||||
<label v-for="type in fileTypes" :key="type.value" class="flex items-center">
|
||||
<input
|
||||
v-model="settings.allowedFileTypes"
|
||||
:value="type.value"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700">{{ type.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统信息 -->
|
||||
<div v-if="activeTab === 'system'" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-2">系统版本</h4>
|
||||
<p class="text-lg font-semibold text-blue-600">v1.0.0</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-2">数据库大小</h4>
|
||||
<p class="text-lg font-semibold text-purple-600">45.2 MB</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-2">存储空间</h4>
|
||||
<p class="text-lg font-semibold text-green-600">2.1 GB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<WarningIcon class="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">系统维护建议</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>建议定期清理系统缓存和日志文件以保持系统性能。</p>
|
||||
</div>
|
||||
<div class="mt-4 flex space-x-3">
|
||||
<button
|
||||
@click="clearCache"
|
||||
class="bg-yellow-100 hover:bg-yellow-200 text-yellow-800 px-3 py-1 rounded text-sm"
|
||||
>
|
||||
清理缓存
|
||||
</button>
|
||||
<button
|
||||
@click="clearLogs"
|
||||
class="bg-yellow-100 hover:bg-yellow-200 text-yellow-800 px-3 py-1 rounded text-sm"
|
||||
>
|
||||
清理日志
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div class="flex justify-end pt-6 border-t border-gray-200">
|
||||
<button
|
||||
@click="saveSettings"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg"
|
||||
>
|
||||
保存设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 图标组件
|
||||
const WarningIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const activeTab = ref('basic')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'basic', name: '基础设置' },
|
||||
{ key: 'security', name: '安全设置' },
|
||||
{ key: 'email', name: '邮件设置' },
|
||||
{ key: 'storage', name: '存储设置' },
|
||||
{ key: 'system', name: '系统信息' }
|
||||
]
|
||||
|
||||
const settings = ref({
|
||||
// 基础设置
|
||||
siteName: 'CMS管理系统',
|
||||
siteSlogan: '高效、安全、易用的内容管理系统',
|
||||
siteDescription: '这是一个基于Vue3和TailwindCSS构建的现代化内容管理系统,提供完整的文章管理、用户管理和系统配置功能。',
|
||||
contactEmail: 'admin@example.com',
|
||||
icp: '京ICP备12345678号',
|
||||
|
||||
// 安全设置
|
||||
twoFactorAuth: false,
|
||||
loginCaptcha: true,
|
||||
minPasswordLength: '8',
|
||||
|
||||
// 邮件设置
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
fromEmail: 'noreply@example.com',
|
||||
fromName: '系统管理员',
|
||||
smtpUsername: '',
|
||||
smtpPassword: '',
|
||||
|
||||
// 存储设置
|
||||
storageType: 'local',
|
||||
accessKey: '',
|
||||
secretKey: '',
|
||||
bucket: '',
|
||||
domain: '',
|
||||
maxFileSize: 10,
|
||||
allowedFileTypes: ['jpg', 'png', 'gif', 'pdf', 'doc']
|
||||
})
|
||||
|
||||
const fileTypes = [
|
||||
{ value: 'jpg', label: 'JPG图片' },
|
||||
{ value: 'png', label: 'PNG图片' },
|
||||
{ value: 'gif', label: 'GIF图片' },
|
||||
{ value: 'pdf', label: 'PDF文档' },
|
||||
{ value: 'doc', label: 'Word文档' },
|
||||
{ value: 'xls', label: 'Excel表格' },
|
||||
{ value: 'zip', label: 'ZIP压缩包' },
|
||||
{ value: 'mp4', label: 'MP4视频' }
|
||||
]
|
||||
|
||||
// 保存设置
|
||||
const saveSettings = () => {
|
||||
console.log('保存设置:', settings.value)
|
||||
// 这里应该调用API保存设置
|
||||
alert('设置保存成功!')
|
||||
}
|
||||
|
||||
// 清理缓存
|
||||
const clearCache = () => {
|
||||
if (confirm('确定要清理系统缓存吗?')) {
|
||||
console.log('清理缓存')
|
||||
// 这里应该调用API清理缓存
|
||||
alert('缓存清理完成!')
|
||||
}
|
||||
}
|
||||
|
||||
// 清理日志
|
||||
const clearLogs = () => {
|
||||
if (confirm('确定要清理系统日志吗?此操作不可恢复!')) {
|
||||
console.log('清理日志')
|
||||
// 这里应该调用API清理日志
|
||||
alert('日志清理完成!')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 开关样式 */
|
||||
.peer:checked ~ .peer-checked\:bg-blue-600 {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 1024px) {
|
||||
.grid-cols-1.md\:grid-cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid-cols-1.md\:grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 标签页导航样式 */
|
||||
nav ul li button {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
528
src/views/admin/Users.vue
Normal file
528
src/views/admin/Users.vue
Normal file
@@ -0,0 +1,528 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">用户管理</h1>
|
||||
<p class="text-gray-600 mt-1">管理系统用户账户和权限</p>
|
||||
</div>
|
||||
<button class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center">
|
||||
<PlusIcon class="w-4 h-4 mr-2" />
|
||||
添加用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">搜索</label>
|
||||
<input
|
||||
v-model="searchForm.keyword"
|
||||
type="text"
|
||||
placeholder="用户名/邮箱/手机号"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">角色</label>
|
||||
<select
|
||||
v-model="searchForm.role"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">全部角色</option>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="editor">编辑</option>
|
||||
<option value="user">普通用户</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">状态</label>
|
||||
<select
|
||||
v-model="searchForm.status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">正常</option>
|
||||
<option value="disabled">禁用</option>
|
||||
<option value="pending">待审核</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="searchUsers"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md mr-2"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
<button
|
||||
@click="resetSearch"
|
||||
class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<!-- 批量操作 -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedItems.length === users.length && users.length > 0"
|
||||
@change="toggleSelectAll"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-700">
|
||||
已选择 {{ selectedItems.length }} 项
|
||||
</span>
|
||||
<div v-if="selectedItems.length > 0" class="flex space-x-2">
|
||||
<button
|
||||
@click="batchEnable"
|
||||
class="text-sm text-green-600 hover:text-green-800"
|
||||
>
|
||||
批量启用
|
||||
</button>
|
||||
<button
|
||||
@click="batchDisable"
|
||||
class="text-sm text-yellow-600 hover:text-yellow-800"
|
||||
>
|
||||
批量禁用
|
||||
</button>
|
||||
<button
|
||||
@click="batchDelete"
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700">
|
||||
显示第 {{ (pagination.current - 1) * pagination.pageSize + 1 }} 到
|
||||
{{ Math.min(pagination.current * pagination.pageSize, pagination.total) }} 条,
|
||||
共 {{ pagination.total }} 条记录
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedItems.length === users.length && users.length > 0"
|
||||
@change="toggleSelectAll"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
用户信息
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
角色
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
状态
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
最后登录
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="user in users" :key="user.id" class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="user.id"
|
||||
v-model="selectedItems"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10">
|
||||
<img
|
||||
class="h-10 w-10 rounded-full"
|
||||
:src="user.avatar || '/placeholder.svg?height=40&width=40'"
|
||||
:alt="user.username"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-medium text-gray-900">{{ user.username }}</div>
|
||||
<div class="text-sm text-gray-500">{{ user.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="getRoleClass(user.role)">
|
||||
{{ getRoleText(user.role) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="getStatusClass(user.status)">
|
||||
{{ getStatusText(user.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ user.lastLogin || '从未登录' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="editUser(user)"
|
||||
class="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="user.status === 'active'"
|
||||
@click="toggleUserStatus(user, 'disabled')"
|
||||
class="text-yellow-600 hover:text-yellow-900"
|
||||
>
|
||||
禁用
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="toggleUserStatus(user, 'active')"
|
||||
class="text-green-600 hover:text-green-900"
|
||||
>
|
||||
启用
|
||||
</button>
|
||||
<button
|
||||
@click="resetPassword(user)"
|
||||
class="text-purple-600 hover:text-purple-900"
|
||||
>
|
||||
重置密码
|
||||
</button>
|
||||
<button
|
||||
@click="deleteUser(user)"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
@click="changePage(pagination.current - 1)"
|
||||
:disabled="pagination.current <= 1"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
@click="changePage(pagination.current + 1)"
|
||||
:disabled="pagination.current >= Math.ceil(pagination.total / pagination.pageSize)"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
显示第 {{ (pagination.current - 1) * pagination.pageSize + 1 }} 到
|
||||
{{ Math.min(pagination.current * pagination.pageSize, pagination.total) }} 条,
|
||||
共 {{ pagination.total }} 条记录
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
@click="changePage(pagination.current - 1)"
|
||||
:disabled="pagination.current <= 1"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
v-for="page in getPageNumbers()"
|
||||
:key="page"
|
||||
@click="changePage(page)"
|
||||
:class="[
|
||||
'relative inline-flex items-center px-4 py-2 border text-sm font-medium',
|
||||
page === pagination.current
|
||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button
|
||||
@click="changePage(pagination.current + 1)"
|
||||
:disabled="pagination.current >= Math.ceil(pagination.total / pagination.pageSize)"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
// 图标组件
|
||||
const PlusIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const searchForm = ref({
|
||||
keyword: '',
|
||||
role: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
const users = ref([
|
||||
{
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
avatar: '/placeholder.svg?height=40&width=40',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
lastLogin: '2024-01-15 10:30:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'editor',
|
||||
email: 'editor@example.com',
|
||||
avatar: '/placeholder.svg?height=40&width=40',
|
||||
role: 'editor',
|
||||
status: 'active',
|
||||
lastLogin: '2024-01-14 16:45:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: 'user1',
|
||||
email: 'user1@example.com',
|
||||
avatar: '/placeholder.svg?height=40&width=40',
|
||||
role: 'user',
|
||||
status: 'disabled',
|
||||
lastLogin: '2024-01-10 09:15:00'
|
||||
}
|
||||
])
|
||||
|
||||
const selectedItems = ref([])
|
||||
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 3
|
||||
})
|
||||
|
||||
// 角色映射
|
||||
const roleMap = {
|
||||
admin: '管理员',
|
||||
editor: '编辑',
|
||||
user: '普通用户'
|
||||
}
|
||||
|
||||
// 状态映射
|
||||
const statusMap = {
|
||||
active: '正常',
|
||||
disabled: '禁用',
|
||||
pending: '待审核'
|
||||
}
|
||||
|
||||
// 获取角色文本
|
||||
const getRoleText = (role) => roleMap[role] || role
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => statusMap[status] || status
|
||||
|
||||
// 获取角色样式
|
||||
const getRoleClass = (role) => {
|
||||
const classes = {
|
||||
admin: 'bg-red-100 text-red-800',
|
||||
editor: 'bg-blue-100 text-blue-800',
|
||||
user: 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
return classes[role] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
// 获取状态样式
|
||||
const getStatusClass = (status) => {
|
||||
const classes = {
|
||||
active: 'bg-green-100 text-green-800',
|
||||
disabled: 'bg-red-100 text-red-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800'
|
||||
}
|
||||
return classes[status] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
// 搜索用户
|
||||
const searchUsers = () => {
|
||||
console.log('搜索用户:', searchForm.value)
|
||||
// 这里应该调用API搜索用户
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.value = {
|
||||
keyword: '',
|
||||
role: '',
|
||||
status: ''
|
||||
}
|
||||
searchUsers()
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedItems.value.length === users.value.length) {
|
||||
selectedItems.value = []
|
||||
} else {
|
||||
selectedItems.value = users.value.map(user => user.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换用户状态
|
||||
const toggleUserStatus = (user, newStatus) => {
|
||||
const action = newStatus === 'active' ? '启用' : '禁用'
|
||||
if (confirm(`确定要${action}用户"${user.username}"吗?`)) {
|
||||
console.log(`${action}用户:`, user.id)
|
||||
// 这里应该调用API更新用户状态
|
||||
user.status = newStatus
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
const editUser = (user) => {
|
||||
console.log('编辑用户:', user.id)
|
||||
// 这里应该打开编辑用户对话框
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
const resetPassword = (user) => {
|
||||
if (confirm(`确定要重置用户"${user.username}"的密码吗?`)) {
|
||||
console.log('重置密码:', user.id)
|
||||
// 这里应该调用API重置密码
|
||||
alert('密码重置成功,新密码已发送到用户邮箱')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
const deleteUser = (user) => {
|
||||
if (confirm(`确定要删除用户"${user.username}"吗?此操作不可恢复!`)) {
|
||||
console.log('删除用户:', user.id)
|
||||
// 这里应该调用API删除用户
|
||||
const index = users.value.findIndex(u => u.id === user.id)
|
||||
if (index > -1) {
|
||||
users.value.splice(index, 1)
|
||||
pagination.value.total--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量启用
|
||||
const batchEnable = () => {
|
||||
if (confirm(`确定要启用选中的 ${selectedItems.value.length} 个用户吗?`)) {
|
||||
console.log('批量启用:', selectedItems.value)
|
||||
// 这里应该调用API批量启用用户
|
||||
selectedItems.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 批量禁用
|
||||
const batchDisable = () => {
|
||||
if (confirm(`确定要禁用选中的 ${selectedItems.value.length} 个用户吗?`)) {
|
||||
console.log('批量禁用:', selectedItems.value)
|
||||
// 这里应该调用API批量禁用用户
|
||||
selectedItems.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const batchDelete = () => {
|
||||
if (confirm(`确定要删除选中的 ${selectedItems.value.length} 个用户吗?此操作不可恢复!`)) {
|
||||
console.log('批量删除:', selectedItems.value)
|
||||
// 这里应该调用API批量删除用户
|
||||
selectedItems.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 切换页码
|
||||
const changePage = (page) => {
|
||||
if (page >= 1 && page <= Math.ceil(pagination.value.total / pagination.value.pageSize)) {
|
||||
pagination.value.current = page
|
||||
console.log('切换到第', page, '页')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取页码数组
|
||||
const getPageNumbers = () => {
|
||||
const total = Math.ceil(pagination.value.total / pagination.value.pageSize)
|
||||
const current = pagination.value.current
|
||||
const pages = []
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
if (current <= 4) {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
} else if (current >= total - 3) {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = total - 4; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = current - 1; i <= current + 1; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
searchUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.overflow-x-auto {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
381
src/views/articles/detail.vue
Normal file
381
src/views/articles/detail.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div v-if="loading" class="flex items-center justify-center min-h-screen">
|
||||
<LoadingIcon class="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!article" class="flex items-center justify-center min-h-screen">
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">文章不存在</h2>
|
||||
<p class="text-gray-600 mb-8">抱歉,您访问的文章不存在或已被删除。</p>
|
||||
<router-link
|
||||
to="/articles"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
返回文章列表
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article v-else class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- 面包屑导航 -->
|
||||
<nav class="flex mb-8" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
||||
<li class="inline-flex items-center">
|
||||
<router-link to="/" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600">
|
||||
<HomeIcon class="w-4 h-4 mr-2" />
|
||||
首页
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<ChevronRightIcon class="w-4 h-4 text-gray-400" />
|
||||
<router-link to="/articles" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2">
|
||||
文章
|
||||
</router-link>
|
||||
</div>
|
||||
</li>
|
||||
<li aria-current="page">
|
||||
<div class="flex items-center">
|
||||
<ChevronRightIcon class="w-4 h-4 text-gray-400" />
|
||||
<span class="ml-1 text-sm font-medium text-gray-500 md:ml-2 truncate">
|
||||
{{ article.title }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- 文章头部 -->
|
||||
<header class="bg-white rounded-lg shadow-sm p-8 mb-8">
|
||||
<div class="mb-4">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium',
|
||||
getCategoryClass(article.category)
|
||||
]"
|
||||
>
|
||||
{{ getCategoryName(article.category) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
{{ article.title }}
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-6 text-sm text-gray-500 mb-6">
|
||||
<div class="flex items-center">
|
||||
<UserIcon class="w-4 h-4 mr-2" />
|
||||
<span>{{ article.author || '管理员' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<CalendarIcon class="w-4 h-4 mr-2" />
|
||||
<span>{{ formatDate(article.publishedAt) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<EyeIcon class="w-4 h-4 mr-2" />
|
||||
<span>{{ article.views || 0 }} 次浏览</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 摘要 -->
|
||||
<div v-if="article.summary" class="bg-blue-50 border-l-4 border-blue-400 p-4 rounded-r-lg">
|
||||
<p class="text-blue-800 font-medium">{{ article.summary }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-8 mb-8">
|
||||
<!-- 特色图片 -->
|
||||
<div v-if="article.image" class="mb-8">
|
||||
<img
|
||||
:src="article.image"
|
||||
:alt="article.title"
|
||||
class="w-full h-64 md:h-96 object-cover rounded-lg shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 正文内容 -->
|
||||
<div class="prose prose-lg max-w-none">
|
||||
<div v-html="article.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相关文章 -->
|
||||
<div v-if="relatedArticles.length > 0" class="bg-white rounded-lg shadow-sm p-8">
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-6">相关文章</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<article
|
||||
v-for="item in relatedArticles"
|
||||
:key="item.id"
|
||||
class="flex gap-4 p-4 rounded-lg border border-gray-200 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer"
|
||||
@click="goToArticle(item.id)"
|
||||
>
|
||||
<img
|
||||
:src="item.image || '/placeholder.svg?height=80&width=120'"
|
||||
:alt="item.title"
|
||||
class="w-20 h-20 object-cover rounded-lg flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-lg font-semibold text-gray-900 mb-2 line-clamp-2">
|
||||
{{ item.title }}
|
||||
</h4>
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<CalendarIcon class="w-4 h-4 mr-1" />
|
||||
<span>{{ formatDate(item.publishedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { articlesAPI } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 图标组件
|
||||
const LoadingIcon = {
|
||||
template: `<svg fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>`
|
||||
}
|
||||
|
||||
const HomeIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path></svg>`
|
||||
}
|
||||
|
||||
const ChevronRightIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>`
|
||||
}
|
||||
|
||||
const UserIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>`
|
||||
}
|
||||
|
||||
const CalendarIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>`
|
||||
}
|
||||
|
||||
const EyeIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(true)
|
||||
const article = ref(null)
|
||||
const relatedArticles = ref([])
|
||||
|
||||
// 模拟文章数据
|
||||
const mockArticleData = {
|
||||
1: {
|
||||
id: 1,
|
||||
title: '如何构建现代化的内容管理系统',
|
||||
summary: '本文将详细介绍如何使用Vue3和现代化技术栈构建一个功能完整的内容管理系统。',
|
||||
content: `
|
||||
<p>在当今数字化时代,内容管理系统(CMS)已成为企业和个人管理数字内容的重要工具。本文将详细介绍如何使用Vue3和现代化技术栈构建一个功能完整的内容管理系统。</p>
|
||||
|
||||
<h3>技术选型</h3>
|
||||
<p>我们选择了以下技术栈来构建这个CMS系统:</p>
|
||||
<ul>
|
||||
<li>前端:Vue3 + Vite + TailwindCSS</li>
|
||||
<li>状态管理:Pinia</li>
|
||||
<li>路由:Vue Router</li>
|
||||
<li>UI组件:自定义组件库</li>
|
||||
</ul>
|
||||
|
||||
<h3>系统架构</h3>
|
||||
<p>系统采用前后端分离的架构设计,前端负责用户界面和交互,后端提供API接口和数据管理。这种架构具有以下优势:</p>
|
||||
<ul>
|
||||
<li>开发效率高,前后端可并行开发</li>
|
||||
<li>维护性好,职责分离清晰</li>
|
||||
<li>扩展性强,易于横向扩展</li>
|
||||
<li>用户体验佳,支持SPA应用</li>
|
||||
</ul>
|
||||
|
||||
<h3>核心功能</h3>
|
||||
<p>系统包含以下核心功能模块:</p>
|
||||
<ul>
|
||||
<li>用户管理:支持用户注册、登录、权限控制</li>
|
||||
<li>内容管理:文章、新闻的创建、编辑、发布</li>
|
||||
<li>媒体管理:图片、文件的上传和管理</li>
|
||||
<li>系统设置:网站配置、主题设置等</li>
|
||||
</ul>
|
||||
|
||||
<h3>总结</h3>
|
||||
<p>通过合理的技术选型和架构设计,我们成功构建了一个现代化的内容管理系统。该系统不仅功能完整,而且具有良好的用户体验和可维护性。</p>
|
||||
`,
|
||||
category: 'technology',
|
||||
author: '技术团队',
|
||||
publishedAt: '2024-01-15',
|
||||
views: 1580,
|
||||
image: 'https://images.unsplash.com/photo-1555066931-4365d14bab8c?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2070&q=80'
|
||||
}
|
||||
}
|
||||
|
||||
// 相关文章数据
|
||||
const mockRelatedArticles = [
|
||||
{
|
||||
id: 2,
|
||||
title: 'Vue3组合式API最佳实践',
|
||||
publishedAt: '2024-01-14',
|
||||
image: 'https://images.unsplash.com/photo-1627398242454-45a1465c2479?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2074&q=80'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'TailwindCSS实用技巧分享',
|
||||
publishedAt: '2024-01-13',
|
||||
image: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2070&q=80'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取分类名称
|
||||
const getCategoryName = (category) => {
|
||||
const categoryMap = {
|
||||
technology: '技术分享',
|
||||
tutorial: '教程指南',
|
||||
news: '新闻资讯',
|
||||
product: '产品介绍'
|
||||
}
|
||||
return categoryMap[category] || '其他'
|
||||
}
|
||||
|
||||
// 获取分类样式
|
||||
const getCategoryClass = (category) => {
|
||||
const classes = {
|
||||
technology: 'bg-blue-100 text-blue-800',
|
||||
tutorial: 'bg-green-100 text-green-800',
|
||||
news: 'bg-purple-100 text-purple-800',
|
||||
product: 'bg-orange-100 text-orange-800'
|
||||
}
|
||||
return classes[category] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取文章详情
|
||||
const fetchArticleDetail = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const articleId = route.params.id
|
||||
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
article.value = mockArticleData[articleId] || null
|
||||
|
||||
if (article.value) {
|
||||
// 增加浏览量
|
||||
article.value.views = (article.value.views || 0) + 1
|
||||
|
||||
// 获取相关文章
|
||||
relatedArticles.value = mockRelatedArticles
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取文章详情失败:', error)
|
||||
article.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到其他文章
|
||||
const goToArticle = (id) => {
|
||||
router.push(`/articles/${id}`)
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchArticleDetail()
|
||||
})
|
||||
|
||||
// 监听路由变化
|
||||
import { watch } from 'vue'
|
||||
watch(() => route.params.id, () => {
|
||||
if (route.params.id) {
|
||||
fetchArticleDetail()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 文本截断 */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 文章内容样式 */
|
||||
.prose {
|
||||
color: #374151;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
margin: 1.25rem 0;
|
||||
padding-left: 1.625rem;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-1.md\:grid-cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.text-3xl.md\:text-4xl {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.flex-wrap {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.gap-6 {
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
479
src/views/articles/index.vue
Normal file
479
src/views/articles/index.vue
Normal file
@@ -0,0 +1,479 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- 页面头部 -->
|
||||
<div class="bg-white shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">技术文章</h1>
|
||||
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
分享最新的技术知识和开发经验
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章分类导航 -->
|
||||
<div class="bg-white border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<nav class="flex space-x-8 overflow-x-auto py-4">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
@click="activeCategory = category.key"
|
||||
:class="[
|
||||
'whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeCategory === category.key
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
]"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
placeholder="搜索文章标题或内容..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
@keyup.enter="searchArticles"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="searchArticles"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
<button
|
||||
@click="resetSearch"
|
||||
class="bg-gray-500 hover:bg-gray-600 text-white px-6 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章列表 -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<LoadingIcon class="w-8 h-8 animate-spin mx-auto mb-4 text-blue-600" />
|
||||
<p class="text-gray-600">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="articlesList.length === 0" class="text-center py-12">
|
||||
<EmptyState />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<article
|
||||
v-for="article in articlesList"
|
||||
:key="article.id"
|
||||
class="bg-white rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer"
|
||||
@click="goToDetail(article.id)"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="article.image || 'https://images.unsplash.com/photo-1555066931-4365d14bab8c?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2070&q=80'"
|
||||
:alt="article.title"
|
||||
class="w-full h-48 object-cover"
|
||||
/>
|
||||
<div class="absolute top-4 left-4">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
getCategoryClass(article.category)
|
||||
]"
|
||||
>
|
||||
{{ getCategoryName(article.category) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-3 line-clamp-2">
|
||||
{{ article.title }}
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-4 line-clamp-3">
|
||||
{{ article.summary || article.content }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<UserIcon class="w-4 h-4 mr-2" />
|
||||
<span>{{ article.author || '管理员' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<CalendarIcon class="w-4 h-4 mr-2" />
|
||||
<span>{{ formatDate(article.publishedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<EyeIcon class="w-4 h-4 mr-1" />
|
||||
<span>{{ article.views || 0 }} 次浏览</span>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="goToDetail(article.id)"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium text-sm"
|
||||
>
|
||||
阅读更多 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="articlesList.length > 0" class="mt-12 flex justify-center">
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
@click="changePage(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="page in getPageNumbers()"
|
||||
:key="page"
|
||||
@click="changePage(page)"
|
||||
:class="[
|
||||
'relative inline-flex items-center px-4 py-2 border text-sm font-medium',
|
||||
page === currentPage
|
||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="changePage(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { articlesAPI } from '@/api'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 图标组件
|
||||
const LoadingIcon = {
|
||||
template: `<svg fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>`
|
||||
}
|
||||
|
||||
const UserIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>`
|
||||
}
|
||||
|
||||
const CalendarIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>`
|
||||
}
|
||||
|
||||
const EyeIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const articlesList = ref([])
|
||||
const searchKeyword = ref('')
|
||||
const activeCategory = ref('all')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(9)
|
||||
const total = ref(0)
|
||||
|
||||
// 文章分类
|
||||
const categories = ref([
|
||||
{ key: 'all', name: '全部' },
|
||||
{ key: 'technology', name: '技术分享' },
|
||||
{ key: 'tutorial', name: '教程指南' },
|
||||
{ key: 'news', name: '新闻资讯' },
|
||||
{ key: 'product', name: '产品介绍' }
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
|
||||
|
||||
// 模拟文章数据
|
||||
const mockArticles = [
|
||||
{
|
||||
id: 1,
|
||||
title: '如何构建现代化的内容管理系统',
|
||||
summary: '本文将详细介绍如何使用Vue3和现代化技术栈构建一个功能完整的内容管理系统。',
|
||||
category: 'technology',
|
||||
author: '技术团队',
|
||||
publishedAt: '2024-01-15',
|
||||
views: 1580,
|
||||
image: 'https://images.unsplash.com/photo-1555066931-4365d14bab8c?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2070&q=80'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Vue3组合式API最佳实践',
|
||||
summary: '深入探讨Vue3组合式API的使用技巧和最佳实践,帮助开发者更好地掌握这一新特性。',
|
||||
category: 'tutorial',
|
||||
author: '前端专家',
|
||||
publishedAt: '2024-01-14',
|
||||
views: 1234,
|
||||
image: 'https://images.unsplash.com/photo-1627398242454-45a1465c2479?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2074&q=80'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'TailwindCSS实用技巧分享',
|
||||
summary: '分享一些TailwindCSS的实用技巧和高级用法,让你的样式开发更加高效。',
|
||||
category: 'tutorial',
|
||||
author: 'UI设计师',
|
||||
publishedAt: '2024-01-13',
|
||||
views: 987,
|
||||
image: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2070&q=80'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '前端性能优化实战指南',
|
||||
summary: '从多个维度分析前端性能优化的方法和技巧,提升用户体验和页面加载速度。',
|
||||
category: 'technology',
|
||||
author: '性能专家',
|
||||
publishedAt: '2024-01-12',
|
||||
views: 1456,
|
||||
image: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2015&q=80'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '微服务架构设计原则',
|
||||
summary: '探讨微服务架构的设计原则和实施策略,帮助团队构建可扩展的系统架构。',
|
||||
category: 'technology',
|
||||
author: '架构师',
|
||||
publishedAt: '2024-01-11',
|
||||
views: 2103,
|
||||
image: 'https://images.unsplash.com/photo-1518186285589-2f7649de83e0?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2074&q=80'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'React vs Vue:框架选择指南',
|
||||
summary: '客观比较React和Vue两大前端框架的优缺点,为项目选择提供参考依据。',
|
||||
category: 'news',
|
||||
author: '技术评论员',
|
||||
publishedAt: '2024-01-10',
|
||||
views: 1789,
|
||||
image: 'https://images.unsplash.com/photo-1633356122544-f134324a6cee?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2070&q=80'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取分类名称
|
||||
const getCategoryName = (category) => {
|
||||
const categoryItem = categories.value.find(cat => cat.key === category)
|
||||
return categoryItem ? categoryItem.name : '其他'
|
||||
}
|
||||
|
||||
// 获取分类样式
|
||||
const getCategoryClass = (category) => {
|
||||
const classes = {
|
||||
technology: 'bg-blue-100 text-blue-800',
|
||||
tutorial: 'bg-green-100 text-green-800',
|
||||
news: 'bg-purple-100 text-purple-800',
|
||||
product: 'bg-orange-100 text-orange-800'
|
||||
}
|
||||
return classes[category] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取文章列表
|
||||
const fetchArticles = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
let filteredArticles = [...mockArticles]
|
||||
|
||||
// 按分类筛选
|
||||
if (activeCategory.value !== 'all') {
|
||||
filteredArticles = filteredArticles.filter(article => article.category === activeCategory.value)
|
||||
}
|
||||
|
||||
// 按关键词搜索
|
||||
if (searchKeyword.value.trim()) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
filteredArticles = filteredArticles.filter(article =>
|
||||
article.title.toLowerCase().includes(keyword) ||
|
||||
article.summary.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
total.value = filteredArticles.length
|
||||
|
||||
// 分页
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
articlesList.value = filteredArticles.slice(start, end)
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取文章列表失败:', error)
|
||||
articlesList.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索文章
|
||||
const searchArticles = () => {
|
||||
currentPage.value = 1
|
||||
fetchArticles()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchKeyword.value = ''
|
||||
activeCategory.value = 'all'
|
||||
currentPage.value = 1
|
||||
fetchArticles()
|
||||
}
|
||||
|
||||
// 切换页码
|
||||
const changePage = (page) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
fetchArticles()
|
||||
// 滚动到顶部
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
// 获取页码数组
|
||||
const getPageNumbers = () => {
|
||||
const pages = []
|
||||
const total = totalPages.value
|
||||
const current = currentPage.value
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
if (current <= 4) {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
} else if (current >= total - 3) {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = total - 4; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = current - 1; i <= current + 1; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
// 跳转到详情页
|
||||
const goToDetail = (id) => {
|
||||
router.push(`/articles/${id}`)
|
||||
}
|
||||
|
||||
// 监听分类变化
|
||||
const handleCategoryChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchArticles()
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchArticles()
|
||||
})
|
||||
|
||||
// 监听分类变化
|
||||
import { watch } from 'vue'
|
||||
watch(activeCategory, handleCategoryChange)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 文本截断 */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-1.md\:grid-cols-2.lg\:grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.flex-col.md\:flex-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.space-x-8 {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.space-x-8 > * + * {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
405
src/views/auth/Login.vue
Normal file
405
src/views/auth/Login.vue
Normal file
@@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex">
|
||||
<!-- 左侧装饰区域 -->
|
||||
<div class="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-blue-600 via-purple-600 to-indigo-800 relative overflow-hidden">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="absolute inset-0">
|
||||
<div class="absolute top-20 left-20 w-32 h-32 bg-white/10 rounded-full blur-xl"></div>
|
||||
<div class="absolute top-40 right-32 w-24 h-24 bg-white/5 rounded-full blur-lg"></div>
|
||||
<div class="absolute bottom-32 left-32 w-40 h-40 bg-white/5 rounded-full blur-2xl"></div>
|
||||
<div class="absolute bottom-20 right-20 w-28 h-28 bg-white/10 rounded-full blur-xl"></div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="relative z-10 flex flex-col justify-center items-center text-white p-12">
|
||||
<div class="max-w-md text-center">
|
||||
<!-- Logo -->
|
||||
<div class="mb-8">
|
||||
<div class="w-20 h-20 bg-white/20 backdrop-blur-sm rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<LogoIcon class="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold mb-2">CMS管理系统</h1>
|
||||
<p class="text-blue-100 text-lg">现代化内容管理平台</p>
|
||||
</div>
|
||||
|
||||
<!-- 特性介绍 -->
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-lg flex items-center justify-center">
|
||||
<ShieldIcon class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<h3 class="font-semibold">安全可靠</h3>
|
||||
<p class="text-blue-100 text-sm">企业级安全保障</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-lg flex items-center justify-center">
|
||||
<SparklesIcon class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<h3 class="font-semibold">简单易用</h3>
|
||||
<p class="text-blue-100 text-sm">直观的操作界面</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-lg flex items-center justify-center">
|
||||
<RocketIcon class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<h3 class="font-semibold">高效管理</h3>
|
||||
<p class="text-blue-100 text-sm">提升工作效率</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧登录表单 -->
|
||||
<div class="flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 bg-gray-50">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- 移动端Logo -->
|
||||
<div class="lg:hidden text-center">
|
||||
<div class="w-16 h-16 bg-gradient-to-br from-blue-600 to-purple-600 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<LogoIcon class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">管理员登录</h2>
|
||||
<p class="mt-2 text-gray-600">请输入您的账户信息</p>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端标题 -->
|
||||
<div class="hidden lg:block text-center">
|
||||
<h2 class="text-3xl font-bold text-gray-900">欢迎回来</h2>
|
||||
<p class="mt-2 text-gray-600">请登录您的管理员账户</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="errors.general" class="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<AlertIcon class="h-5 w-5 text-red-400 mt-0.5" />
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-red-800">{{ errors.general }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<!-- 用户名输入 -->
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
用户名
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<UserIcon class="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
v-model="loginForm.username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
:class="[
|
||||
'block w-full pl-10 pr-3 py-3 border rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors',
|
||||
errors.username ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : 'border-gray-300'
|
||||
]"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.username" class="mt-2 text-sm text-red-600">{{ errors.username }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 密码输入 -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<LockIcon class="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
v-model="loginForm.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
:class="[
|
||||
'block w-full pl-10 pr-10 py-3 border rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors',
|
||||
errors.password ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : 'border-gray-300'
|
||||
]"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
<EyeIcon v-if="showPassword" class="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
<EyeOffIcon v-else class="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" class="mt-2 text-sm text-red-600">{{ errors.password }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 记住我和忘记密码 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="remember"
|
||||
v-model="loginForm.remember"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label for="remember" class="ml-2 block text-sm text-gray-700">
|
||||
记住我
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
<a href="#" class="font-medium text-blue-600 hover:text-blue-500 transition-colors">
|
||||
忘记密码?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
|
||||
<LoadingIcon v-if="loading" class="h-5 w-5 animate-spin" />
|
||||
<LoginIcon v-else class="h-5 w-5 group-hover:text-blue-200" />
|
||||
</span>
|
||||
{{ loading ? '登录中...' : '立即登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 演示账号信息 -->
|
||||
<div class="mt-8 pt-6 border-t border-gray-200">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-500 mb-3">演示账号信息</p>
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-gray-600">用户名:</span>
|
||||
<span class="font-mono bg-white px-2 py-1 rounded border">admin</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-gray-600">密码:</span>
|
||||
<span class="font-mono bg-white px-2 py-1 rounded border">123456</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="text-center text-sm text-gray-500">
|
||||
<p>© 2024 CMS管理系统. 保留所有权利.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 图标组件
|
||||
const LogoIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-4m-5 0H3m2 0h3M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path></svg>`
|
||||
}
|
||||
|
||||
const ShieldIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>`
|
||||
}
|
||||
|
||||
const SparklesIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path></svg>`
|
||||
}
|
||||
|
||||
const RocketIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12.75 3.03v.568c0 .334.148.65.405.864l1.068.89c.442.369.535 1.01.216 1.49l-.51.766a2.25 2.25 0 01-1.161.886l-.143.048a1.107 1.107 0 00-.57 1.664c.369.555.169 1.307-.427 1.605L9 13.125l.423 1.059a.956.956 0 01-1.652.928l-.679-.906a1.125 1.125 0 00-1.906.172L4.5 15.75l-.612.153M12.75 3.031a9 9 0 00-8.862 12.872M12.75 3.031a9 9 0 016.69 14.036m0 0l-.177-.529A2.25 2.25 0 0017.128 15H16.5l-.324-.324a1.453 1.453 0 00-2.328.377l-.036.073a1.586 1.586 0 01-.982.816l-.99.282c-.55.157-.894.702-.8 1.267l.073.438c.08.474.49.821.97.821.846 0 1.598.542 1.865 1.345l.215.643m5.276-3.67a9.012 9.012 0 01-5.276 3.67m0 0a9 9 0 01-10.275-4.835M15.75 9c0 .896-.393 1.7-1.016 2.25"></path></svg>`
|
||||
}
|
||||
|
||||
const UserIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>`
|
||||
}
|
||||
|
||||
const LockIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>`
|
||||
}
|
||||
|
||||
const EyeIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>`
|
||||
}
|
||||
|
||||
const EyeOffIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"></path></svg>`
|
||||
}
|
||||
|
||||
const AlertIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>`
|
||||
}
|
||||
|
||||
const LoadingIcon = {
|
||||
template: `<svg fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>`
|
||||
}
|
||||
|
||||
const LoginIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
remember: false
|
||||
})
|
||||
|
||||
const errors = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
general: ''
|
||||
})
|
||||
|
||||
// 表单验证
|
||||
const validateForm = () => {
|
||||
errors.value = {
|
||||
username: '',
|
||||
password: '',
|
||||
general: ''
|
||||
}
|
||||
|
||||
let isValid = true
|
||||
|
||||
if (!loginForm.value.username.trim()) {
|
||||
errors.value.username = '请输入用户名'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
if (!loginForm.value.password.trim()) {
|
||||
errors.value.password = '请输入密码'
|
||||
isValid = false
|
||||
} else if (loginForm.value.password.length < 6) {
|
||||
errors.value.password = '密码长度不能少于6位'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// 简单的演示验证
|
||||
if (loginForm.value.username === 'admin' && loginForm.value.password === '123456') {
|
||||
// 保存用户信息
|
||||
userStore.setUser({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
name: '管理员',
|
||||
role: 'admin'
|
||||
})
|
||||
|
||||
// 跳转到管理后台
|
||||
const redirect = router.currentRoute.value.query.redirect || '/admin'
|
||||
router.push(redirect)
|
||||
} else {
|
||||
errors.value.general = '用户名或密码错误'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
errors.value.general = '登录失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 渐变背景动画 */
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-gradient-to-br {
|
||||
background-size: 400% 400%;
|
||||
animation: gradient 15s ease infinite;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 按钮悬停效果 */
|
||||
.group:hover .group-hover\:text-blue-200 {
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 1024px) {
|
||||
.lg\:w-1\/2 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 输入框聚焦效果 */
|
||||
input:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
355
src/views/contact/index.vue
Normal file
355
src/views/contact/index.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- 页面头部 -->
|
||||
<div class="bg-white shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">联系我们</h1>
|
||||
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
我们随时准备为您提供帮助,欢迎通过以下方式与我们取得联系
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 联系方式和表单 -->
|
||||
<section class="py-16">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<!-- 联系信息 -->
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">联系信息</h2>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<LocationIcon class="w-6 h-6 text-blue-600 mt-1" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">公司地址</h3>
|
||||
<p class="text-gray-600">深圳市南山区科技园南区深圳湾科技生态园</p>
|
||||
<p class="text-gray-600">10栋A座5层501室</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<PhoneIcon class="w-6 h-6 text-blue-600 mt-1" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">联系电话</h3>
|
||||
<p class="text-gray-600">客服热线:400-123-4567</p>
|
||||
<p class="text-gray-600">技术支持:0755-8888-9999</p>
|
||||
<p class="text-gray-600">周一至周五:9:00 - 18:00</p>
|
||||
<p class="text-gray-600">周六:10:00 - 16:00</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<EmailIcon class="w-6 h-6 text-blue-600 mt-1" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">电子邮箱</h3>
|
||||
<p class="text-gray-600">商务合作:business@example.com</p>
|
||||
<p class="text-gray-600">技术支持:support@example.com</p>
|
||||
<p class="text-gray-600">人事招聘:hr@example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工作时间 -->
|
||||
<div class="bg-blue-50 rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">工作时间</h3>
|
||||
<div class="space-y-2 text-gray-600">
|
||||
<div class="flex justify-between">
|
||||
<span>周一至周五</span>
|
||||
<span>9:00 - 18:00</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>周六</span>
|
||||
<span>10:00 - 16:00</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>周日</span>
|
||||
<span>休息</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 联系表单 -->
|
||||
<div class="bg-white rounded-lg shadow-lg p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">发送消息</h2>
|
||||
<form @submit.prevent="submitForm" class="space-y-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
姓名 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
:class="[
|
||||
'w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors',
|
||||
errors.name ? 'border-red-500' : 'border-gray-300'
|
||||
]"
|
||||
placeholder="请输入您的姓名"
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1 text-sm text-red-600">{{ errors.name }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
邮箱 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
:class="[
|
||||
'w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors',
|
||||
errors.email ? 'border-red-500' : 'border-gray-300'
|
||||
]"
|
||||
placeholder="请输入您的邮箱"
|
||||
/>
|
||||
<p v-if="errors.email" class="mt-1 text-sm text-red-600">{{ errors.email }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
联系电话
|
||||
</label>
|
||||
<input
|
||||
id="phone"
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||
placeholder="请输入您的联系电话"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="subject" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
主题 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="subject"
|
||||
v-model="form.subject"
|
||||
:class="[
|
||||
'w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors',
|
||||
errors.subject ? 'border-red-500' : 'border-gray-300'
|
||||
]"
|
||||
>
|
||||
<option value="">请选择主题</option>
|
||||
<option value="business">商务合作</option>
|
||||
<option value="technical">技术支持</option>
|
||||
<option value="feedback">意见反馈</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
<p v-if="errors.subject" class="mt-1 text-sm text-red-600">{{ errors.subject }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="message" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
消息内容 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
v-model="form.message"
|
||||
rows="5"
|
||||
:class="[
|
||||
'w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors resize-none',
|
||||
errors.message ? 'border-red-500' : 'border-gray-300'
|
||||
]"
|
||||
placeholder="请详细描述您的问题或需求..."
|
||||
></textarea>
|
||||
<p v-if="errors.message" class="mt-1 text-sm text-red-600">{{ errors.message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="agree"
|
||||
v-model="form.agree"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label for="agree" class="ml-2 text-sm text-gray-600">
|
||||
我同意<a href="#" class="text-blue-600 hover:text-blue-800">隐私政策</a> 和 <a href="#" class="text-blue-600 hover:text-blue-800">服务条款</a>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="errors.agree" class="text-sm text-red-600">{{ errors.agree }}</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<LoadingIcon v-if="loading" class="animate-spin -ml-1 mr-3 h-5 w-5" />
|
||||
<span v-if="loading">发送中...</span>
|
||||
<span v-else>发送消息</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 地图区域 -->
|
||||
<section class="py-16 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-4">找到我们</h2>
|
||||
<p class="text-gray-600">我们位于深圳市南山区,交通便利,欢迎您的到访</p>
|
||||
</div>
|
||||
<div class="bg-gray-200 rounded-lg h-96 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<MapIcon class="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<p class="text-gray-600">地图加载中...</p>
|
||||
<p class="text-sm text-gray-500 mt-2">深圳市南山区科技园南区深圳湾科技生态园</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 图标组件
|
||||
const LocationIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>`
|
||||
}
|
||||
|
||||
const PhoneIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path></svg>`
|
||||
}
|
||||
|
||||
const EmailIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>`
|
||||
}
|
||||
|
||||
const MapIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"></path></svg>`
|
||||
}
|
||||
|
||||
const LoadingIcon = {
|
||||
template: `<svg fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
agree: false
|
||||
})
|
||||
|
||||
const errors = ref({})
|
||||
|
||||
// 表单验证
|
||||
const validateForm = () => {
|
||||
errors.value = {}
|
||||
|
||||
if (!form.value.name.trim()) {
|
||||
errors.value.name = '请输入您的姓名'
|
||||
}
|
||||
|
||||
if (!form.value.email.trim()) {
|
||||
errors.value.email = '请输入邮箱地址'
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.value.email)) {
|
||||
errors.value.email = '请输入有效的邮箱地址'
|
||||
}
|
||||
|
||||
if (!form.value.subject) {
|
||||
errors.value.subject = '请选择主题'
|
||||
}
|
||||
|
||||
if (!form.value.message.trim()) {
|
||||
errors.value.message = '请输入消息内容'
|
||||
} else if (form.value.message.trim().length < 10) {
|
||||
errors.value.message = '消息内容至少需要10个字符'
|
||||
}
|
||||
|
||||
if (!form.value.agree) {
|
||||
errors.value.agree = '请同意隐私政策和服务条款'
|
||||
}
|
||||
|
||||
return Object.keys(errors.value).length === 0
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const submitForm = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 这里应该调用API发送消息
|
||||
console.log('发送消息:', form.value)
|
||||
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
alert('消息发送成功!我们会尽快回复您。')
|
||||
|
||||
// 重置表单
|
||||
form.value = {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
agree: false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送失败', error)
|
||||
alert('发送失败,请稍后重试。')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-1.lg\:grid-cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.space-y-8 > * + * {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.space-y-6 > * + * {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表单样式优化 */
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
387
src/views/index/index.vue
Normal file
387
src/views/index/index.vue
Normal file
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 英雄区域 -->
|
||||
<section class="bg-gradient-to-br from-blue-600 via-purple-600 to-indigo-800 text-white py-20 md:py-32 relative overflow-hidden">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-6 leading-tight">
|
||||
现代化的
|
||||
<span class="bg-gradient-to-r from-yellow-400 to-orange-500 bg-clip-text text-transparent">
|
||||
内容管理系统
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl mb-8 opacity-90 leading-relaxed">
|
||||
高效、安全、易用的CMS解决方案,助力您的内容创作与管理
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<router-link
|
||||
to="/articles"
|
||||
class="bg-white text-blue-600 px-8 py-4 rounded-lg font-semibold hover:bg-gray-100 transition-all duration-300 transform hover:scale-105 shadow-lg"
|
||||
>
|
||||
浏览文章
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin"
|
||||
class="bg-transparent border-2 border-white text-white px-8 py-4 rounded-lg font-semibold hover:bg-white hover:text-blue-600 transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
管理后台
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 装饰性元素 -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-white opacity-10 rounded-full"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-96 h-96 bg-white opacity-5 rounded-full"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 特性介绍 -->
|
||||
<section class="py-20 bg-white">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||
为什么选择我们的CMS?
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
我们提供完整的内容管理解决方案,让您专注于创作优质内容
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="text-center group">
|
||||
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6 group-hover:bg-blue-200 transition-colors">
|
||||
<RocketIcon class="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">高效管理</h3>
|
||||
<p class="text-gray-600 leading-relaxed">
|
||||
直观的管理界面,让内容创作和管理变得简单高效,提升工作效率
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center group">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6 group-hover:bg-green-200 transition-colors">
|
||||
<ShieldIcon class="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">安全可靠</h3>
|
||||
<p class="text-gray-600 leading-relaxed">
|
||||
企业级安全保障,多层权限控制,确保您的内容和数据安全
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center group">
|
||||
<div class="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-6 group-hover:bg-purple-200 transition-colors">
|
||||
<DeviceIcon class="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">响应式设计</h3>
|
||||
<p class="text-gray-600 leading-relaxed">
|
||||
完美适配各种设备,无论是桌面端还是移动端都有出色的体验
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 统计数据 -->
|
||||
<section class="py-20 bg-gray-50">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl md:text-4xl font-bold text-blue-600 mb-2">{{ stats.articles }}+</div>
|
||||
<div class="text-gray-600">技术文章</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl md:text-4xl font-bold text-green-600 mb-2">{{ stats.users }}+</div>
|
||||
<div class="text-gray-600">注册用户</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl md:text-4xl font-bold text-purple-600 mb-2">{{ stats.views }}+</div>
|
||||
<div class="text-gray-600">总浏览量</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl md:text-4xl font-bold text-orange-600 mb-2">{{ stats.news }}+</div>
|
||||
<div class="text-gray-600">新闻资讯</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 最新文章 -->
|
||||
<section class="py-20 bg-white">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-12">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-2">最新文章</h2>
|
||||
<p class="text-gray-600">探索最新的技术趋势和开发经验</p>
|
||||
</div>
|
||||
<router-link
|
||||
to="/articles"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium flex items-center group"
|
||||
>
|
||||
查看全部
|
||||
<ArrowRightIcon class="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 文章列表 -->
|
||||
<div v-if="featuredArticles.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<article
|
||||
v-for="article in featuredArticles"
|
||||
:key="article.id"
|
||||
class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-all duration-300 group cursor-pointer"
|
||||
@click="goToArticle(article.id)"
|
||||
>
|
||||
<div class="relative overflow-hidden h-48">
|
||||
<img
|
||||
:src="article.cover || 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=400&h=300&fit=crop'"
|
||||
:alt="article.title"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div class="absolute top-4 left-4">
|
||||
<span class="bg-blue-600 text-white px-2 py-1 rounded text-xs font-medium">
|
||||
{{ article.category }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2 line-clamp-2 group-hover:text-blue-600 transition-colors">
|
||||
{{ article.title }}
|
||||
</h3>
|
||||
<p class="text-gray-600 text-sm mb-4 line-clamp-3">
|
||||
{{ article.summary }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{{ formatDate(article.created_at) }}</span>
|
||||
<span>{{ article.views }} 阅读</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<EmptyState
|
||||
v-else
|
||||
title="暂时没有文章"
|
||||
description="敬请期待更多精彩内容"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 最新新闻 -->
|
||||
<section class="py-20 bg-gray-50">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-12">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-2">新闻资讯</h2>
|
||||
<p class="text-gray-600">关注行业动态,掌握最新资讯</p>
|
||||
</div>
|
||||
<router-link
|
||||
to="/news"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium flex items-center group"
|
||||
>
|
||||
查看全部
|
||||
<ArrowRightIcon class="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="featuredNews.length > 0" class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- 主要新闻 -->
|
||||
<div
|
||||
class="relative group cursor-pointer"
|
||||
@click="goToNews(featuredNews[0].id)"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-lg h-64">
|
||||
<img
|
||||
:src="featuredNews[0].cover || 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=400&h=300&fit=crop'"
|
||||
:alt="featuredNews[0].title"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
<div class="absolute bottom-6 left-6 right-6">
|
||||
<span class="bg-red-500 text-white px-2 py-1 rounded text-xs font-medium mb-3 inline-block">
|
||||
热门
|
||||
</span>
|
||||
<h3 class="text-white text-xl font-bold mb-2 line-clamp-2">
|
||||
{{ featuredNews[0].title }}
|
||||
</h3>
|
||||
<p class="text-gray-200 text-sm">
|
||||
{{ formatDate(featuredNews[0].created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他新闻 -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="news in featuredNews.slice(1, 4)"
|
||||
:key="news.id"
|
||||
class="flex space-x-4 group cursor-pointer"
|
||||
@click="goToNews(news.id)"
|
||||
>
|
||||
<img
|
||||
:src="news.cover || 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=400&h=300&fit=crop'"
|
||||
:alt="news.title"
|
||||
class="w-20 h-16 object-cover rounded-lg flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 line-clamp-2 group-hover:text-blue-600 transition-colors">
|
||||
{{ news.title }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ formatDate(news.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<EmptyState
|
||||
v-else
|
||||
title="暂时没有新闻"
|
||||
description="敬请期待更多精彩内容"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 图标组件
|
||||
const RocketIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>`
|
||||
}
|
||||
|
||||
const ShieldIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>`
|
||||
}
|
||||
|
||||
const DeviceIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>`
|
||||
}
|
||||
|
||||
const ArrowRightIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const stats = ref({
|
||||
articles: 128,
|
||||
users: 1024,
|
||||
views: 50000,
|
||||
news: 86
|
||||
})
|
||||
|
||||
const featuredArticles = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: 'Vue 3 Composition API 深度解析',
|
||||
summary: '详细介绍Vue 3中Composition API的使用方法和最佳实践,帮助开发者更好地理解和应用这一新特性。',
|
||||
cover: 'https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=400&h=300&fit=crop',
|
||||
category: 'Vue.js',
|
||||
views: 1234,
|
||||
created_at: '2024-01-15T10:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'React 18 新特性全面解读',
|
||||
summary: 'React 18带来了许多激动人心的新特性,包括并发渲染、自动批处理等,让我们一起探索这些新功能。',
|
||||
cover: 'https://images.unsplash.com/photo-1633356122102-3fe601e05bd2?w=400&h=300&fit=crop',
|
||||
category: 'React',
|
||||
views: 987,
|
||||
created_at: '2024-01-14T15:20:00Z'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'TypeScript 高级类型系统详解',
|
||||
summary: '深入理解TypeScript的高级类型系统,掌握泛型、条件类型、映射类型等高级特性的使用技巧。',
|
||||
cover: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=400&h=300&fit=crop',
|
||||
category: 'TypeScript',
|
||||
views: 756,
|
||||
created_at: '2024-01-13T09:45:00Z'
|
||||
}
|
||||
])
|
||||
|
||||
const featuredNews = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '2024年前端技术发展趋势分析',
|
||||
summary: '深入分析2024年前端技术的发展方向,包括框架演进、工具链优化和新兴技术应用。',
|
||||
cover: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=500&h=300&fit=crop',
|
||||
views: 2345,
|
||||
created_at: '2024-01-16T08:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '公司获得年度最佳创新产品奖',
|
||||
summary: '我们的CMS系统在行业评选中脱颖而出,获得年度最佳创新产品奖。',
|
||||
cover: 'https://images.unsplash.com/photo-1559136555-9303baea8ebd?w=400&h=300&fit=crop',
|
||||
views: 1876,
|
||||
created_at: '2024-01-15T14:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Vue.js 3.4 正式发布',
|
||||
summary: 'Vue.js 团队发布了3.4版本,在编译器优化和运行时性能方面有显著改进。',
|
||||
cover: 'https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=400&h=300&fit=crop',
|
||||
views: 1543,
|
||||
created_at: '2024-01-14T11:15:00Z'
|
||||
}
|
||||
])
|
||||
|
||||
// 方法
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const goToArticle = (id) => {
|
||||
router.push(`/articles/${id}`)
|
||||
}
|
||||
|
||||
const goToNews = (id) => {
|
||||
router.push(`/news/${id}`)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 这里可以调用API获取真实数据
|
||||
console.log('首页已加载')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
394
src/views/news/detail.vue
Normal file
394
src/views/news/detail.vue
Normal file
@@ -0,0 +1,394 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div v-if="loading" class="flex items-center justify-center min-h-screen">
|
||||
<LoadingIcon class="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!news" class="flex items-center justify-center min-h-screen">
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">新闻不存在</h2>
|
||||
<p class="text-gray-600 mb-8">抱歉,您访问的新闻不存在或已被删除。</p>
|
||||
<router-link
|
||||
to="/news"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
返回新闻列表
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article v-else class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- 面包屑导航 -->
|
||||
<nav class="flex mb-8" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
||||
<li class="inline-flex items-center">
|
||||
<router-link to="/" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600">
|
||||
<HomeIcon class="w-4 h-4 mr-2" />
|
||||
首页
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<ChevronRightIcon class="w-4 h-4 text-gray-400" />
|
||||
<router-link to="/news" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2">
|
||||
新闻资讯
|
||||
</router-link>
|
||||
</div>
|
||||
</li>
|
||||
<li aria-current="page">
|
||||
<div class="flex items-center">
|
||||
<ChevronRightIcon class="w-4 h-4 text-gray-400" />
|
||||
<span class="ml-1 text-sm font-medium text-gray-500 md:ml-2 truncate">
|
||||
{{ news.title }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- 新闻头部 -->
|
||||
<header class="bg-white rounded-lg shadow-sm p-8 mb-8">
|
||||
<div class="mb-4">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium',
|
||||
getCategoryClass(news.category)
|
||||
]"
|
||||
>
|
||||
{{ getCategoryName(news.category) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
{{ news.title }}
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-6 text-sm text-gray-500 mb-6">
|
||||
<div class="flex items-center">
|
||||
<UserIcon class="w-4 h-4 mr-2" />
|
||||
<span>{{ news.author || '管理员' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<CalendarIcon class="w-4 h-4 mr-2" />
|
||||
<span>{{ formatDate(news.publishedAt) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<EyeIcon class="w-4 h-4 mr-2" />
|
||||
<span>{{ news.views || 0 }} 次浏览</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 摘要 -->
|
||||
<div v-if="news.summary" class="bg-blue-50 border-l-4 border-blue-400 p-4 rounded-r-lg">
|
||||
<p class="text-blue-800 font-medium">{{ news.summary }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 新闻内容 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-8 mb-8">
|
||||
<!-- 特色图片 -->
|
||||
<div v-if="news.image" class="mb-8">
|
||||
<img
|
||||
:src="news.image"
|
||||
:alt="news.title"
|
||||
class="w-full h-64 md:h-96 object-cover rounded-lg shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 正文内容 -->
|
||||
<div class="prose prose-lg max-w-none">
|
||||
<div v-html="news.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相关新闻 -->
|
||||
<div v-if="relatedNews.length > 0" class="bg-white rounded-lg shadow-sm p-8">
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-6">相关新闻</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<article
|
||||
v-for="item in relatedNews"
|
||||
:key="item.id"
|
||||
class="flex gap-4 p-4 rounded-lg border border-gray-200 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer"
|
||||
@click="goToNews(item.id)"
|
||||
>
|
||||
<img
|
||||
:src="item.image || '/placeholder.svg?height=80&width=120'"
|
||||
:alt="item.title"
|
||||
class="w-20 h-20 object-cover rounded-lg flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-lg font-semibold text-gray-900 mb-2 line-clamp-2">
|
||||
{{ item.title }}
|
||||
</h4>
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<CalendarIcon class="w-4 h-4 mr-1" />
|
||||
<span>{{ formatDate(item.publishedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { newsAPI } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 图标组件
|
||||
const LoadingIcon = {
|
||||
template: `<svg fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>`
|
||||
}
|
||||
|
||||
const HomeIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path></svg>`
|
||||
}
|
||||
|
||||
const ChevronRightIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>`
|
||||
}
|
||||
|
||||
const UserIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>`
|
||||
}
|
||||
|
||||
const CalendarIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>`
|
||||
}
|
||||
|
||||
const EyeIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(true)
|
||||
const news = ref(null)
|
||||
const relatedNews = ref([])
|
||||
|
||||
// 模拟新闻数据
|
||||
const mockNewsData = {
|
||||
1: {
|
||||
id: 1,
|
||||
title: '公司成功获得ISO9001质量管理体系认证',
|
||||
summary: '经过严格的审核流程,我们公司正式获得ISO9001质量管理体系认证,这标志着我们在质量管理方面达到了国际先进水平。',
|
||||
content: `
|
||||
<p>经过为期三个月的严格审核流程,我们公司正式获得了ISO9001质量管理体系认证。这一重要里程碑标志着我们在质量管理方面达到了国际先进水平,为公司的可持续发展奠定了坚实基础。</p>
|
||||
|
||||
<h3>认证过程</h3>
|
||||
<p>ISO9001认证过程包括了文件审核、现场审核等多个环节。审核专家对我们的质量管理体系进行了全面评估,涵盖了从产品设计、生产制造到售后服务的全过程。</p>
|
||||
|
||||
<h3>认证意义</h3>
|
||||
<p>获得ISO9001认证不仅是对我们质量管理水平的权威认可,更是我们持续改进、追求卓越的重要动力。这将有助于:</p>
|
||||
<ul>
|
||||
<li>提升产品和服务质量</li>
|
||||
<li>增强客户信任和满意度</li>
|
||||
<li>提高运营效率</li>
|
||||
<li>增强市场竞争力</li>
|
||||
</ul>
|
||||
|
||||
<h3>未来展望</h3>
|
||||
<p>我们将以此次认证为新起点,继续完善质量管理体系,不断提升产品和服务质量,为客户创造更大价值。</p>
|
||||
`,
|
||||
category: 'company',
|
||||
author: '管理员',
|
||||
publishedAt: '2024-01-15',
|
||||
views: 1250,
|
||||
image: 'https://images.unsplash.com/photo-1560472354-b33ff0c44a43?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2126&q=80'
|
||||
},
|
||||
2: {
|
||||
id: 2,
|
||||
title: '行业数字化转型趋势分析报告发布',
|
||||
summary: '我们发布了最新的行业数字化转型趋势分析报告,深入分析了当前市场的发展趋势和未来机遇。',
|
||||
content: `
|
||||
<p>随着数字技术的快速发展,各行各业都在经历着深刻的数字化转型。我们的研究团队经过深入调研和分析,发布了这份行业数字化转型趋势分析报告。</p>
|
||||
|
||||
<h3>主要发现</h3>
|
||||
<p>报告显示,数字化转型已成为企业发展的必然趋势,主要体现在以下几个方面:</p>
|
||||
<ul>
|
||||
<li>云计算技术的广泛应用</li>
|
||||
<li>人工智能在业务流程中的深度融合</li>
|
||||
<li>数据驱动决策成为主流</li>
|
||||
<li>移动办公模式的普及</li>
|
||||
</ul>
|
||||
|
||||
<h3>发展机遇</h3>
|
||||
<p>数字化转型为企业带来了前所未有的发展机遇,包括提高运营效率、优化客户体验、创新商业模式等。</p>
|
||||
|
||||
<h3>挑战与应对</h3>
|
||||
<p>同时,数字化转型也面临着技术复杂性、人才短缺、安全风险等挑战。企业需要制定合适的策略来应对这些挑战。</p>
|
||||
`,
|
||||
category: 'industry',
|
||||
author: '研究部',
|
||||
publishedAt: '2024-01-14',
|
||||
views: 890,
|
||||
image: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2070&q=80'
|
||||
}
|
||||
}
|
||||
|
||||
// 相关新闻数据
|
||||
const mockRelatedNews = [
|
||||
{
|
||||
id: 3,
|
||||
title: '新产品发布会将于下月举行',
|
||||
publishedAt: '2024-01-13',
|
||||
image: 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2070&q=80'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '技术团队分享最新开发经验',
|
||||
publishedAt: '2024-01-12',
|
||||
image: 'https://images.unsplash.com/photo-1517077304055-6e89abbf09b0?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2069&q=80'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取分类名称
|
||||
const getCategoryName = (category) => {
|
||||
const categoryMap = {
|
||||
company: '公司新闻',
|
||||
industry: '行业动态',
|
||||
product: '产品资讯',
|
||||
technology: '技术分享'
|
||||
}
|
||||
return categoryMap[category] || '其他'
|
||||
}
|
||||
|
||||
// 获取分类样式
|
||||
const getCategoryClass = (category) => {
|
||||
const classes = {
|
||||
company: 'bg-blue-100 text-blue-800',
|
||||
industry: 'bg-green-100 text-green-800',
|
||||
product: 'bg-purple-100 text-purple-800',
|
||||
technology: 'bg-orange-100 text-orange-800'
|
||||
}
|
||||
return classes[category] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取新闻详情
|
||||
const fetchNewsDetail = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const newsId = route.params.id
|
||||
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
news.value = mockNewsData[newsId] || null
|
||||
|
||||
if (news.value) {
|
||||
// 增加浏览量
|
||||
news.value.views = (news.value.views || 0) + 1
|
||||
|
||||
// 获取相关新闻
|
||||
relatedNews.value = mockRelatedNews
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取新闻详情失败:', error)
|
||||
news.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到其他新闻
|
||||
const goToNews = (id) => {
|
||||
router.push(`/news/${id}`)
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchNewsDetail()
|
||||
})
|
||||
|
||||
// 监听路由变化
|
||||
import { watch } from 'vue'
|
||||
watch(() => route.params.id, () => {
|
||||
if (route.params.id) {
|
||||
fetchNewsDetail()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 文本截断 */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 文章内容样式 */
|
||||
.prose {
|
||||
color: #374151;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
margin: 1.25rem 0;
|
||||
padding-left: 1.625rem;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-1.md\:grid-cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.text-3xl.md\:text-4xl {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.flex-wrap {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.gap-6 {
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
493
src/views/news/index.vue
Normal file
493
src/views/news/index.vue
Normal file
@@ -0,0 +1,493 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- 页面头部 -->
|
||||
<div class="bg-white shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">新闻资讯</h1>
|
||||
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
了解最新的行业动态和公司资讯
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新闻分类导航 -->
|
||||
<div class="bg-white border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<nav class="flex space-x-8 overflow-x-auto py-4">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
@click="activeCategory = category.key"
|
||||
:class="[
|
||||
'whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeCategory === category.key
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
]"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
placeholder="搜索新闻标题或内容..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
@keyup.enter="searchNews"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="searchNews"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
<button
|
||||
@click="resetSearch"
|
||||
class="bg-gray-500 hover:bg-gray-600 text-white px-6 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新闻列表 -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<LoadingIcon class="w-8 h-8 animate-spin mx-auto mb-4 text-blue-600" />
|
||||
<p class="text-gray-600">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="newsList.length === 0" class="text-center py-12">
|
||||
<EmptyState />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<article
|
||||
v-for="news in newsList"
|
||||
:key="news.id"
|
||||
class="bg-white rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition-shadow cursor-pointer"
|
||||
@click="goToDetail(news.id)"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="news.image || 'https://images.unsplash.com/photo-1504711434969-e33886168f5c?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2070&q=80'"
|
||||
:alt="news.title"
|
||||
class="w-full h-48 object-cover"
|
||||
/>
|
||||
<div class="absolute top-4 left-4">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
getCategoryClass(news.category)
|
||||
]"
|
||||
>
|
||||
{{ getCategoryName(news.category) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-3 line-clamp-2">
|
||||
{{ news.title }}
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-4 line-clamp-3">
|
||||
{{ news.summary || news.content }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<UserIcon class="w-4 h-4 mr-2" />
|
||||
<span>{{ news.author || '管理员' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<CalendarIcon class="w-4 h-4 mr-2" />
|
||||
<span>{{ formatDate(news.publishedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<EyeIcon class="w-4 h-4 mr-1" />
|
||||
<span>{{ news.views || 0 }} 次浏览</span>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="goToDetail(news.id)"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium text-sm"
|
||||
>
|
||||
阅读更多 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="newsList.length > 0" class="mt-12 flex justify-center">
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
@click="changePage(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="page in getPageNumbers()"
|
||||
:key="page"
|
||||
@click="changePage(page)"
|
||||
:class="[
|
||||
'relative inline-flex items-center px-4 py-2 border text-sm font-medium',
|
||||
page === currentPage
|
||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="changePage(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { newsAPI } from '@/api'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 图标组件
|
||||
const LoadingIcon = {
|
||||
template: `<svg fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>`
|
||||
}
|
||||
|
||||
const UserIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>`
|
||||
}
|
||||
|
||||
const CalendarIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>`
|
||||
}
|
||||
|
||||
const EyeIcon = {
|
||||
template: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>`
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const newsList = ref([])
|
||||
const searchKeyword = ref('')
|
||||
const activeCategory = ref('all')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(9)
|
||||
const total = ref(0)
|
||||
|
||||
// 新闻分类
|
||||
const categories = ref([
|
||||
{ key: 'all', name: '全部' },
|
||||
{ key: 'company', name: '公司新闻' },
|
||||
{ key: 'industry', name: '行业动态' },
|
||||
{ key: 'product', name: '产品资讯' },
|
||||
{ key: 'technology', name: '技术分享' }
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
|
||||
|
||||
// 模拟新闻数据
|
||||
const mockNews = [
|
||||
{
|
||||
id: 1,
|
||||
title: '公司成功获得ISO9001质量管理体系认证',
|
||||
summary: '经过严格的审核流程,我们公司正式获得ISO9001质量管理体系认证,这标志着我们在质量管理方面达到了国际先进水平。',
|
||||
content: '经过严格的审核流程,我们公司正式获得ISO9001质量管理体系认证...',
|
||||
category: 'company',
|
||||
author: '管理员',
|
||||
publishedAt: '2024-01-15',
|
||||
views: 1250,
|
||||
image: 'https://images.unsplash.com/photo-1560472354-b33ff0c44a43?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2126&q=80'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '行业数字化转型趋势分析报告发布',
|
||||
summary: '我们发布了最新的行业数字化转型趋势分析报告,深入分析了当前市场的发展趋势和未来机遇。',
|
||||
content: '我们发布了最新的行业数字化转型趋势分析报告...',
|
||||
category: 'industry',
|
||||
author: '研究部',
|
||||
publishedAt: '2024-01-14',
|
||||
views: 890,
|
||||
image: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2070&q=80'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '新产品发布会将于下月举行',
|
||||
summary: '我们将在下个月举办新产品发布会,届时将展示我们最新的技术成果和产品创新。',
|
||||
content: '我们将在下个月举办新产品发布会...',
|
||||
category: 'product',
|
||||
author: '市场部',
|
||||
publishedAt: '2024-01-13',
|
||||
views: 654,
|
||||
image: 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2070&q=80'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '技术团队分享最新开发经验',
|
||||
summary: '我们的技术团队在最新的技术分享会上,分享了在项目开发过程中积累的宝贵经验和最佳实践。',
|
||||
content: '我们的技术团队在最新的技术分享会上...',
|
||||
category: 'technology',
|
||||
author: '技术部',
|
||||
publishedAt: '2024-01-12',
|
||||
views: 432,
|
||||
image: 'https://images.unsplash.com/photo-1517077304055-6e89abbf09b0?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2069&q=80'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '公司参加国际技术展览会获得好评',
|
||||
summary: '我们公司参加了国际技术展览会,展示的创新产品和解决方案获得了业界的广泛好评和认可。',
|
||||
content: '我们公司参加了国际技术展览会...',
|
||||
category: 'company',
|
||||
author: '市场部',
|
||||
publishedAt: '2024-01-11',
|
||||
views: 789,
|
||||
image: 'https://images.unsplash.com/photo-1505373877841-8d25f7d46678?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2012&q=80'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: '行业合作伙伴关系进一步深化',
|
||||
summary: '我们与多家行业领先企业建立了更深层次的合作关系,共同推动行业技术创新和发展。',
|
||||
content: '我们与多家行业领先企业建立了更深层次的合作关系...',
|
||||
category: 'industry',
|
||||
author: '商务部',
|
||||
publishedAt: '2024-01-10',
|
||||
views: 567,
|
||||
image: 'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2084&q=80'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取分类名称
|
||||
const getCategoryName = (category) => {
|
||||
const categoryItem = categories.value.find(cat => cat.key === category)
|
||||
return categoryItem ? categoryItem.name : '其他'
|
||||
}
|
||||
|
||||
// 获取分类样式
|
||||
const getCategoryClass = (category) => {
|
||||
const classes = {
|
||||
company: 'bg-blue-100 text-blue-800',
|
||||
industry: 'bg-green-100 text-green-800',
|
||||
product: 'bg-purple-100 text-purple-800',
|
||||
technology: 'bg-orange-100 text-orange-800'
|
||||
}
|
||||
return classes[category] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取新闻列表
|
||||
const fetchNews = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 这里应该调用真实的API
|
||||
// const response = await newsAPI.getList({
|
||||
// page: currentPage.value,
|
||||
// pageSize: pageSize.value,
|
||||
// category: activeCategory.value === 'all' ? '' : activeCategory.value,
|
||||
// keyword: searchKeyword.value
|
||||
// })
|
||||
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
let filteredNews = [...mockNews]
|
||||
|
||||
// 按分类筛选
|
||||
if (activeCategory.value !== 'all') {
|
||||
filteredNews = filteredNews.filter(news => news.category === activeCategory.value)
|
||||
}
|
||||
|
||||
// 按关键词搜索
|
||||
if (searchKeyword.value.trim()) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
filteredNews = filteredNews.filter(news =>
|
||||
news.title.toLowerCase().includes(keyword) ||
|
||||
news.summary.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
total.value = filteredNews.length
|
||||
|
||||
// 分页
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
newsList.value = filteredNews.slice(start, end)
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取新闻列表失败:', error)
|
||||
newsList.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索新闻
|
||||
const searchNews = () => {
|
||||
currentPage.value = 1
|
||||
fetchNews()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchKeyword.value = ''
|
||||
activeCategory.value = 'all'
|
||||
currentPage.value = 1
|
||||
fetchNews()
|
||||
}
|
||||
|
||||
// 切换页码
|
||||
const changePage = (page) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
fetchNews()
|
||||
// 滚动到顶部
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
// 获取页码数组
|
||||
const getPageNumbers = () => {
|
||||
const pages = []
|
||||
const total = totalPages.value
|
||||
const current = currentPage.value
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
if (current <= 4) {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
} else if (current >= total - 3) {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = total - 4; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = current - 1; i <= current + 1; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
pages.push('...')
|
||||
pages.push(total)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
// 跳转到详情页
|
||||
const goToDetail = (id) => {
|
||||
router.push(`/news/${id}`)
|
||||
}
|
||||
|
||||
// 监听分类变化
|
||||
const handleCategoryChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchNews()
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchNews()
|
||||
})
|
||||
|
||||
// 监听分类变化
|
||||
import { watch } from 'vue'
|
||||
watch(activeCategory, handleCategoryChange)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 文本截断 */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-1.md\:grid-cols-2.lg\:grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.flex-col.md\:flex-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.space-x-8 {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.space-x-8 > * + * {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
28
vite.config.js
Normal file
28
vite.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 18888,
|
||||
proxy: {
|
||||
'/api': {
|
||||
// target: 'http://127.0.0.1:18007/api/',
|
||||
target: 'http://127.0.0.1:18001/api/',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user