普通视图

发现新文章,点击刷新页面。
昨天以前钟意博客

设计模式系列——观察者模式

作者 钟意
2024年6月10日 10:01

模式

一种订阅机制, 在可观察对象事件发生时通知多个 “观察” 该对象的其他对象。中文以订阅者(观察者)和订阅对象(可观察对象)更容易理解,而发布者理解为统一的通知部门。

啊〰老师老师,有人就要问了,为什么不用Kafka?Redis?RabbitMQ?
没有为什么,Kafka、Redis、RabbitMQ都是消息队列,但观察者模式是一种更加通用的模式,可以用于非使命必达的场景。

  1. 发布者 (Publisher):
    • 定义:当可观察对象发生变更,筛选对应的订阅者并发布他们关注的内容
  2. 订阅者 (Subscriber):
    • 定义:除了有update方法,订阅者还需要实现逻辑来处理发布者的通知参数

场景

这个模式的生活场景巨多,就比如 一蓑烟雨 的博客就有文章订阅 哈哈哈

  • 邮箱订阅:给感兴趣的人推送更新,当然现在不感兴趣也会被迫收到。
  • 期刊订阅:小学订阅的小学生之友,还有英语老师让大家(可自愿)订阅的英语报。
  • 菜市场:和老板娘说有漂亮的五花肉记得打电话给我。就是她有时候会忘记。
  • 群聊通知:排除掉开启了免打扰的成员,剩下的都是订阅者。

案例

简单点

一个商品降价订阅通知,商品为小米SU7,为了能在线分享用 TypeScript 写案例分享。

以下代码点击 codesandbox 按钮即可运行。
Edit ThatCoder-Design

观察者接口

定义了基本的观察者接口,有观察者的信息和可观察对象的变更回调方法update()

观察者接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Observer.ts 观察者接口
export interface Observer {
  // 可观察对象变更回调
  update(product: string, price: number): void;
  userUUID: string;
  email: string;
  subscriptionType: SubscriptionType;
  discountThreshold?: number; // 仅对 DISCOUNT_TO 类型有效
}

// 订阅类型枚举
export class SubscriptionType {
  private constructor(public readonly model: string) {}

  static readonly IN_STOCK = new SubscriptionType("IN_STOCK");
  static readonly DISCOUNT = new SubscriptionType("DISCOUNT");
  static readonly DISCOUNT_TO = new SubscriptionType("DISCOUNT_TO");

  getDescription(): string {
    switch (this.model) {
      case "IN_STOCK":
        return "来货通知";
      case "DISCOUNT":
        return "降价通知";
      case "DISCOUNT_TO":
        return "降价到预期通知";
      default:
        return "未知订阅";
    }
  }
}

观察者实现

实现了观察者,增加了发送邮箱这个实际的通知方法,在update()实现通知调用

观察者接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// UserObserver.ts 实现具体的观察者,处理不同类型的通知
import {logger} from "../util/Logger"
import { Observer, SubscriptionType } from "./Observer";

export class UserObserver implements Observer {
  constructor(
    public userUUID: string,
    public email: string,
    public subscriptionType: SubscriptionType,
    public discountThreshold?: number // 仅对 DISCOUNT_TO 类型有效
  ) {}

  update(product: string, price: number): void {
    switch (this.subscriptionType) {
      case SubscriptionType.IN_STOCK:
        this.sendEmailNotification(`${product} 来货了!`);
        break;
      case SubscriptionType.DISCOUNT:
        this.sendEmailNotification(`${product} 现在已经降价至 $${price}!`);
        break;
      case SubscriptionType.DISCOUNT_TO:
        this.sendEmailNotification(
          `${product} 现在已经降价至 $${price}, 满足您期待的降价 $${
            this.discountThreshold ?? 0
          }% !`
        );
        break;
    }
  }

  private sendEmailNotification(message: string): void {
    logger.info(`发送邮件 ${this.email}: ${message}`);
  }
}

可观察者接口

定义了基本的可观察者接口,主要有订阅、取消订阅、通知三要素。

可观察者接口
1
2
3
4
5
6
7
8
9
10
11
12
13
// Observable.ts 定义一个可观察对象接口,包括订阅、取消订阅和通知方法
import { Observer } from "../Observer";

export interface Observable {
  // 订阅
  subscribe(observer: Observer): void;

  // 取消订阅
  unsubscribe(observer: Observer): void;

  // 通知
  notifyObservers(): void;
}

可观察者实现

实现了一个商品观察对象

可观察者实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// ProductObservable.ts  实现具体的可观察对象(商品通知器)
import { Observable } from "./Observable";
import { Observer, SubscriptionType } from "../Observer";
import { logger } from "../../util/Logger";

export class ProductObservable implements Observable {
  private publishers: Observer[] = [];
  private currentPrice: number = 0.0;
  private originalPrice: number = 100.0; // 原始价格,用于比较

  constructor(private product: string) {
    logger.info(
      `创建可观察对象(商品:${product}),价格 $${this.originalPrice}`
    );
  }

  subscribe(publisher: Observer): void {
    this.publishers.push(publisher);
    logger.info(
      `用户UUID: ${publisher.userUUID} ,成功订阅商品 ${
        this.product
      } ,订阅类型 ${publisher.subscriptionType.getDescription()}.`
    );
  }

  unsubscribe(publisher: Observer): void {
    this.publishers = this.publishers.filter(
      (obs) => obs.userUUID !== publisher.userUUID
    );
    logger.info(
      `用户UUID: ${publisher.userUUID} ,取消订阅商品 ${this.product} `
    );
  }

  notifyObservers(): void {
    for (const publisher of this.publishers) {
      switch (publisher.subscriptionType) {
        case SubscriptionType.IN_STOCK:
          publisher.update(this.product, this.currentPrice);
          break;
        case SubscriptionType.DISCOUNT:
          if (this.currentPrice < this.originalPrice) {
            publisher.update(this.product, this.currentPrice);
          }
          break;
        case SubscriptionType.DISCOUNT_TO:
          if (this.currentPrice <= (publisher.discountThreshold ?? 0)) {
            publisher.update(this.product, this.currentPrice);
          }
          break;
      }
    }
  }

  productRestocked(): void {
    logger.info(`商品 ${this.product} 采购成功`);
    this.notifyObservers();
  }

  productDiscounted(newPrice: number): void {
    this.currentPrice = newPrice;
    if (newPrice === this.originalPrice) {
      logger.info(`商品 ${this.product} 恢复原价`);
    } else {
      logger.info(`商品 ${this.product} 降价至: $${this.currentPrice}`);
    }
    this.notifyObservers();
  }
}

测试效果

创建 小米SU7 这个可观察对象
三个用户关注了 小米SU7,关注类型不一样
在 小米SU7 库存和价格变动时候可以观测到对应的通知变化

测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// main.ts
import { ProductObservable } from "./observable/ProductObservable";
import { UserObserver } from "./UserObserver";
import { SubscriptionType } from "./Observer";
import { logger } from "../util/Logger";

export const TestObserver = () => {
  // 创建可观察对象(商品通知器)
  const su7Notifier = new ProductObservable("小米SU7");

  // 创建观察者(用户)
  const user1 = new UserObserver(
    "UUID-1111",
    "user1@thatcoder.cn",
    SubscriptionType.IN_STOCK
  );
  const user2 = new UserObserver(
    "UUID-2222",
    "user2@thatcoder.cn",
    SubscriptionType.DISCOUNT
  );
  const user3 = new UserObserver(
    "UUID-3333",
    "user3@thatcoder.cn",
    SubscriptionType.DISCOUNT_TO,
    50
  );

  // 用户1订阅iPhone 15有货通知
  su7Notifier.subscribe(user1);
  // 用户2订阅iPhone 15降价通知
  su7Notifier.subscribe(user2);
  // 用户3订阅iPhone 15降价到50%通知
  su7Notifier.subscribe(user3);

  // 商品到货,通知相关用户
  su7Notifier.productRestocked();

  // 商品降价,通知相关用户
  su7Notifier.productDiscounted(60.0);

  // 商品恢复原价
  su7Notifier.productDiscounted(100.0);

  // 商品降价到50%,通知相关用户
  su7Notifier.productDiscounted(45.0);

  // 用户1取消iPhone 15的订阅
  su7Notifier.unsubscribe(user1);

  // 商品到货,通知剩余的用户
  su7Notifier.productRestocked();
};

测试结果

和预想一致,可观察对象只需要关注自己的变动就可以了,用户考虑的就多了(还要点击订阅)。
降价到60,所以用户3不被通知
用户1取消订阅,所以来货了也不被通知
当然这是最简单的示例

运行结果 ratio:1463/577
运行结果 ratio:1463/577

Spring监听机制

Spring有EventListener类似去定义一个事件的处理逻辑,相当于在里面写了订阅者的通知方法。ApplicationEventPublisher会去发布定义的事件,相当于可观察者的对象发生了变动。不同的是我们只关心发布和处理逻辑即可,中间的调用交给了Listener

生命周期事件

在包 org.springframework.context.event 下面有很多与 ApplicationContext 生命周期相关的事件,这些事件都继承自 ApplicationContextEvent,包括 ContextRefreshedEvent, ContextStartedEvent, ContextStoppedEvent, ContextClosedEvent
到了对应的生命周期会调用订阅。

启动和刷新
1
2
3
4
5
6
7
8
9
10
import org.springframework.context.ApplicationListener
import org.springframework.context.event.ContextRefreshedEvent
import org.springframework.stereotype.Component

@Component
class StartupListener : ApplicationListener<ContextRefreshedEvent> {
override fun onApplicationEvent(event: ContextRefreshedEvent) {
println("应用刷新成功!")
}
}

事务监听

@TransactionalEventListener
举例一个下单成功后的发布事务

事件定义
1
data class OrderPlacedEvent(val orderId: String, val userEmail: String)
事件处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.springframework.context.event.TransactionalEventListener
import org.springframework.stereotype.Component

@Component
class OrderPlacedEventListener {

@TransactionalEventListener
@Async
fun handleOrderPlacedEvent(event: OrderPlacedEvent) {
// 发送订单确认邮件
val orderId = event.orderId
val userEmail = event.userEmail
println("发送 $orderId 信息到用户邮箱 $userEmail")
// 实际发送邮件的逻辑...
}
}
事件触发
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class OrderService(private val eventPublisher: ApplicationEventPublisher) {

@Transactional
fun save(order: Order) {
// 处理下单逻辑...
// 发布事件
eventPublisher.publishEvent(OrderPlacedEvent(orderId, userEmail))
}
}

总结

优点

  • 代码解耦:观察者和订阅者的逻辑分开,订阅者只引用了抽象的发布者接口,每个可观察者只需要关注自己的实现。
  • 抽象耦合:如上代码解耦后逻辑上依然保持着抽象的耦合,订阅者只需要注册订阅即可

缺点

  • 隐式依赖:抽象耦合就代表着事件通知机制是隐式的,系统的行为可能变得难以预测和理解。及时补充文档,不然就慢慢DEBUG。
  • 瞬时峰值:某个可观察对象有大量订阅时,触发update带来的巨额性能开销可能会导致性能瓶颈,甚至系统阻塞。注意异步和削峰。
  • 并发问题:多线程中,事件的发布和订阅者的变动可能带来并发问题。需要复杂的同步机制来确保线程安全,比如ConcurrentModificationException。除了线程安全的集合可能还需要考虑显式锁、读写锁或原子操作。
IDEA的监听耳机 ratio:670/334
IDEA的监听耳机 ratio:670/334

Tabby的Web前端与Gateway网关部署

作者 钟意
2024年5月13日 10:00

前言

经常换设备用终端时候总是要下载 tabby 和添加原来的连接配置,还不同步。一直想搭建官方提供的tabby-web,现在终于有空搞。
搭建完发现不只是可以同步,还可以在网页连接配置里的终端,但是要搭建网关,顺便把网关也搭建了。web是http协议,网关是ws协议。
但是搭建过程和官方REDEME相比差别甚大 遂本文记载 tabby-webtabby-gateway 的搭建配置与联动。

部署结果 ratio:2701/1705
部署结果 ratio:2701/1705

准备

  • docker!docker!docker!
  • 两份域名证书(前端和网关,前端的自己反向代理使用,网关的需要挂载到容器)

部署

先部署吧,碰到的小毛病都是部署后面的(只部署 tabby-web 的话自己删一下网关那段)
一起整合到了编排文件。

需要修改的地方如下,分是否开启SSL两种:

  • 开启SSL
    1. 填写 网关证书目录地址:签名和私钥命名为gateway.pem和gateway.key
    2. 填写 前端容器挂载目录:后面要用到。
    3. 填写 两个映射的端口
    4. 填写 前端域名
    5. 填写 网关密钥(相当于自定义的密码)
    6. 填写 SOCIAL_AUTH_GITHUB_KEY 与 SOCIAL_AUTH_GITHUB_SECRET (用于用户Github登录,点进来进来创建一个,回调地址填前端域名)
  • 取消SSL
    1. 修改 tabby-gateway.command: –token-auth –host 0.0.0.0
    2. 删除 tabby-gateway.volumes
    3. 修改 “网关端口:443” -> “网关端口:9000”
    4. 填写 网关密钥
    5. 填写 前端域名
    6. 填写 SOCIAL_AUTH_GITHUB_KEY 与 SOCIAL_AUTH_GITHUB_SECRET
docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
version: "3.0"

services:
tabby-gateway:
image: ghcr.io/eugeny/tabby-connection-gateway:master
container_name: "tabby-gateway"
command: --token-auth --host 0.0.0.0 --port 443 --certificate /custom/ssl/gateway.pem --private-key /custom/ssl/gateway.key
environment:
- TABBY_AUTH_TOKEN=网关密钥
ports:
- "网关端口:443"
volumes:
- /你的网关证书地址:/custom/ssl
restart: unless-stopped

tabby-web:
image: ghcr.io/eugeny/tabby-web:latest
container_name: tabby-web
restart: unless-stopped
volumes:
- /你的前端容器挂载目录:/data
environment:
- DATABASE_URL=sqlite:////data/db.sqlite3
- DEBUG=False
- PORT=8080
- APP_DIST_STORAGE=file:///data
- SOCIAL_AUTH_GITHUB_KEY=记得填
- SOCIAL_AUTH_GITHUB_SECRET=记得填
- com.centurylinklabs.watchtower.enable=true
- traefik.enable=true
- traefik.http.routers.app-tabby-web.tls=true
- traefik.http.routers.app-tabby-web.tls.certresolver=cloudflare
- traefik.http.routers.app-tabby-web.entrypoints=websecure
- traefik.http.routers.app-tabby-web.rule=Host(`前端域名`)
- traefik.http.routers.app-tabby-web.service=app-tabby-web
- traefik.http.services.app-tabby-web.loadbalancer.server.port=9090
logging:
driver: json-file
options:
max-size: 5m
max-file: "5"
ports:
- "前端端口:8080"

编排就交给你了,部署完接下来分两步,先跑通web后讲gateway。

前端

安装依赖

前端警告⚠️ ratio:751/67
前端警告⚠️ ratio:751/67

访问前端后会肯定有一个提醒,我们来到tabby-web容器的命令行执行如下

web容器
1
/manage.sh add_version 1.0.187-nightly.1

下面是毛病吐槽,点击跳过碎碎念。

最新版其实是 1.0.197-nightly.1 ,但是容易暴毙。
安装完之后重启容器发现仍然是黑屏!

打开F12网络检查会发现找不到 /app-dist/1.0.187-nightly.1/ 下面的文件。
来到服务器前端容器挂载目录会发现有1.0.187-nightly.1目录,但是它的子目录是/tmpxxxx,而/tmpxxxx目录下面就是前端网络找不到的文件,是不是很抽象。

当你把文件/tmpxxxx目录下面文件移动到1.0.187-nightly.1下面,重启容器,你会发现前端不再黑屏,但是一直在加载,打开F12故技重施发现又要/tmpxxxx下面的文件,而且要的还是1.0.187-nightly.1依赖未解压的版本,实在是抽象。

好了我吐槽完了,下面厘清操作。

调整依赖

在前端容器挂载目录完成。

  1. /1.0.187-nightly.1/tmpxxxx 下面的文件移动到 /1.0.187-nightly.1
移动文件
1
2
3
4
5
# 去目标目录
cd /前端容器挂载目录/1.0.187-nightly.1
# 查询tmpxxx名字
ls
mv ./tmpxxxx/* ./
  1. 下载源码,解压到 ./tmpxxxx
下载解压
1
2
3
4
# 去目标目录
cd ./tmpxxx
# 下载解压
wget https://registry.npmjs.org/tabby-web-demo/-/tabby-web-demo-1.0.187-nightly.1.tgz tar -xvf tabby-web-demo-1.0.187-nightly.1.tgz

同步配置

自此重启容器,前端能加载完毕。

访问:你的域名/login。可以使用Github登录。
登陆之后点击设置有token,然后在客户端配置即可,参考下面图片,前者前端,后者客户端。

前端设置 ratio:1014/1615
前端设置 ratio:1014/1615
客户端配置 ratio:1631/1097
客户端配置 ratio:1631/1097

网关

编排能通过网关就没问题,网关一直重启就是证书目录有问题。

注意:网关是WS协议,不要习惯去http反向代理,然后配置了证书的话网关地址应该是 wss://网关域名:网关端口

填写是在前端填写,这样就能在网页上用同步的配置连接终端,如图。

网页使用配置 ratio:2159/1261
网页使用配置 ratio:2159/1261
网页通过网关连接终端 ratio:1365/1447
网页通过网关连接终端 ratio:1365/1447

结语

遇事不决,欢迎提问。

Stellar 1.18 迁移到 1.22.1

作者 钟意
2024年1月8日 00:00

前言

不得不说,从1.18迁移到1.22变化挺大。作者xaoxuu辛苦了。

迁移工作

考虑长期使用stellar,就fork了一个分支持续跟进作者的更新。

变化

很多细节变化吧,这里备注一下巨变。

references写法改变

我wiki大量使用了参考文献功能,给出正则表达式批量替换方法

  • 查找:- title: '(.*?)'\n url: '(.*?)'
  • 替换:- '[$1]($2)'

也不是万能的,如果标题有特殊字符违背markdown写法可能报错,但剩下几个特殊的手动改就行。

friends标签

friends标签的分组需要单独一个yml文件
sites也一样

wiki系统

这个一开始有点绕,我整理了一下逻辑。

  1. _data/wiki.yml 的列表名字如 pro_name 指向 _data/wiki/pro_name.yml 的文件名字
  2. _data/wiki/xxx.yml 文件里面的 path: /wiki/pro_path/ 参数指向 source/wiki/pro_path/ 文件夹
  3. source/wiki/pro_path/ 文件夹内文件的 wiki: pro_name 闭环指向 _data/wiki.yml 的列表名字
  • 综上 _data/wiki.yml 和 _data/wiki/pro_name.yml 和 文件wiki: pro_name 需要一致是 pro_name
  • 而最终上线的项目在线 url 与 pro_name 无关,关联的是 source/wiki/pro_path/ 对应的 pro_path 目录名称

其他功能

来不及一个一个试功能,先写到这,便把博客更新到1.22.1

Stellar代码块个人向美化

作者 钟意
2023年2月3日 16:42

前言

增加主题控制后代码块样式有些唐突, 遂改之.

思路

不改变主题代码情况下思路的主旋律按以下走:

  1. 代码块随主题颜色变更
  2. 增加复制代码功能 (来源whbbit)
  3. 增加代码过长折叠

代码

下面直接成品, 有需求自定义修改

代码块样式

ZYCode.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
:root{
--code-autor: '© 钟意博客🌙';
--code-tip: "优雅借鉴";
}

/*语法高亮*/
.hljs {
position: relative;
display: block;
overflow-x: hidden;
/*背景跟随Stellar*/
background: var(--block);
color: #9c67a1;
padding: 30px 5px 2px 5px;
box-shadow: 0 10px 30px 0px rgb(0 0 0 / 40%)
}

.hljs::before {
content: var(--code-tip);
position: absolute;
left: 15px;
top: 10px;
overflow: visible;
width: 12px;
height: 12px;
border-radius: 16px;
box-shadow: 20px 0 #a9a6a1, 40px 0 #999;
-webkit-box-shadow: 20px 0 #999, 40px 0 #999;
background-color: #999;
white-space: nowrap;
text-indent: 75px;
font-size: 16px;
line-height: 12px;
font-weight: 700;
color: #999
}

.highlight:hover .hljs::before {
color: #35cd4b;
box-shadow: 20px 0 #fdbc40, 40px 0 #35cd4b;
-webkit-box-shadow: 20px 0 #fdbc40, 40px 0 #35cd4b;
background-color: #fc625d;
}

.hljs-ln {
display: inline-block;
overflow-x: auto;
padding-bottom: 5px
}

.hljs-ln td {
padding: 0;
background-color: var(--block)
}

.hljs-ln::-webkit-scrollbar {
height: 10px;
border-radius: 5px;
background: #333;
}

.hljs-ln::-webkit-scrollbar-thumb {
background-color: #bbb;
border-radius: 5px;
}

.hljs-ln::-webkit-scrollbar-thumb:hover {
background: #ddd;
}

.hljs table tbody tr {
border: none
}

.hljs .hljs-ln-line {
padding: 1px 10px;
border: none
}

td.hljs-ln-line.hljs-ln-numbers {
border-right: 1px solid #666;
}

.hljs-keyword,
.hljs-literal,
.hljs-symbol,
.hljs-name {
color: #c78300
}

.hljs-link {
color: #569cd6;
text-decoration: underline
}

.hljs-built_in,
.hljs-type {
color: #4ec9b0
}

.hljs-number,
.hljs-class {
color: #2094f3
}

.hljs-string,
.hljs-meta-string {
color: #4caf50
}

.hljs-regexp,
.hljs-template-tag {
color: #9a5334
}

.hljs-subst,
.hljs-function,
.hljs-title,
.hljs-params,
.hljs-formula {
color: #c78300
}

.hljs-property {
color: #9c67a1;
}

.hljs-comment,
.hljs-quote {
color: #57a64a;
font-style: italic
}

.hljs-doctag {
color: #608b4e
}

.hljs-meta,
.hljs-meta-keyword,
.hljs-tag {
color: #9b9b9b
}

.hljs-variable,
.hljs-template-variable {
color: #bd63c5
}

.hljs-attr,
.hljs-attribute,
.hljs-builtin-name {
color: #d34141
}

.hljs-section {
color: gold
}

.hljs-emphasis {
font-style: italic
}

.hljs-strong {
font-weight: bold
}

.hljs-bullet,
.hljs-selector-tag,
.hljs-selector-id,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #c78300
}

.hljs-addition {
background-color: #144212;
display: inline-block;
width: 100%
}

.hljs-deletion {
background-color: #600;
display: inline-block;
width: 100%
}

.hljs.language-html::before,
.hljs.language-xml::before {
content: "HTML/XML"
}

.hljs.language-javascript::before {
content: "JavaScript"
}

.hljs.language-c::before {
content: "C"
}

.hljs.language-cpp::before {
content: "C++"
}

.hljs.language-java::before {
content: "Java"
}

.hljs.language-asp::before {
content: "ASP"
}

.hljs.language-actionscript::before {
content: "ActionScript/Flash/Flex"
}

.hljs.language-bash::before {
content: "Bash"
}

.hljs.language-css::before {
content: "CSS"
}

.hljs.language-asp::before {
content: "ASP"
}

.hljs.language-cs::before,
.hljs.language-csharp::before {
content: "C#"
}

.hljs.language-d::before {
content: "D"
}

.hljs.language-golang::before,
.hljs.language-go::before {
content: "Go"
}

.hljs.language-json::before {
content: "JSON"
}

.hljs.language-lua::before {
content: "Lua"
}

.hljs.language-less::before {
content: "LESS"
}

.hljs.language-md::before,
.hljs.language-markdown::before,
.hljs.language-mkdown::before,
.hljs.language-mkd::before {
content: "Markdown"
}

.hljs.language-mm::before,
.hljs.language-objc::before,
.hljs.language-obj-c::before,
.hljs.language-objective-c::before {
content: "Objective-C"
}

.hljs.language-php::before {
content: "PHP"
}

.hljs.language-perl::before,
.hljs.language-pl::before,
.hljs.language-pm::before {
content: "Perl"
}

.hljs.language-python::before,
.hljs.language-py::before,
.hljs.language-gyp::before,
.hljs.language-ipython::before {
content: "Python"
}

.hljs.language-r::before {
content: "R"
}

.hljs.language-ruby::before,
.hljs.language-rb::before,
.hljs.language-gemspec::before,
.hljs.language-podspec::before,
.hljs.language-thor::before,
.hljs.language-irb::before {
content: "Ruby"
}

.hljs.language-sql::before {
content: "SQL"
}

.hljs.language-sh::before,
.hljs.language-shell::before,
.hljs.language-Session::before,
.hljs.language-shellsession::before,
.hljs.language-console::before {
content: "Shell"
}

.hljs.language-swift::before {
content: "Swift"
}

.hljs.language-vb::before {
content: "VB/VBScript"
}

.hljs.language-yaml::before {
content: "YAML"
}

/*stellar主题补偿*/
.md-text pre>.hljs {
padding-top: 2rem !important;
}

.md-text pre {
padding: 0 !important;
}

code {
background-image: linear-gradient(90deg, rgba(60, 10, 30, .04) 3%, transparent 0), linear-gradient(1turn, rgba(60, 10, 30, .04) 3%, transparent 0) !important;
background-size: 20px 20px !important;
background-position: 50% !important;
}

figure::after {
content: var(--code-autor);
text-align: right;
font-size: 10px;
float: right;
margin-top: 3px;
padding-right: 15px;
padding-bottom: 8px;
color: #999
}

figcaption span {
border-radius: 0px 0px 12px 12px !important;
}


/* 复制代码按钮 */
.highlight {
position: relative;
}

.highlight .code .copy-btn {
position: absolute;
top: 0;
right: 0;
padding: 4px 0.5rem;
opacity: 0.25;
font-weight: 700;
color: var(--theme);
cursor: pointer;
transination: opacity 0.3s;
}

.highlight .code .copy-btn:hover {
color: var(--text-code);
opacity: 0.75;
}

.highlight .code .copy-btn.success {
color: var(--swiper-theme-color);
opacity: 0.75;
}

/* 描述 */
.md-text .highlight figcaption span {
font-size: small;
}

/* 折叠 */
code.hljs {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
/*-webkit-line-clamp: 6;*/
padding: 1rem 1rem 0 1rem; /* chino建议 */
}

.hljsOpen {
-webkit-line-clamp: 99999 !important;
}
.CodeCloseDiv {
color: #999;
background: var(--block);
display: flex;
justify-content: center;
margin-top: inherit;
margin-bottom: -18px;
}
.CodeClose {
color: #999;
margin-top: 3px;
background: var(--block);
}

.highlight button:hover,
.highlight table:hover+button {
color: var(--swiper-theme-color);
opacity: 0.75;
}

执行函数

原作者复制代码
会因为tabs这种标签的display:none而与代码语言重合, 已修复(也不算修复, 我把它写死了)

ZYCode.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// 这四个常量是复制,复制成功,展开,收缩
// 我使用的是 https://fontawesome.com/ 图标, 不用可以改为文字.
const copyText = '<i class="fa-regular fa-copy" style="color: #aa69ec;"></i>';
const copySuccess = '<i class="fa-regular fa-circle-check" style="color: limegreen;"></i>';
const openText = '<i class="fa-solid fa-angles-down fa-beat-fade"></i>';
const closeText = '<i class="fa-solid fa-angles-up fa-beat-fade"></i>';

const codeElements = document.querySelectorAll('td.code');

codeElements.forEach((code, index) => {
const preCode = code.querySelector('pre');

// 设置id和样式
preCode.id = `ZYCode${index+1}`;
preCode.style.webkitLineClamp = '6';

// 添加展开/收起按钮
if (preCode.innerHTML.split('<br>').length > 6) {
const codeCopyDiv = document.createElement('div');
codeCopyDiv.classList.add('CodeCloseDiv');
code.parentNode.parentNode.parentNode.parentNode.appendChild(codeCopyDiv);

const codeCopyOver = document.createElement('button');
codeCopyOver.classList.add('CodeClose');
codeCopyOver.innerHTML = openText;

const parent = code.parentNode.parentNode.parentNode.parentNode;
const description = parent.childNodes.length === 3 ? parent.children[2] : parent.children[1];
description.appendChild(codeCopyOver);

codeCopyOver.addEventListener('click', () => {
if (codeCopyOver.innerHTML === openText) {
const scrollTop = document.documentElement.scrollTop;
const codeHeight = code.clientHeight;

if (scrollTop < codeHeight) {
document.documentElement.scrollTop += codeHeight - scrollTop;
}

preCode.style.webkitLineClamp = '99999';
codeCopyOver.innerHTML = closeText;
} else {
preCode.style.webkitLineClamp = '6';
codeCopyOver.innerHTML = openText;
}
});
}

// 添加复制按钮
const codeCopyBtn = document.createElement('div');
codeCopyBtn.classList.add('copy-btn');
codeCopyBtn.innerHTML = copyText;
code.appendChild(codeCopyBtn);

// 添加复制功能
codeCopyBtn.addEventListener('click', async () => {
const currentCodeElement = code.querySelector('pre')?.innerText;
await copyCode(currentCodeElement);

codeCopyBtn.innerHTML = copySuccess;
codeCopyBtn.classList.add('success');

setTimeout(() => {
codeCopyBtn.innerHTML = copyText;
codeCopyBtn.classList.remove('success');
}, 3000);
});
});

async function copyCode(currentCode) {
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(currentCode);
} catch (error) {
console.error(error);
}
} else {
console.error('当前浏览器不支持此API');
}
}

引入函数

根目录/_config.yml
1
2
3
4
# 自定义引入css,js
inject:
script:
- <script type="text/javascript" src="/custom/js/ZYCode.js"></script>

引入样式

根目录/_config.stellar.yml
1
2
3
style:
codeblock:
highlightjs_theme: /custom/css/ZYCode.css

结语

你备份了吗?

❌
❌