前端样式第一版已完成

This commit is contained in:
2025-07-29 12:43:09 +08:00
commit 4b2d0a6e94
62 changed files with 10693 additions and 0 deletions

1
.git_disabled/HEAD Normal file
View File

@@ -0,0 +1 @@
ref: refs/heads/main

13
.git_disabled/config Normal file
View 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

View File

@@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View 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+"$@"}
:

View 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
}

View 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;
}

View 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

View 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+"$@"}
:

View 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 --

View 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"
:

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Binary file not shown.

View 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]
# *~

View File

@@ -0,0 +1,2 @@
# pack-refs with: peeled fully-peeled sorted
67f9f5181634c2564201942f1e042c74d4759f44 refs/remotes/origin/main

View File

@@ -0,0 +1 @@
67f9f5181634c2564201942f1e042c74d4759f44

View File

@@ -0,0 +1 @@
ref: refs/remotes/origin/main

24
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

214
README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

1
public/vite.svg Normal file
View 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
View File

@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
<router-view />
</template>
<style lang='scss' scoped>
</style>

290
src/api/index.js Normal file
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
@import "tailwindcss";

302
src/views/about/index.vue Normal file
View 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>

View 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">支持 JPGPNG 格式</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>

View 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>

View 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
View 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>

View 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
View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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/, '')
}
}
}
})