从centos7迁移到ubuntu,记录一下

sudo apt update
sudo apt upgrade
#这里是安装跟之前一样版本的数据库,按自己的来
sudo apt install mysql-server
systemctl statu mysql
systemctl status mysql
sudo apt install php7.4
#安装依赖
sudo apt install php-mysql
php -m | grep mysqli
php -m | grep pdo
sudo apt install nginx
#装完发现nginx起不来,php自带了apache2,端口冲突了,停了apache2换成nginx
sudo systemctl stop apache2
sudo systemctl disable apache2
sudo systemctl start nginx
sudo systemctl status nginx
#安装nginx的依赖
sudo apt install php-fpm
vi /etc/nginx/sites-available/default
#修改nginx的配置,如果配置了伪静态要改成我这样
#其他拦截规则、日志等配置请按需配置
#nginx默认14天切一次日志,如果要改去logrotate里面nginx配置文件修改
location / {
    try_files $uri $uri/ /index.php$is_args$args;
}


# 处理 PHP 文件
location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;  # 根据你的 PHP 版本修改路径
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
}

location ~ /\.ht {
    deny all;
}

sudo nginx -t
sudo systemctl reload nginx
sudo systemctl enable nginx

#可以在/var/www/html下创建个info.php来看下有没有正常提供服务,不过看完记得删
#成功切换应该看到$_SERVER['SERVER_SOFTWARE']    nginx/xxx
#从原服务器备份数据库,在新服务器创建完成创建数据库、创建用户、权限分配、导入
mysql -u root -p
CREATE DATABASE 库;
CREATE USER '用户'@'localhost' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON 库.* TO '用户'@'localhost';
FLUSH PRIVILEGES;

sudo apt install php-mbstring
sudo systemctl restart php7.4-fpm  # 其他版本根据实际情况修改
sudo systemctl restart nginx
#下载解压typecho https://docs.typecho.org/install
#因为是迁移,要注意目录和原来保持一致,修改nginx root
#如果nginx工作进程是以www-data启动的,要修改目录属主
sudo chown -R www-data:www-data /目录/uploads
#访问ip/install.php,进行安装
#安装完成后访问ip,如果能够正常显示博客内容则表示成功迁移了
#迁移原来的图片,放在相同目录下

#现在来配置https,用的let's encrypt
#在签名之前请先修改dns解析记录,不然会签名失败
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d xxx.site -d www.xxx.site
#在申请过程中可以直接选择要不要强制跳转https,选了会自动改nginx,如果没选得手动改
#配置完成后certbot可以自动续签证书
#查看签名信息
sudo certbot certificates
#certbot日志
sudo less /var/log/letsencrypt/letsencrypt.log
#查看证书
sudo openssl x509 -in /etc/letsencrypt/live/xxx/fullchain.pem -text -noout

关闭所有文章评论区的方法:

UPDATE `xxx_contents` 
SET `allowComment` = '0' 
WHERE `type` = 'post';

linux

三次握手

1.jpg

Client调用connect(),向server发送syn,如果出现丢包现象,client会重传syn,具体重传次数由tcp_syn_retries决定。同理还有控制syn+ack包重传次数的tcp_synack_retries

cat /proc/sys/net/ipv4/tcp_syn_retries
6

cat /proc/sys/net/ipv4/tcp_synack_retries
5

要注意的是,每一次重传所等待的时间都是上一次的两倍,tcp_syn_retries默认值为6,假设第一次等待1s,这代表要等待1+2+4+8+16+32+64=127秒后才会Timeout,这会导致大量的堵塞,建议调小

服务器收到SYN但还没回复SYN+ACK时,会去创建一个半连接,放在syn queue中,如果服务器的半连接数已经达到上限,新的半连接会被丢弃,所以服务器不回应也可能和此有关,可以适当调大
半连接上限控制的参数为

cat /proc/sys/net/ipv4/tcp_max_syn_backlog
4096

同理还有全连接上限的控制

cat /proc/sys/net/core/somaxconn
4096

半连接积压过多,也可能和遭受SYN Flood攻击导致,可以开启SYN Cookie机制,在收到SYN的时候不分配资源,根据SYN包计算出一个Cookie随着SYN+ACK一起返回回去,等收到ACK包而且Cookie没问题才分配资源创建链接
net.ipv4.tcp_syncookies = 1

全连接满了后,linux会丢弃新建的全连接,丢弃可以选择是否要通知对方reset,默认是不通知,也建议不通知,这样可以让对方进行重试

cat /proc/sys/net/ipv4/tcp_abort_on_overflow
0

使用这条指令可以看当前所有连接统计数据
netstat -an | awk '/^tcp/ {++s[$NF]} END {for(a in s) print a, s[a]}'
查看半连接、全连接数、当前的全连接队列

netstat -natp | grep SYN_RECV | wc -l
netstat -natp | grep ESTABLISHED | wc -l
ss -lnt |grep port

3.png

四次挥手

2.png
一方发起FIN包,另一方收到后,如果数据已经处理完了,会回复ACK+FIN包,如果没处理完,会先ACK,等完成了再FIN,这时发起方回复ACK,自己断开。而在收到对方ACK但还没收到FIN包这期间,也就是FIN_WAIT_2,如果过长时间没收到对方的消息也会自动断开,这个时间也由参数控制,默认60s,可以改短避免浪费资源

cat /proc/sys/net/ipv4/tcp_fin_timeout
60

同理还有Time_wait,默认也为60s,这个要通过sysctl去修改

sudo vim /etc/sysctl.conf
net.ipv4.tcp_fin_timeout = 5

sudo sysctl -p

https://andyx.net/modifying_linux_kernel_parameters_to_reduce_time-wait_connection/

处于time_wait状态的连接可以被复用,这样就不用浪费资源再开一个连接了,也在一定程度上解决端口有限问题,对客户端一方有效。注意还有个recycle的参数,那个不建议开,会引起NAT丢包

cat /proc/sys/net/ipv4/tcp_tw_reuse
2

+ 0 - disable
+ 1 - global enable
+ 2 - enable for loopback traffic only

容器网络ns

容器使用Network namespace来进行网络资源的隔离,具体隔离的有以下几种资源:

  1. 网络设备,如lo,eth0,可通过ip link查看
  2. TCP和UDP的协议栈
  3. 路由表,可用ip route查看
  4. 防火墙规则,iptables
  5. 网络状态信息,一般在/proc/net和/sys/class/net

使用lsns -t net可以查看当前设备上的网络ns,使用nsenter进入
因为ns的缘故,直接去修改宿主机的部分内核参数,是无法在容器里起作用的。而容器中这些参数是只读的,无法修改,如果要修改必须通过docker或者k8s来去修改runC的接口,docker是docker –sysctl,k8s是allowed-unsafe-sysctls

docker run -d --name net_para --sysctl net.ipv4.tcp_keepalive_time=600 centos:8.1.1911 sleep 3600
7efed88a44d64400ff5a6d38fdcc73f2a74a7bdc3dbc7161060f2f7d0be170d1
docker exec net_para cat /proc/sys/net/ipv4/tcp_keepalive_time
600

实操技巧

进入容器网络ns

kubectl get pods xxx -o wide获取node名
登录node
获取容器进程id:
docker:
docker ps| grep $pod
docker inspect -f {{.State.Pid}} 容器id
containerd:
crictl ps | grep podname
crictl inspect 容器id | jq '.info.pid'
进入容器的网络命名空间
nsenter --target pid -n

筛选抓取

netstat -ant查看当前开放的端口
https://www.baeldung.com/linux/tcpdump-capture-ssl-handshake

偶发性问题抓包:循环抓包
指定抓包文件数,按单个文件的大小或者抓取的时长进行切割,超过指定生成的文件数之后循环覆盖旧文件
示例:
-W 个数:生成的循环文件数量,生成到最大数量后,新的报文数据会覆盖写入第一个文件
-C 尺寸:每个文件的大小,单位是 MB
-G 间隔时间:每次新生成文件的间隔时间,单位是分钟
每 100MB 或者 60 分钟就生成一个文件,一共 10 个文件
tcpdump -i eth0 -w file.pcap -W 10 -C 100 -G 60

wireshark筛选:
ip筛选 ip.addr ip.src ip.dst eq xxx
日期筛选 frame.time > "feb 01, 2024" and frame.time < "mar 01, 2024 00:00:00"
标志位筛选 tcp.flags.rst eq 1
长度筛选 tcp.len eq xx
报文模糊筛选 tcp.payload contains xxx

k8s网络

登录到节点,lsns -t net可以查看到当前节点上的网络命名空间,其中有一些看到是pause,这些是容器的sandbox,k8s使用sandbox容器来创建和维持命名空间,他也是进程空间中的pid 1

lsns -t net
        NS TYPE NPROCS     PID USER     NETNSID NSFS                                                COMMAND
4026531992 net     131       1 root  unassigned                                                     /sbin/init
4026532308 net       2    2451 65535          0 /run/netns/cni-d612c07a-e8bf-969a-a2c5-2392a222533e /pause
4026532382 net       4    2622 65535          1 /run/netns/cni-c122169f-f7f0-03b3-1aa0-7ba3a07a3317 /pause
4026532447 net       2    2722 65535          2 /run/netns/cni-723e1b9c-0355-15e1-a4a0-f7926de9c3b2 /pause
4026532515 net       1 2797456 uuidd unassigned                                                     /usr/sbin/uuidd -
4026532575 net       5 2080352 65535          3 /run/netns/cni-6c23adba-e86b-87c1-bd3a-b7be2b18fda9 /pause

获取到pid后也可以直接使用ip来获取和进入对应的命名空间

ip netns identify 2080411
cni-6c23adba-e86b-87c1-bd3a-b7be2b18fda9
ip netns exec cni-6c23adba-e86b-87c1-bd3a-b7be2b18fda9 ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: eth0@if9: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UP group default
    link/ether b6:a4:e2:bc:5a:10 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.244.0.9/24 brd 10.244.0.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::b4a4:e2ff:febc:5a10/64 scope link
       valid_lft forever preferred_lft forever

同理可以查看其他命名空间,如下例

crictl ps | grep pod-1
8453bb79c5a3f       92b11f67642b6       3 hours ago         Running             nginx                     0                   576ce2a9d00c7       pod-1
crictl inspect 8453bb79c5a3f  | jq '.info.pid'
2080411
lsns -t pid | grep 2080411
4026532639 pid       4 2080411 root  nginx: master process nginx -g daemon off;

从上面的信息我们可以看到,容器的虚拟网卡为eth0,被链接在9号接口上,ip为10.244.0.9
我们来看下这个虚拟网卡连在哪

ip link | grep -A1 ^9
9: vethed920513@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue master cni0 state UP mode DEFAULT group default
    link/ether be:99:98:76:2a:42 brd ff:ff:ff:ff:ff:ff link-netns cni-6c23adba-e86b-87c1-bd3a-b7be2b18fda9

可以看到,这块网卡被连在了网桥cni0上面
网桥工作在数据链路层,可以连接多个网段,当有请求进来时,网桥会去所有接口询问是否有认识原始ip的,如果有,发送到那个veth,然后veth发给eth0(容器默认虚拟网卡一般都是eth0)
网卡和veth绑定关系还可以使用brctl来查看(需要安装

brctl show
bridge name     bridge id               STP enabled     interfaces
cni0            8000.e6f9e02baef7       no              veth8d55a711
                                                        veth92b8f630
                                                        vethcbd8b3fe
                                                        vethed920513

关于网络容器接口CNI可以看这篇介绍文章
https://atbug.com/deep-dive-cni-spec/

同node通信:
pod从自己的eth0发出请求,到达veth,veth通过网桥cni0和其他veth相连,cni0问所有接口认不认识ip,如果有认识的,记录映射关系并转发到该veth,veth再把信息发给eth0

跨node通信:
跨node通信的方式取决于使用的网络插件

额外篇:关于创建VPC和集群时需要考虑的网络问题
https://help.aliyun.com/zh/ack/ack-managed-and-ack-dedicated/user-guide/plan-cidr-blocks-for-an-ack-cluster-2#2e587ee4f46oq

flannel

flannel在安装的时候就会指定CIDR以及和后端类型,可以查看某个node具体所属的网段

kubectl get no nodename -o jsonpath={.spec} | jq
{
  "podCIDR": "10.244.0.0/24",
  "podCIDRs": [
    "10.244.0.0/24"
  ]
}

flannel接收cni-conf.json,输出结果并交给bridge

cat /var/lib/cni/flannel/2fc679ceaa17e33115657688a9b859e89a66c8911bcd9134fad2327830519396 | jq
{
  "cniVersion": "0.3.1",
  "hairpinMode": true,
  "ipMasq": false,
  "ipam": {
    "ranges": [
      [
        {
          "subnet": "10.244.0.0/24"
        }
      ]
    ],
    "routes": [
      {
        "dst": "10.244.0.0/16"
      }
    ],
    "type": "host-local"
  },
  "isDefaultGateway": true,
  "isGateway": true,
  "mtu": 1450,
  "name": "cbr0",
  "type": "bridge"
}

bridge 使用上面的输出连同参数一起作为输入,根据配置完成如下操作:

  1. 创建网桥 cni0(节点的根网络命名空间)
  2. 创建容器网络接口 eth0( pod 网络命名空间)
  3. 创建主机上的虚拟网络接口 vethX(节点的根网络命名空间)
  4. 将 vethX 连接到网桥 cni0
  5. 委托 ipam 插件分配 IP 地址、DNS、路由
  6. 将 IP 地址绑定到 pod 网络命名空间的接口 eth0 上
  7. 检查网桥状态
  8. 设置路由
  9. 设置 DNS

    cat /var/lib/cni/results/cbr0-2fc679ceaa17e33115657688a9b859e89a66c8911bcd9134fad2327830519396-eth0 | jq
    {
      "kind": "cniCacheV1",
      "containerId": "2fc679ceaa17e33115657688a9b859e89a66c8911bcd9134fad2327830519396",
      "config": "ewogICJuYW1lIjogImNicjAiLAogICJjbmlWZXJzaW9uIjogIjAuMy4xIiwKICAicGx1Z2lucyI6IFsKICAgIHsKICAgICAgInR5cGUiOiAiZmxhbm5lbCIsCiAgICAgICJkZWxlZ2F0ZSI6IHsKICAgICAgICAiaGFpcnBpbk1vZGUiOiB0cnVlLAogICAgICAgICJpc0RlZmF1bHRHYXRld2F5IjogdHJ1ZQogICAgICB9CiAgICB9LAogICAgewogICAgICAidHlwZSI6ICJwb3J0bWFwIiwKICAgICAgImNhcGFiaWxpdGllcyI6IHsKICAgICAgICAicG9ydE1hcHBpbmdzIjogdHJ1ZQogICAgICB9CiAgICB9CiAgXQp9Cg==",
      "ifName": "eth0",
      "networkName": "cbr0",
      "cniArgs": [
     [
       "K8S_POD_INFRA_CONTAINER_ID",
       "2fc679ceaa17e33115657688a9b859e89a66c8911bcd9134fad2327830519396"
     ],
     [
       "K8S_POD_UID",
       "2ab4ed63-1aff-40fc-9e40-ab95e0709cc1"
     ],
     [
       "IgnoreUnknown",
       "1"
     ],
     [
       "K8S_POD_NAMESPACE",
       "default"
     ],
     [
       "K8S_POD_NAME",
       "my-nginx-7c79c4bf97-s4jjh"
     ]
      ],
      "capabilityArgs": {
     "dns": {
       "Servers": [
         "10.96.0.10"
       ],
       "Searches": [
         "default.svc.cluster.local",
         "svc.cluster.local",
         "cluster.local"
       ],
       "Options": [
         "ndots:5"
       ]
     },
     "io.kubernetes.cri.pod-annotations": {
       "kubernetes.io/config.seen": "2024-03-21T23:40:40.674628231Z",
       "kubernetes.io/config.source": "api"
     }
      },
      "result": {
     "cniVersion": "0.3.1",
     "dns": {},
     "interfaces": [
       {
         "mac": "e6:f9:e0:2b:ae:f7",
         "name": "cni0"
       },
       {
         "mac": "82:c5:fd:ce:10:42",
         "name": "veth8d55a711"
       },
       {
         "mac": "52:d4:10:d4:6b:2c",
         "name": "eth0",
         "sandbox": "/var/run/netns/cni-c122169f-f7f0-03b3-1aa0-7ba3a07a3317"
       }
     ],
     "ips": [
       {
         "address": "10.244.0.7/24",
         "gateway": "10.244.0.1",
         "interface": 2,
         "version": "4"
       }
     ],
     "routes": [
       {
         "dst": "10.244.0.0/16"
       },
       {
         "dst": "0.0.0.0/0",
         "gw": "10.244.0.1"
       }
     ]
      }
    }

flannel会在每台node上面运行一个flannel.1虚拟网桥。不同node之间进行通信的时候,当cni发现无接口响应,便发送给flannel.1,因为集群给不同Node划分了不同的网段(CIDR),所以可以区分出ip属于哪个node,于是用Udp封装数据包,然后发送给对应的node,对应node上的flannel.1监听8472端口,获取到数据包后解包发给cni
路线为
eth0(容器)-->cni-->flannel.1-->eth(宿主机)-->eth(目标宿主机)-->flannel.1-->cni-->eth0(目标容器)
在修改网络配置的时候,也需要注意CIDR和掩码,以防因为集群扩张而无网段可分配
如:--cluster-cidr=10.244.0.0/12和--node-cidr-mask-size=16,最大节点数为16

整体

<template>
    <div class="project_main">
      <div class="alert_box">
        <el-alert v-if="alert.show" :title="alert.title" :type="alert.type" closable @close="handleAlertClose" />
      </div>
      <el-dialog v-model="confirmdialog" title="Warning" width="30%" center>
      <span>Confirm deleting this data row
      </span>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="confirmdialog = false">Cancel</el-button>
          <el-button type="primary" @click="confirmDelete">
            Confirm
          </el-button>
        </span>
      </template>
    </el-dialog>
      <div class="search_box">
        <el-form :inline="true" class="search_info">
            <el-form-item label="project:">
              <el-input v-model="projectname" placeholder="项目名" clearable />
            </el-form-item>
            <el-form-item label="owner:" >
              <el-input v-model="projectowner" placeholder="负责人" clearable />
            </el-form-item>
            <el-form-item label="rank:">
            <el-select
              v-model="projectrank"
              class="m-2"
              placeholder="Select"
              size="large"
              style="width: 240px"
              clearable 
            >
              <el-option
                v-for="item in options"
                :key="item.value"
                :label="item.label"
                :value="item.value"
              />
            </el-select>
          </el-form-item>
        <el-form-item>
            <el-button type="primary" @click="handleSubmit(1,10,projectname,projectowner,projectrank)">Query</el-button>
          </el-form-item>
          <el-form-item>
          <el-button type="primary" class="add_row" @click="handleAdd">Add</el-button>
        </el-form-item>
        </el-form>
      </div>
      <div class="project_table_box">
        <el-table :data="projectlist" style="width: 100%" :row-height="rowHeight">
          <el-table-column fixed prop="project_name" label="项目名" width="150">
            <template #default="{ row }">
              <span v-if="!row.editing && !row.addediting">{{ row.project_name }}</span>
              <el-input
                v-else
                v-model="row.editName"
              ></el-input>
            </template>
          </el-table-column>
          <el-table-column prop="project_rank" label="重要等级" width="140" >
            <template #default="{ row }">
              <span v-if="!row.editing && !row.addediting">{{ row.project_rank }}</span>
              <el-select
              v-else
              v-model="row.editRank"
              class="m-2"
              placeholder="Select"
              size="large"
              style="width: 110px"
              clearable 
            >
              <el-option
                v-for="item in options"
                :key="item.value"
                :label="item.label"
                :value="item.value"
              />
            </el-select>
            </template>
          </el-table-column>
          <el-table-column prop="project_owner_id" label="负责人" width="120">
            <template #default="{ row }">
              <span v-if="!row.editing && !row.addediting">{{ row.project_owner_id }}</span>
              <el-input
                v-else
                v-model="row.editOwner"
              ></el-input>
            </template>
          </el-table-column>
          <el-table-column prop="project_create_time" label="创建时间" width="320" />
          <el-table-column prop="project_last_mod" label="最后修改时间" width="600" />
          <el-table-column fixed="right" label="Operations" width="240">
            <template #default="{ row, $index }" class="operation_box">
              <el-button size="small" v-if="!row.editing && !row.addediting" @click="handleEdit(row, $index)">Edit</el-button>
              <el-button size="small" type="danger" v-if="row.editing && !row.addediting" @click="handleConfirm(row, $index)">Confirm</el-button>
              <el-button size="small" type="danger" v-if="!row.editing && !row.addediting" @click="handleDelete(row, $index)">Delete</el-button>
              <el-button size="small" v-if="row.editing" @click="handleCancel(row, $index)">Cancel</el-button>
              <el-button size="small" type="danger" v-if="row.addediting" @click="handleaddConfirm(row, $index)">Confirm2</el-button>
              <el-button size="small" v-if="row.addediting" @click="handleaddCancel(row, $index)">Cancel2</el-button>
            </template>
            </el-table-column>
        </el-table>
      </div>
      <div class="pagination">
        <div class="demo-pagination-block">
          <div class="demonstration">每页显示数</div>
          <el-pagination
            v-model:current-page="page"
            v-model:page-size="page_size"
            :page-sizes="[10, 20, 30, 40, 50]"
            :small="small"
            :disabled="disabled"
            :background="background"
            layout="sizes, prev, pager, next"
            :total="total_items"
            @size-change="handleSizeChange"
            @current-change="handleCurrentChange"
          />
  </div>
      </div>
    </div>
  </template>
  
  <script setup>
  import { ref, onMounted } from 'vue';
  import { projectrequest, projectupdate, projectdelete } from '../network/projectrequest';
  
  const projectlist = ref([]);
  const page = ref(1);
  const page_size = ref(10);
  const rowHeight = 40; 
  const small = ref(false);
  const background = ref(false);
  const disabled = ref(false);
  const projectname = ref('');
  const projectowner = ref('');
  const pagination_ctl = ref();
  const total_items = ref();
  const projectrank = ref('');
  const confirmdialog = ref(false);
  const options = [
    {
      value: 'P0',
      label: 'P0',
    },
    {
      value: 'P1',
      label: 'P1',
    },
    {
      value: 'P2',
      label: 'P2',
    },
    {
      value: 'P3',
      label: 'P3',
    },
    {
      value: 'P4',
      label: 'P4',
    },
    {
      value: 'P5',
      label: 'P5',
    },
  ];

  const alert = ref({
  show: false,
  type: 'success',
  title: 'Success alert',
});
function handleAlertClose() {
  alert.value.show = false;
}
  
  onMounted(() => {
    projectinfo(page.value, page_size.value)
  });
  function projectinfo(currentpage,currentpage_size,projectname,projectowner,projectrank){
    projectrequest(currentpage, currentpage_size,projectname,projectowner,projectrank).then((res) => {
      console.log(res.data)
      console.log(res)
      projectlist.value = res.data.data;
      pagination_ctl.value = res.data.pagination;
      total_items.value = pagination_ctl.value['total_items'];
    });
  }

  function handleSizeChange(pagesize_now){
    page_size.value = pagesize_now;
    page.value = 1;
    projectinfo(page.value, page_size.value, projectname.value, projectowner.value, projectrank.value);
  }

  function handleCurrentChange(page_now){
    page.value = page_now
    projectinfo(page.value, page_size.value, projectname.value, projectowner.value, projectrank.value);
  }

  function handleSubmit(currentpage,currentpage_size,projectname,projectowner,projectrank) {
  projectinfo(currentpage,currentpage_size,projectname,projectowner,projectrank);
  projectname = '';
  projectowner = '';
  }

  function handleEdit(row, index) {
  projectlist.value[index].editName = row.project_name;
  projectlist.value[index].editOwner = row.project_owner_id;
  projectlist.value[index].editRank = row.project_rank;
  projectlist.value[index].editing = true;
}

  function handleConfirm(row, index) {
    const editedData = {
    project_id: row.project_id,
    project_name: row.editName,
    project_rank: row.editRank,
    project_owner_id: row.editOwner,
  };
  //  console.log(editedData);
   projectupdate(editedData)
    .then((res) => {
      if (res.data.responsecode === 1000) {
        alert.value.type = 'success';
        alert.value.title = '更新成功';
        alert.value.show = true;
        // console.log(alert.value)
        projectlist.value[index].project_name = row.editName;
        projectlist.value[index].project_owner_id = row.editOwner;
        projectlist.value[index].editing = false;
      } else {
        alert.value.type = 'error';
        alert.value.title = `更新失败: ${res.data.errorMessage}`;
        alert.value.show = true;
        
        console.error('更新失败:', res.data.errorMessage);
      }
    })
    .catch((error) => {
      alert.value.type = 'error';
      alert.value.title = '更新失败';
      alert.value.show = true;

      console.error('更新失败:', error);
    });
}

  function handleCancel(row, index) {
  projectlist.value[index].editing = false;
}

  function handleDelete(row, index){
    confirmdialog.index = row.project_id;
    confirmdialog.value = true;
}

  function confirmDelete(){
    // console.log(confirmdialog.index);
    projectdelete(confirmdialog.index)
      .then((response) => {
      if (response.data.responsecode === 1000) {
        projectlist.value.splice(confirmdialog.index, 1);
        confirmdialog.value = false;
        alert.value.type = 'success';
        alert.value.title = '删除成功';
        alert.value.show = true;
      } else {
        alert.value.type = 'error';
        alert.value.title = `删除失败: ${response.data.data}`;
        alert.value.show = true;
        confirmdialog.value = false;
      }
    })
    .catch((error) => {
      alert.value.type = 'error';
      alert.value.title = `删除失败: ${error}`;
      alert.value.show = true;
      confirmdialog.value = false;
    });
  }

  function handleAdd() {
  projectlist.value.unshift({
    project_name: '',
    project_rank: '',
    project_owner_id: '',
    project_create_time: '',
    project_last_mod: '',
    addediting: true,
  });
}
  function handleaddConfirm(row, index) {
    const newData = {
    project_name: row.editName,
    project_rank: row.editRank,
    project_owner_id: row.editOwner,
  };
  projectupdate(newData)
    .then((res) => {
      if (res.data.responsecode === 1000) {
        alert.value.type = 'success';
        alert.value.title = '添加成功';
        alert.value.show = true;

        projectinfo(page.value, page_size.value);
      } else {
        alert.value.type = 'error';
        alert.value.title = `添加失败: ${res.data.errorMessage}`;
        alert.value.show = true;

        projectlist.value.splice(index, 1);
      }
    })
    .catch((error) => {
      alert.value.type = 'error';
      alert.value.title = `添加失败: ${error}`;
      alert.value.show = true;

      projectlist.value.splice(index, 1);
    });
  }

  function handleaddCancel(row, index) {
    projectlist.value.splice(index, 1);
  }
  </script>
  
  <style scoped>
  .el-alert {
    margin: 20px 0 0;
    z-index: 1000; 
  }
  .el-alert:first-child {
    margin: 0;
  }

  .operation_box {
    position: relative;
  }

  .operation_box .el-button {
    position: absolute;
    margin-top: 10px;
  }

  .operation_box .el-button:nth-child(2) {
    left: 70px; 
  }
  </style>

拆解

查询功能用表单实现,search_box部分,本质上就是用表单获取新参数,然后handleSubmit里面提交给后端,翻页也是类似的原理,获取参数-->传给后端
element本身表格不带增删改功能,主要难点在实现这块
CUD部分实现主要分两块内容:
1.表格切换可输入文本框:
需要从展示数据切换到可输入的文本框的功能有增和改,这块思路是给行(row)加一个状态属性,通过这个状态属性来切换前端展示的组件,具体看project_table_box这块
如下例,如果满足v-if="!row.editing && !row.addediting",那么这里就展示数据,如果不满足,那就变成input

  <el-table-column fixed prop="project_name" label="项目名" width="150">
    <template #default="{ row }">
      <span v-if="!row.editing && !row.addediting">{{ row.project_name }}</span>
      <el-input
        v-else
        v-model="row.editName"
      ></el-input>
    </template>
  </el-table-column>

控制状态切换的按钮放在了operation_box里面,增加功能的按钮为了美观放在了search_box部分
组件响应顺序:点击功能按钮--功能按钮响应函数修改前端--点击confirm--提交数据给后端,根据返回结果来改变前端/点击cancel--只改变前端,不提交数据
2.从当前行获取数据
主要利用了vue3的slot机制,也就是代码里看到的<template #default="{ row, $index }">这玩意,这种子template的作用就是从父template那边获取元素进行渲染,default定义了获取到的这些东西的变量名,在这里,row是当前行的数据,index表示当前行的索引(也就是projectlist这个数组里的索引),operation_box因为是project_table_box里面的子表,所以他的row和project_table_box的row是一样的。我们可以通过编辑row里面的属性来实现功能,或者通过index直接访问projectlist里面对应的数据。
需要注意的是,增加功能为了给用户提供一行可以用来编辑的空表,用projectlist.value.unshift直接在数组的最前面插入了一行空数据,这种插入是前端性质的,不涉及后端交互,又因为是插入在第一位,所以插入完成或者取消后,可以使用projectlist.value.splice(index, 1)从前端删除这条数据,index表示要移除的索引,1代表要移除1条数据
删除功能和增改实现思路差不多,不过把编辑换成了一个弹窗确认,依旧是operation按钮用于切换前端,在弹出来的弹窗上面的按钮才是真正绑定交互函数的按钮

2.png

下载安装node.js
创建项目目录,在里面npm init vite@latest,可以直接选框架语言快速生成,也可以选完框架后选择自定义,按提示安装相关vue组件,最后进去npm install安装依赖(根据package.json安装),npm run dev运行环境,vscode安装volar
目录中的 index.html 是项目的入口;package.json 是管理项目依赖和配置的文件;public 目录放置静态资源,比如 logo 等图片;vite.config.js 就是和 Vite 相关所有工程化的配置;src 就是工作的重点,我们大部分的代码都会在 src 目录下管理和书写,后面我们也会在 src 目录下细化项目规范。
访问提示里给的本地路径,可以看到vite的界面
Vue 负责核心,Vuex 负责管理数据,vue-router 负责管理路由
npm install vuex@next
npm install vue-router@next
如果在npm i的时候使用了--save,则该包会被安装到所有的环境
如果--save-dev,则只保存在dev环境
package.json里面,各个版本号的前面,^代表大版本号不动,后两位找当前最新,~代表前两位不动,最后一位最新
vue3基于proxy实现了响应机制

语法

风格

把vue.app删光光后,里面就剩下这两块东西
一块放js,一块放html
另外需要注意的是,默认情况下app.vue是程序的入口,也就是这里的东西会出现在所有的程序中,后面开发时,除非你有这方面的需求,如固定的侧边栏导航栏,不然不要在这里写页面代码(除了路由)

<template>
这里放html
</template>

<script>
这里放js
</script>

vue有两种风格,分别是选项式(vue2)和组合式(vue3)
选项式

<script>
export default {
  // data() 返回的属性将会成为响应式的状态
  // 并且暴露在 `this` 上
  data() {
    return {
      count: 0
    }
  },

  // methods 是一些用来更改状态与触发更新的函数
  // 它们可以在模板中作为事件处理器绑定
  methods: {
    increment() {
      this.count++
    }
  },

  // 生命周期钩子会在组件生命周期的各个不同阶段被调用
  // 例如这个函数就会在组件挂载完成后被调用
  mounted() {
    console.log(`The initial count is ${this.count}.`)
  }
}
</script>

<template>
  <button @click="increment">Count is: {{ count }}</button>
</template>

组合式,以下笔记都将由组合式编写(为什么网上教程全是选项式的啊,怒了

<script setup>
import { ref, onMounted } from 'vue'

// 响应式状态
const count = ref(0)

// 用来修改状态、触发更新的函数
function increment() {
  count.value++
}

// 生命周期钩子
onMounted(() => {
  console.log(`The initial count is ${count.value}.`)
})
</script>

<template>
  <button @click="increment">Count is: {{ count }}</button>
</template>

组合式的风格可以把一组相关的数据和方法放在一起,以便更好地管理

function useTodos() {
  let title = ref("");
  let todos = ref([{ title: "学习Vue", done: false }]);
  function addTodo() {
    todos.value.push({
      title: title.value,
      done: false,
    });
    title.value = "";
  }
  function clear() {
    todos.value = todos.value.filter((v) => !v.done);
  }
  let active = computed(() => {
    return todos.value.filter((v) => !v.done).length;
  });
  let all = computed(() => todos.value.length);
  let allDone = computed({
    get: function () {
      return active.value === 0;
    },
    set: function (value) {
      todos.value.forEach((todo) => {
        todo.done = value;
      });
    },
  });
  return { title, todos, addTodo, clear, active, all, allDone };
}

这段代码定义了一个名为 useTodos 的函数,它返回一个对象,该对象包含了一些属性和方法,用于管理一个 todo 列表。

let title = ref("");: 定义了一个响应式数据 title,初始值为空字符串。

let todos = ref([{ title: "学习Vue", done: false }]);: 定义了一个响应式数据 todos,初始值是一个包含一个 todo 对象的数组,该对象有一个 title 属性和一个 done 属性。

function addTodo() { ... }: 定义了一个名为 addTodo 的方法,用于向 todos 数组中添加一个新的 todo 对象。该方法将新的 todo 对象 push 到 todos.value 中,并将 title.value 设置为空字符串。

function clear() { ... }: 定义了一个名为 clear 的方法,用于清除已完成的 todo 对象。该方法使用 filter 方法从 todos.value 数组中筛选出未完成的 todo 对象,然后将结果赋值给 todos.value。

let active = computed(() => { ... }): 定义了一个计算属性 active,它返回未完成的 todo 对象的数量。计算属性使用 todos.value 数组进行计算,并返回结果。

let all = computed(() => todos.value.length);: 定义了一个计算属性 all,它返回 todos.value 数组的长度。

let allDone = computed({ ... }): 定义了一个计算属性 allDone,它既可以读取也可以设置。当读取 allDone 时,它会判断 active.value 是否等于 0,并返回判断结果。当设置 allDone 时,它会遍历 todos.value 数组,并将每个 todo 对象的 done 属性设置为传入的值。

return { title, todos, addTodo, clear, active, all, allDone };: 返回一个包含 title、todos、addTodo、clear、active、all 和 allDone 属性的对象。这些属性和方法可以在组件中使用。
比如这样

<script>
import { useTodos } from './useTodos';
const { title, todos, addTodo, clear, active, all, allDone } = useTodos();
</script>
  <template>
  <div>
    <h1>{{ title }}</h1>
    <ul>
      <li v-for="todo in todos" :key="todo.id">{{ todo.text }}</li>
    </ul>
    <button @click="addTodo">Add Todo</button>
    <button @click="clear">Clear Todos</button>
  </div>
</template>

基础

值/属性绑定

文本插值,会根据变量的值动态更新,使用双大括号加载,被加载的值会被当做普通文本输出而不是html处理

<span>Message: {{ msg }}</span>

双大括号的写法不能用于html的属性,所以提供了另外一种写法

<div v-bind:id="dynamicId"></div>
或者简写成
<div :id="dynamicId"></div>

如果想一次性绑多个,可以这样写
const objectOfAttrs = {
  id: 'container',
  class: 'wrapper'
}
//不指定具体的属性
<div v-bind="objectOfAttrs"></div>

//动态的属性名
<a v-bind:[attributeName]="url"> ... </a>

<!-- 简写 -->
<a :[attributeName]="url"> ... </a>

当属性为真值或者true时,这个属性是显性的,如果属性值为false,那么这个属性将不被启用

js表达式

vue支持完整的js表达式,{{}}或者所有的v-指令中都能被识别
但是vue只支持能够返回值的语句,类似赋值语句或者判断语句都无法生效

{{ number + 1 }}

{{ ok ? 'YES' : 'NO' }}

{{ message.split('').reverse().join('') }}

<div :id="`list-${id}`"></div>

ref,reactive

响应式的创建变量、对象
注意是ref,不是Ref,ref用于创建一个响应式变量,而Ref是ts中的ref调用后返回的类型

<script>
import {reactive,computed,toRefs} from 'vue'
interface DataProps {
  count: number;
  double: number;
  increase: () => void;
}
const data: DataProps = reactive({
  count: 0,
  increase: () => {data.count++},
  double: computed(() => data.count * 2)
})
//const refdata = toRefs(data)
</script>

      <p>{{ data.count }}</p>
      <p>{{ data.double }}</p>
      <button @click="data.increase">clickhere</button>

//如果是用torefs转的响应式,increase要写成refdata.count.increase

示例:搜索框

<div class="content">
  <input type="text" placeholder="搜索" ref="searchWord">
  <span class="iconfont icon-fangdajing" @click="search(this.$refs.searchWord.value)"></span>
</div>

要注意的是,ref在函数中引用赋值时,需要指定访问xxx.value,但是从template传递过来时(包括从函数参数传过来时),可以直接访问
示例:翻页

<template>
    <div class="project_main">
      <div class="search_box"></div>
      <div class="project_table_box">
        <el-table :data="projectlist" style="width: 100%" :row-height="rowHeight">
          <el-table-column fixed prop="project_name" label="项目名" width="150" />
          <el-table-column prop="project_rank" label="重要等级" width="120" />
          <el-table-column prop="project_owner_id" label="负责人" width="120" />
          <el-table-column prop="project_create_time" label="创建时间" width="320" />
          <el-table-column prop="project_last_mod" label="最后修改时间" width="600" />
        </el-table>
      </div>
      <div class="pagination">
        <div class="demo-pagination-block">
          <div class="demonstration">每页显示数</div>
          <el-pagination
            v-model:current-page="page"
            v-model:page-size="page_size"
            :page-sizes="[10, 20, 30, 40, 50]"
            :small="small"
            :disabled="disabled"
            :background="background"
            layout="sizes, prev, pager, next"
            :total="100"
            @size-change="handleSizeChange"
            @current-change="handleCurrentChange"
          />
  </div>
      </div>
    </div>
  </template>
  
  <script setup>
  import { ref, onMounted } from 'vue';
  import { projectrequest } from '../network/projectrequest';

  
  const projectlist = ref([]);
  const page = ref(1);
  const page_size = ref(10);
  const rowHeight = 40; 
  const small = ref(false)
  const background = ref(false)
  const disabled = ref(false)
  
  onMounted(() => {
    projectinfo(page.value, page_size.value)
  });
  function projectinfo(currentpage,currentpage_size){
    projectrequest(currentpage, currentpage_size).then((res) => {
      projectlist.value = res.data.data;
      console.log(projectlist.value)
      console.log(currentpage,currentpage_size)
    });
  }

  function handleSizeChange(pagesize_now){
    page_size.value = pagesize_now;
    page.value = 1;
    projectinfo(page.value, page_size.value)
  };

  function handleCurrentChange(page_now){
    page.value = page_now
    projectinfo(page.value, page_size.value)
  }
  </script>

计算属性

利用现有定义的值来计算出一个新的值,好处是计算过一次后就会缓存

<script setup>
import { reactive, computed } from 'vue'

const author = reactive({
  name: 'John Doe',
  books: [
    'Vue 2 - Advanced Guide',
    'Vue 3 - Basic Guide',
    'Vue 4 - The Mystery'
  ]
})

// 一个计算属性 ref
const publishedBooksMessage = computed(() => {
  return author.books.length > 0 ? 'Yes' : 'No'
})
</script>

<template>
  <p>Has published books:</p>
  <span>{{ publishedBooksMessage }}</span>
</template>

v-if/show

v-i会根据值的真假来判断是否插入/移除该节点

<p v-if="seen">Now you see me</p>
<div v-if="type === 'A'">
  A
</div>
<div v-else-if="type === 'B'">
  B
</div>
<div v-else-if="type === 'C'">
  C
</div>
<div v-else>
  Not A/B/C
</div>

示例

<template>
  <div><button @click="onclickup" id="show">click here</button></div>
  <div v-if!="flag">U.N is her?</div>
  <div v-else>WOW!</div>
</template>

<script setup>
  import { ref } from 'vue';

  const flag = ref(false)

  function onclickup() {
    if (flag.value === false){
      flag.value = true
    }
    else if(flag.value === true){
      flag.value = false
    }
  }
</script>

v-show和v-if功能类似,都是控制节点是否展示,但是show是在样式添加css--display:none,隐藏后dom仍然存在,if则是增加删除对应的dom

v-on

监听dom事件,用于对事件的反应,可以用于函数的调用

<a v-on:click="counter += 1"> ... </a>

<!-- 简写 -->
<a @click="doSomething"> ... </a>

<a v-on:[eventName]="doSomething"> ... </a>

<!-- 简写 -->
<a @[eventName]="doSomething">

<!--带参-->
<button @click="say('hello')">Say hello</button>

function say(message) {
  alert(message)
}

v-for

遍历取值,绑定key属性可以让vue根据该属性所对应的值来绑定节点的身份,以方便其进行排序,如果没有可绑的可以用index

<template>
<--值only-->
<li v-for="item in agelist">{{ item.message }}</li>
<--值和索引-->
<li v-for="(item,index) in agelist">{{index}} : {{ item.message }}</li>
<--用第二个位置代表属性,用第三个位置代表索引-->
<li v-for="(value,key,index) in character" :key="key|index">{{ index }}:{{ key }}:{{ value }}</li>
<--范围内取值,从1开始-->
<span v-for="n in 10">{{ n }}</span>
</template>

<script setup>
import { ref } from 'vue';

const flag = ref(false)
const agelist = ref([{message:17},{message:20}])
const character = ref({
  yukari : 17,
  marisa : 20
})

</script>

<!-- 17
20
0 : 17
1 : 20
0:yukari:17
1:marisa:20
2:erin:9999
12345678910 -->

如果要同时使用v-for和v-if,不要放在同一级,if优先级比for更高,会影响代码功能
正确示范

<template v-for="todo in todos">
  <li v-if="!todo.isComplete">
    {{ todo.name }}
  </li>
</template>

v-model

表单绑定

<p>Message is: {{ message }}</p>
  <input v-model="message" placeholder="edit me" />

  <input type="checkbox" id="checkbox" v-model="checked" />
  <label for="checkbox">{{ checked }}</label>

  //复选框的值放入列表,列表中的值为选项的value
  <template>
  <div>choice list:{{ checklist }}</div>
  <form>
  <input type="checkbox" v-model="checklist" id="zhangsan" value="zhangsan">
  <label for="zhangsan">zhangsan</label>
  <input type="checkbox" v-model="checklist" id="lisi" value="lisi">
  <label for="lisi">lisi</label>
  <input type="checkbox" v-model="checklist" id="wangwu" value="wangwu">
  <label for="wangwu">wangwu</label>
  </form>
  </template>
  <script>
  const checklist = ref([])
</script>

  //自定义值选择器
  <select v-model="selected">
    <option v-for="option in options" :key="option.value" :value="option.value">
    {{ option.text }}
</option>
  </select>

  <div>Selected: {{ selected }}</div>
  const options = ref([
    { text: 'One', value: 'A' },
    { text: 'Two', value: 'B' },
    { text: 'Three', value: 'C' }
  ])

修饰符

<!-- 在 "change" 事件后同步更新而不是 "input" -->
<input v-model.lazy="msg" />

//自动转数字
<input v-model.number="age" />

//去除两端空格
<input v-model.trim="msg" />

watch

监听某个值的变化并对其进行操作
例:监听count的变化

<script setup>
import { ref, watch } from 'vue';
const count = ref(0)
watch(count,(newValue, oldValue)=>{
  if(newValue >=3){
    console.log("too much click",newValue)
  }
}
)
function clickup(){
  count.value++
}
</script>

<template>
  <button @click="clickup">clickhere:{{ count }}</button>

</template>

如果有多个变化的值,可以从newvalue中取对应的值,值名可在控制台看到

v-slot

插槽绑定
假如某模板如下

<template>
  <form class="validate-form-container">
    <slot name="default"></slot>
    <div class="submit-area" @click.prevent="submitForm">
      <slot name="submit">//当没有绑定时会显示默认的提交按钮
        <button type="submit" class="btn btn-primary">提交</button>
      </slot>
    </div>
  </form>
</template>

现对其使用

<template>
  <div class="login-page mx-auto p-3 w-330">
    <h5 class="my-4 text-center">登录到xx</h5>
    <validate-form @form-submit="onFormSubmit">
      <div class="mb-3">
        <label class="form-label">邮箱地址</label>
        <validate-input //上面模板的代码块
          :rules="emailRules" v-model="emailVal"
          placeholder="请输入邮箱地址"
          type="text"
          ref="inputRef"
        />
      </div>
      <div class="mb-3">
        <label class="form-label">密码</label>
        <validate-input
          type="password"
          placeholder="请输入密码"
          :rules="passwordRules"
          v-model="passwordVal"
        />
      </div>
      <template #submit>//v-slot的简写,指定name="submit"
        <button type="submit" class="btn btn-primary btn-block btn-large">登录</button>
      </template>
    </validate-form>
  </div>
</template>

响应式

下面使用组合式表达
想创建一个变量,用ref来进行声明,其值通过var.value输出
当你在模板中使用了一个 ref,然后改变了这个 ref 的值时,Vue 会自动检测到这个变化,并且相应地更新 DOM。这是通过一个基于依赖追踪的响应式系统实现的。当一个组件首次渲染时,Vue 会追踪在渲染过程中使用的每一个 ref。然后,当一个 ref 被修改时,它会触发追踪它的组件的一次重新渲染。<script setup> SFC可以避免我们手动在setup()中输出的繁琐

<template>
  <div>count:<button @click="onclickup">{{ count }}</button></div>
  </template>

  <script setup>
  import { ref } from 'vue';

const count = ref(111)

function onclickup() {
  count.value++
}
</script>

直接输出count和count.value的区别

RefImpl {__v_isShallow: false, dep: undefined, __v_isRef: true, _rawValue: 111, _value: 111}dep: undefined__v_isRef: true__v_isShallow: false_rawValue: 111_value: 111value: (...)[[Prototype]]: Object
                                                                                                                                                                              App.vue:9 111

在其他组件模板中使用ref的方式,无setup的情况下

import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)

    function increment() {
      // 在 JavaScript 中需要 .value
      count.value++
    }

    // 不要忘记同时暴露 increment 函数
    return {
      count,
      increment
    }
  }
}

引用模板

<template>
<HelloWorld />
</template>

<script setup>
import HelloWorld from './components/HelloWorld.vue';
</script>

如果需要父组件给子组件传递一些值,需要用到props
props传过来的值,不能在setup作为变量使用,但是可以在template里面使用

<template>
  <p>title:{{ title }}</p>
  <p>id:{{ paperid }}</p>
</template>

<script setup>
defineProps(['paperid','title'])
</script>
<template>
<HelloWorld v-for="paper in paperlist" :paperid="paper.id" :title="paper.title"/>
</template>

<script setup>
import HelloWorld from './components/HelloWorld.vue';
const paperlist = ref([
  {id: 1, title: 'hello on board'},
  {id: 2, title: 'guide for new blood'},
  {id: 3, title: '100 way to learn vue'}
])
</script>

更全能的写法

const props = defineProps({
  isOpen: Boolean,
  count: {
    type: Number, //类型
    default: 0 //默认值
  },
  title: {
    type: String,
    required: true //是否必须
  }
});

如果想要使用复杂的类型,比如接口数组,得用PropType包裹

export interface Columntype{
    id: number;
    title: string;
    avatar: string;
    abstract: string;
}
const props = defineProps({
    columnlist: {
        type: Array as PropType<Columntype[]>,
        required: true
    }
})

子调用父的方法,可以用emit
https://zhuanlan.zhihu.com/p/581646269?utm_id=0
emit有点像订阅发布的意思,子组件在自己身上注册了一个方法,然后告诉父组件我这里要调用一个名为xxx的方法,父组件真实定义xxx后,再把xxx放入子组件注册这个方法的地方真实调用。也就是子组件负责注册,父组件负责定义和调用

<template>
  <HelloWorld v-for="paper in paperlist" :paperid="paper.id" :title="paper.title" @printpaper="printpaper(paper.title)"/>
</template>
<script setup>
import HelloWorld from './components/HelloWorld.vue';
function printpaper(papername) {
  console.log("already printed paper ",papername)
}
</script>
<template>
  <p>title:{{ title }}</p>
  <p>id:{{ paperid }}</p>
  <button @click="$emit('printpaper')">Print</button>
</template>

<script setup>
defineProps(['paperid','title'])
defineEmits(['printpaper'])
</script>

页面切换,通过点击按钮来切换对应加载的组件,使用component

<template>
<button v-for="(_,tab) in tabs" @click="currentpage = tab" :key="tab">{{ tab }}</button>
<component :is="tabs[currentpage]"></component>
</template>

<script setup>
import { ref } from 'vue';
import HelloWorld from './components/HelloWorld.vue';
import shop from './components/shop.vue'
const currentpage = ref()
</script>

创建一个应用

在默认文件里有个main.js
createApp的参数为根组件,在这里根组件为App

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

createApp(App).mount('#app')

监听

监听变量的值,当发生变化时执行操作
如果想要同时监控多个值,把第一个参数换成数组

import { watch } from 'vue'
watch(data,()=>{
  document.title = 'data updated'
})

如果想监控一个对象里面的值,需要把那个值函数化

const data: DataProps = reactive({
  count: 0,
  increase: () => {data.count++},
  double: computed(() => data.count * 2)
})
watch(()=>data.count,()=>{  //不能直接data.count
  document.title = 'data updated'
})

插槽

在子组件上提供一个插槽,以便于父组件传入的东西能够替代插槽里面的默认设置
https://zhuanlan.zhihu.com/p/529152853

子组件FancyButton
<button class="fancy-btn">
  <slot></slot> <!-- 插槽出口 -->
</button>

父组件中使用
<FancyButton>
  Click me! <!-- 插槽内容 -->
</FancyButton>

传送

传送可以让你把你的html挂载到其他的dom节点下
某个模组

<template>
    <Teleport to="#modal">//指定要传送的节点的id,被包裹的部分会给传送走
        <div id="centermodal" v-if="isOpen">
            <h2><slot>Modal window</slot></h2>
            <button @click="$emit('onModalclose')">close</button>
        </div>
    </Teleport>
</template>

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vite App</title>
  </head>
  <body>
    <div id="app"></div>
    <div id="modal"></div>//会被挂载在这里
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

加载后按f12查看,发现窗口在modal这个div下,和app是平级,成功实现了传送
可以用来避免过深的嵌套

Promise、Suspense

异步执行
示例:两秒后打印42

<template>
    <h1>{{ result }}</h1>
    <span></span>
</template>

<script setup lang="ts">
import {ref} from 'vue'

const result = ref<number | null>(null);

new Promise<number>((resolve) => {
  setTimeout(() => {
    resolve(42);
  }, 2000);
}).then((value) => {
  result.value = value;
});
</script>

app.vue

  <Suspense>
    <template #default>//加载出来后再显示这块
      <Asyncshow />
    </template>
    <template #fallback> //加载失败时显示
      <h1>Loading...</h1>
    </template>
  </Suspense>

provide,inject

在根组件中一次申明,就可以在任意组件中取用

app.vue
const location = ref('HZ')
const changelocation = (input: string) => {
  location.value = input
}
provide('location',location)

      <button @click="changelocation('Tokyo')">changeyourlocation</button>
//点击按钮就能修改
xxx.vue
import {inject} from 'vue'
const location = inject('location')

获取节点

vue3移除了this指针,但是依旧有方法可以获得节点

<template>
  <div class="demo-dropdown-wrap" ref="dropdownRef">
</template>
<script setup lang="ts">
const dropdownRef = ref<null | HTMLElement>(null) //起名一定要和上面一样
const handleMenuClick = (e: MouseEvent) => {
    console.log('click', e);
    if(dropdownRef.value){
        if(dropdownRef.value.contains(e.target as HTMLElement)){
            console.log(dropdownRef.value)
        }
    }
};
</script>

$attrs

好像是阻止vue把子节点的属性合并到父节点上面去的,暂时没用到就先不写了

路由

https://router.vuejs.org/zh/guide
在vue中使用来显示对应的路由界面

基本路由

npm install vue-router@4 --save
当然也可以在创建项目的时候用手脚架快速搭建
在src/router下创建index.js
默认文件

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (About.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import('../views/AboutView.vue')
    }
  ]
})

export default router

在views下创建个shop,添加到路由里面,访问路径/shop,即可看到对应的页面

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import shop from '../views/shop.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (About.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import('../views/AboutView.vue')
    },
    {
      path: '/shop',
      name: 'shop',
      component: shop//指定对应的组件名,如果没放在同级目录或者views下要像上面import,上面展示的是动态写法
    },
  ]
})

export default router

动态路由

动态路径,可以动态的识别参数,我这里把User.vue放在了/src/news/

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import shop from '../views/shop.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/User/:username/home/:id',
      name: 'User',
      component: () => import('../news/User.vue')
    }
  ]
})

export default router

User.vue

<template>
    <h1>User</h1>
    <p>welcome {{ $route.params.username }},your id is {{ $route.params.id }}</p>
</template>

//访问http://localhost:5173/User/alice/home/123
//显示User
//welcome alice,your id is 123

跳转

如果想要通过链接的形式跳转,使用RouterLink

<RouterLink to="/shop">Shop</RouterLink>

如果是要用变量做跳转,记得直接绑定

<RouterLink :to="'/column/' + item.id" @click="console.log(item.id)">

当你想使用函数做跳转,你就需要push

// const goToHomePage = () => {
//     routerhome.push('/login');
// };

重定向

重定向

    {
      path: '/buy',
      redirect: '/shop'
    }

嵌套路由

嵌套路由

const routes = [
  {
    path: '/user/:id',
    component: User,
    children: [
      {
        // 当 /user/:id/profile 匹配成功
        // UserProfile 将被渲染到 User 的 <router-view> 内部
        path: 'profile',
        component: UserProfile,
      },
      {
        // 当 /user/:id/posts 匹配成功
        // UserPosts 将被渲染到 User 的 <router-view> 内部
        path: 'posts',
        component: UserPosts,
      },
    ],
  },
]

嵌套示例

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import shop from '../views/shop.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (About.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import('../views/AboutView.vue')
    },
    {
      path: '/shop',
      name: 'shop',
      component: shop
    },
    {
      path: '/User/:username/home/:id',
      name: 'User',
      component: () => import('../news/User.vue'),
      children: [
        {
          path: '',
          component: () => import('../news/Userhome.vue')
        },
        {
          path: 'profile',
          component: () => import('../news/Userprofile.vue')
        },
      ],
    },
    {
      path: '/buy',
      redirect: '/shop'
    }
  ]
})

export default router
import { RouterView } from 'vue-router';
<template>
    <h1>User</h1>
    <p>welcome {{ $route.params.username }},your id is {{ $route.params.id }}</p>
    <RouterView></RouterView> //必须有这行来显示子组件
</template>

Userhome.vue
<template>
    <p>here is home</p>
</template>

Userprofile.vue
<template>
    <p>here is profile</p>
</template>

懒加载

懒加载可以让你访问到对应路由的时候再加载对应的资源,以减少资源开销和加载时间

// 将
// import UserDetails from './views/UserDetails.vue'
// 替换成
const UserDetails = () => import('./views/UserDetails.vue')

const router = createRouter({
  // ...
  routes: [{ path: '/users/:id', component: UserDetails }],
})

props传参

URL /search?q=vue 将传递 {query: 'vue'} 作为 props 传给 SearchUser 组件

const routes = [
  {
    path: '/search',
    component: SearchUser,
    props: route => ({ query: route.query.q })
  }
]

在对应组件使用$route.query.q来获取值
另外一种params的方式在上面动态路由里面展示过了

回退

退回上n页

router.go(n)

push

import { useRouter } from 'vue-router';
const routerhome = useRouter()
const goToHomePage = () => {
    routerhome.push('/');
};

守卫

声明式api里面有这样三个路由守卫

const UserDetails = {
  template: `...`,
  beforeRouteEnter(to, from) {
    // 在渲染该组件的对应路由被验证前调用
    // 不能获取组件实例 `this` !
    // 因为当守卫执行时,组件实例还没被创建!
  },
  beforeRouteUpdate(to, from) {
    // 在当前路由改变,但是该组件被复用时调用
    // 举例来说,对于一个带有动态参数的路径 `/users/:id`,在 `/users/1` 和 `/users/2` 之间跳转的时候,
    // 由于会渲染同样的 `UserDetails` 组件,因此组件实例会被复用。而这个钩子就会在这个情况下被调用。
    // 因为在这种情况发生的时候,组件已经挂载好了,导航守卫可以访问组件实例 `this`
  },
  beforeRouteLeave(to, from) {
    // 在导航离开渲染该组件的对应路由时调用
    // 与 `beforeRouteUpdate` 一样,它可以访问组件实例 `this`
  },
}

换到setup的组合式里面,由于setup中路由已经创建完毕,所以只有leave和update两个可用
如果你想使用enter,可以参考这篇,其实原理就是除了setup以外另写个script,在里面用export default
https://blog.richex.cn/vue3-how-to-use-beforerouteenter-in-script-setup-syntactic-sugar.html

import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ref } from 'vue'

export default {
  setup() {
    // 与 beforeRouteLeave 相同,无法访问 `this`
    onBeforeRouteLeave((to, from) => {
      const answer = window.confirm(
        'Do you really want to leave? you have unsaved changes!'
      )
      // 取消导航并停留在同一页面上
      if (!answer) return false
    })

    const userData = ref()

    // 与 beforeRouteUpdate 相同,无法访问 `this`
    onBeforeRouteUpdate(async (to, from) => {
      //仅当 id 更改时才获取用户,例如仅 query 或 hash 值已更改
      if (to.params.id !== from.params.id) {
        userData.value = await fetchUser(to.params.id)
      }
    })
  },
}

当然你也可以用用看路由独享的守卫,不过这个得写在router里面

const routes = [
  {
    path: '/users/:id',
    component: UserDetails,
    beforeEnter: (to, from) => {
      // reject the navigation
      return false
    },
  },
]

完整的导航解析流程

  1. 导航被触发。
  2. 在失活的组件里调用 beforeRouteLeave 守卫。
  3. 调用全局的 beforeEach 守卫。
  4. 在重用的组件里调用 beforeRouteUpdate 守卫(2.2+)。
  5. 在路由配置里调用 beforeEnter。
  6. 解析异步路由组件。
  7. 在被激活的组件里调用 beforeRouteEnter。
  8. 调用全局的 beforeResolve 守卫(2.5+)。
  9. 导航被确认。
  10. 调用全局的 afterEach 钩子。
  11. 触发 DOM 更新。
  12. 调用 beforeRouteEnter 守卫中传给 next 的回调函数,创建好的组件实例会作为回调函数的参数传入。

示例:检测用户是否登录,否则通通逐一发送login(老牛皮藓网址了

import { globalFlagsStore } from '@/stores/store'
router.beforeEach((to, from, next) => {
  const { user } = globalFlagsStore().flags;
  
  // 如果用户未登录且访问的不是登录页面,则导航至登录页面
  if (!user.isLoging && to.path !== '/login') {
    next('/login');
  } else {
    next();
  }
});

meta

可以在路由上面带元信息,用来配合守卫之类的功能

import ColumnListVue from '@/hooks/ColumnList.vue'
import ColumnDetailVue from '@/views/ColumnDetail.vue'
import HomeVue from '@/views/Home.vue'
import LoginVue from '@/views/Login.vue'
import PostpageVue from '@/views/Postpage.vue'
import { createRouter, createWebHistory } from 'vue-router'
import { globalFlagsStore } from '@/stores/store'


const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeVue
    },
    {
      path: '/login',
      name: 'login',
      component: LoginVue,
      meta: {requirenologin: true}
    },
    {
      path: '/column/',
      name: 'column',
      component: ColumnListVue
    },
    {
      path: '/column/:id',
      name: 'columndetail',
      component: ColumnDetailVue
    },
    {
      path: '/create/',
      name: 'createpost',
      component: PostpageVue,
      meta: {requirelogin: true},
    }

  ]
})

router.beforeEach((to, from, next) => {
  const { user } = globalFlagsStore().flags;
  //登录的不给访问login,没登陆的不给访问create
  if (!user.isLoging && to.meta.requirelogin) {
    next('/login');
  }else if (to.meta.requirenologin && user.isLoging){
    next('/');
  }else {
    next()
  }
});


export default router

promise

const test = new Promise((resolve,rejects) =>{
    setTimeout(() => {
        const data = { name: "John", age: 30 };
        // 如果成功获取到数据,调用 resolve 并传递数据
        resolve(data);
        // 如果获取数据失败,调用 reject 并传递错误信息
        rejects("Error fetching data");
      }, 2000);  
}).then((data)=>{
    if(true){
        console.log(data)
    }
}).catch((error) => {
    console.error(error)
})

async await

async表明一个函数是个异步函数,在async内部可以使用await,await表面这个调用是个promise调用,如果调用成功会返回调用结果,如果失败则会返回报错

async function getData() {
  try {
    const response = await fetch('https://api.example.com/data'); // 等待fetch请求的Promise对象完成
    const data = await response.json(); // 等待解析响应的Promise对象完成
    console.log(data);
  } catch (error) {
    console.log('Error:', error);
  }
}

getData();

pinia

状态管理工具,对标vuex,更轻量
vite自定义安装的时候可以直接勾选,如果快速安装则需要另外再装下
npm install pinia
状态是啥,说白了就是当前一些值,比如说我当前在查询3369这个单号的工单,然后我需要切换到另外一个组件继续处理,我希望再切换过去的时候依旧能保持工单号,这样可以减少再操作的麻烦,并且这种情况可能会出现多次,于是我就需要一个状态保持工具
pinia中的store是全局的,不跟随组件的加载卸载改变,并且每个组件都可以读或者写,它有三个概念,state、getter 和 action,相当于组件中的 data、 computed 和 function
(选项式真的太丑了,我宁愿写组合式)

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const pinia = createPinia()
const app = createApp(App)

app.use(pinia)
app.mount('#app')


如果你选的是自定义安装,那么自带的示例长这样,放在stores目录下(不过好像没被使用

import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0) //代表变量
  const doubleCount = computed(() => count.value * 2) //代表计算属性
  function increment() { //代表函数
    count.value++
  }

  return { count, doubleCount, increment }
})

让我们写个vue调用一下他

<template>
    <h3>hello,here is shop</h3>
    <p>here is your point:{{ store.count }}</p>
    <button @click="store.increment">click</button>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
</script>

钩子


使用不同的钩子函数可以在程序的不同生命周期来调用功能
vue3中beforeDestory变成beforeUnmount,destoryed变成unmounted,beforecreate和created合成了setup
另外新增两个调试用的

renderTracked -> onRenderTracked,状态跟踪,vue3新引入的钩子函数,只有在开发环境有用,用于跟踪所有响应式变量和方法,一旦页面有update,就会跟踪他们并返回一个event对象

renderTriggered -> onRenderTriggered,状态触发,同样是vue3新引入的钩子函数,只有在开发环境有效,与onRenderTracked的效果类似,但不会跟踪所有的响应式变量方法,只会定点追踪发生改变的数据,同样返回一个event对象


使用示例

import {onMounted, onUpdated,onRenderTriggered} from 'vue'

onMounted(()=> {
  console.log('mounted')
})
onUpdated(()=>{
  console.log('updated')
})
onRenderTriggered(()=>{
  console.log(event)
})

监听鼠标坐标

const x = ref(0)
const y = ref(0)
function updateMouse(e: MouseEvent){
  x.value = e.pageX
  y.value = e.pageY
}
onMounted(()=>{
  document.addEventListener('click',updateMouse)
})
onUnmounted(()=>{
  document.removeEventListener('click',updateMouse)
})

打包

npm run build
npm run preview
vite自带rollup打包,不用另外去折腾webpack啥的
rollup带tree shaking,也就是摇树机制,会自动把没用到的代码删除掉

  "scripts": {
    "dev": "vite",
    "build": "run-p type-check \"build-only {@}\" --", //打包语句,@会被替换成build-only
    "preview": "vite preview",
    "test:unit": "vitest",
    "build-only": "vite build",
    "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false"
  },

第三方

axios

npm install --save axios
npm install --save querystring
基于promise
(这里不知道为啥全局引用失败,先单个引用先)
如果安装了报但是还是报找不到这个模块,那么看下这个处理方法
https://blog.csdn.net/qq_22841387/article/details/123433223?spm=1001.2014.3001.5501

基础

get

axios({
    method: "get",
    url: "xxxx"
}).then(res => {
    console.log(res.data);
})

简写
axios.get("xxxx")
    .then(res =>{
      console.log(res.data);
    })

post

axios({
    method:"post",
    url:"xxx",
    data:qs.stringify({
        param1:"xxx",
        param2:"xxxx",
        verification_code:"xxxxx"
    })
}).then(res =>{
    console.log(res.data);
})

简写
axios.post("xxx", qs.stringify({
      param: "xxx",
  ......
    }))
      .then(res => {
        console.log(res.data);
      })

跨域问题
在vue/vite.config.js中加上

devServer: {
    proxy: {
      '/api': {
        target: '<url>',
        changeOrigin: true
      }
    }
}

ts例子

结合TS使用

import { ref } from 'vue'
import axios from 'axios'

function useURLLoader (url: string){
    const result = ref(null)
    const loading = ref(true)
    const loaded = ref(false)
    const error = ref(null)

    axios.get(url).then((rawData) => {
        loading.value = false
        loaded.value = true
        result.value = rawData.data
    }).catch(e => {
        error.value = e
        loading.value = false
    })
    return {
        result,
        loading,
        loaded,
        error
    }
}

export default useURLLoader
import useURLLoader from './hooks/useURLLoader'
const {result,loading,loaded} = useURLLoader("https://mock.apifox.cn/m1/3372030-0-default/pet/1")
      <h1 v-if="loading">Loading</h1>
      <p v-if="loaded">reslut:{{ result }}</p>

如果想要使用的调用都在某个网站上的不同路径下,可以在main直接设置根目录,然后调用api的时候只要写相对目录就行了

axios.defaults.baseURL = 'https://根目录'
axios.get('/users')

拦截器

说白了就是把包拦下来改点东西再继续传,分成请求和响应两种
请求拦截器

axios.interceptors.request.use(
  (config) => {
    // 在发送请求之前,对请求进行处理
    // 比如添加请求头,验证身份信息等
    config.headers.Authorization = 'Bearer token';

    return config;//必须返回,不能只拦不放
  },
  (error) => {
    // 请求错误时的处理
    return Promise.reject(error);
  }
);

在 Axios 中,请求拦截器的回调函数中的 config 参数是一个包含请求配置的对象。这个对象具有以下常用属性:

url:请求的 URL 地址。
method:请求的 HTTP 方法,例如 GET、POST。
baseURL:基础 URL 地址,会被添加到 url 前面。
headers:请求的头部信息,是一个对象,可以设置请求头的内容,例如设置认证信息。
params:请求的 URL 参数,也是一个对象。这个对象会被自动转换为 URL 查询字符串的形式,并添加到 URL 的末尾。
data:请求的主体数据,通常用于 POST、PUT、PATCH 等请求方法。
timeout:请求的超时时间,单位是毫秒。
transformRequest:请求数据的转换函数,可以用来转换请求数据的格式。
transformResponse:响应数据的转换函数,可以用来转换响应数据的格式。
paramsSerializer:URL 参数的序列化函数,可以自定义参数的序列化方式。
responseType:响应的数据类型,例如 ‘json’、‘text’ 等。
withCredentials:是否允许携带跨域请求的凭证。
auth:用于 HTTP 基础认证的用户名和密码。
onUploadProgress:上传进度的回调函数。
onDownloadProgress:下载进度的回调函数。

响应拦截器

axios.interceptors.response.use(
  (response) => {
    // 在接收到响应之前,对响应进行处理
    // 比如解析数据,统一处理错误等
    const data = response.data;
    if (data.code !== 200) {
      // 处理错误
      console.log('请求出错');
    }

    return response;
  },
  (error) => {
    // 响应错误时的处理
    return Promise.reject(error);
  }
);

封装、并发

很多时候肯定要用的网址不止一个,单纯封一个默认的网址有点不够用,可以用create来创建独立的axios示例
axios.all可以处理并发请求,他的参数为一个包含多个请求的数组
axios本身也是个promise函数,也可以用then catch来处理结果

import axios from 'axios'

const userRequest = axios.create({
  baseURL:"xx"
}
)
const goodsRequest = axios.create({
  baseURL: "xxxx"
})

axios.all([
  userRequest.get(),
  goodsRequest.post({})
])
.then(axios.spread((userResponse, goodsResponse) => {
  console.log('User Response:', userResponse);
  console.log('Goods Response:', goodsResponse);
}))
.catch(error => {
  console.log('Error:', error);
});

环境隔离

不同环境需要封装的地址是不同的,可以通过配置环境文件,来做到在启动的时候就进行自动读取对应环境下的配置,以实现环境隔离封装
https://cn.vitejs.dev/guide/env-and-mode.html
示例:
在项目根目录下创建.env.development

VITE_MODE_NAME=development
VITE_APP_BASE_URL=http://127.0.0.1:8000/

配置vite.config.ts

export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  envDir: './'//按照实际的路径来
})

封装使用

import axios, { type InternalAxiosRequestConfig, type AxiosResponse } from 'axios';

function request(config) {
    const instance = axios.create({
        baseURL: import.meta.env.VITE_APP_BASE_URL,
        timeout: 5000
    });
    return instance(config);
}

quillEditor

富文本编辑器
https://blog.csdn.net/weixin_42232622/article/details/126317622
https://www.kancloud.cn/liuwave/quill/1434140

npm install @vueup/vue-quill@alpha --save

无缝滚动vue3-seamless-scroll

https://doc.wssio.com/opensource/vue3-seamless-scroll/

webpack

npm install webpack-dev-server;

UI

antd vue

npm install ant-design-vue@next --save
https://2x.antdv.com/docs/vue/getting-started-cn

import Antd from 'ant-design-vue';
app.use(Antd)

element-plus

npm install element-plus --save
默认源下不了可以用国内源--registry=https://registry.npmmirror.com

import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'

const app = createApp(App)

app.use(ElementPlus)
app.mount('#app')

iconfont

一个前端图表素材库
https://www.iconfont.cn/
选择图表放入购物车,结算到项目,选择下载到本地,解压后放到本地目录下
打开里面的html文件,根据提示引用对应的组件,也可直接在main引用css