mioku-plugin-impact 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/config.md +42 -0
- package/configs/base.ts +40 -0
- package/db.ts +229 -0
- package/handlers/dajiao.ts +50 -0
- package/handlers/open-module.ts +22 -0
- package/handlers/pk.ts +91 -0
- package/handlers/query-injection.ts +74 -0
- package/handlers/queryjj.ts +53 -0
- package/handlers/rank.ts +58 -0
- package/handlers/suo.ts +82 -0
- package/handlers/types.ts +15 -0
- package/handlers/yinpa.ts +179 -0
- package/image.ts +335 -0
- package/index.ts +142 -0
- package/package.json +126 -0
- package/router.ts +94 -0
- package/state.ts +35 -0
- package/types.ts +17 -0
- package/utils.ts +99 -0
package/handlers/rank.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { renderRankChart, type RankRow } from "../image";
|
|
2
|
+
import { getStrangerNickname, pickJj } from "../utils";
|
|
3
|
+
import type { HandlerContext } from "./types";
|
|
4
|
+
|
|
5
|
+
export async function handleRank(h: HandlerContext): Promise<void> {
|
|
6
|
+
const { ctx, db, screenshot, event } = h;
|
|
7
|
+
const ranking = db.getRanking();
|
|
8
|
+
if (ranking.length < 5) {
|
|
9
|
+
await event.reply([ctx.segment.text("目前记录的数据量小于5, 无法显示rank喵")], false);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (!screenshot) {
|
|
14
|
+
await event.reply(
|
|
15
|
+
[ctx.segment.text("缺少 screenshot 服务,无法生成排行榜图片喵")],
|
|
16
|
+
false,
|
|
17
|
+
);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const top5 = ranking.slice(0, 5);
|
|
22
|
+
const last5 = ranking.slice(-5);
|
|
23
|
+
|
|
24
|
+
const myIndex = ranking.findIndex((r) => r.userId === Number(event.user_id));
|
|
25
|
+
if (myIndex < 0) {
|
|
26
|
+
await db.addNewUser(Number(event.user_id));
|
|
27
|
+
await event.reply(
|
|
28
|
+
[
|
|
29
|
+
ctx.segment.at(Number(event.user_id)),
|
|
30
|
+
ctx.segment.text(
|
|
31
|
+
`你还没有创建${pickJj()}看不到rank喵, 咱帮你创建了喵, 目前长度是10cm喵`,
|
|
32
|
+
),
|
|
33
|
+
],
|
|
34
|
+
false,
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const selfId = Number(event.self_id);
|
|
40
|
+
const rows: RankRow[] = await Promise.all(
|
|
41
|
+
[...top5, ...last5].map(async (r) => ({
|
|
42
|
+
name: await getStrangerNickname(ctx, selfId, r.userId),
|
|
43
|
+
userId: r.userId,
|
|
44
|
+
jjLength: r.jjLength,
|
|
45
|
+
})),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const imagePath = await renderRankChart(screenshot, rows);
|
|
49
|
+
|
|
50
|
+
await event.reply(
|
|
51
|
+
[
|
|
52
|
+
ctx.segment.at(Number(event.user_id)),
|
|
53
|
+
ctx.segment.image(imagePath),
|
|
54
|
+
ctx.segment.text(`你的排名为${myIndex + 1}喵`),
|
|
55
|
+
],
|
|
56
|
+
false,
|
|
57
|
+
);
|
|
58
|
+
}
|
package/handlers/suo.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { checkCooldown, clearCooldown, markCooldown } from "../state";
|
|
2
|
+
import { getAtUserId, getRandomNum, pickJj, roundTo } from "../utils";
|
|
3
|
+
import type { HandlerContext } from "./types";
|
|
4
|
+
|
|
5
|
+
export async function handleSuo(h: HandlerContext): Promise<void> {
|
|
6
|
+
const { ctx, db, cd, config, event } = h;
|
|
7
|
+
const uid = String(event.user_id);
|
|
8
|
+
const cdState = checkCooldown(cd.suo, uid, config.suoCd);
|
|
9
|
+
if (!cdState.ok) {
|
|
10
|
+
await event.reply(
|
|
11
|
+
[
|
|
12
|
+
ctx.segment.at(Number(event.user_id)),
|
|
13
|
+
ctx.segment.text(
|
|
14
|
+
`你已经嗦不动了喵, 请等待${roundTo(cdState.remaining, 3)}秒后再嗦喵`,
|
|
15
|
+
),
|
|
16
|
+
],
|
|
17
|
+
false,
|
|
18
|
+
);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
markCooldown(cd.suo, uid);
|
|
22
|
+
|
|
23
|
+
const at = getAtUserId(event.message);
|
|
24
|
+
if (at == null) {
|
|
25
|
+
if (db.isUserInTable(Number(uid))) {
|
|
26
|
+
const delta = getRandomNum();
|
|
27
|
+
await db.addJjLength(Number(uid), delta);
|
|
28
|
+
await event.reply(
|
|
29
|
+
[
|
|
30
|
+
ctx.segment.at(Number(event.user_id)),
|
|
31
|
+
ctx.segment.text(
|
|
32
|
+
`你的${pickJj()}很满意喵, 嗦长了${delta}cm喵, 目前长度为${db.getJjLength(
|
|
33
|
+
Number(uid),
|
|
34
|
+
)}cm喵`,
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
false,
|
|
38
|
+
);
|
|
39
|
+
} else {
|
|
40
|
+
await db.addNewUser(Number(uid));
|
|
41
|
+
clearCooldown(cd.suo, uid);
|
|
42
|
+
await event.reply(
|
|
43
|
+
[
|
|
44
|
+
ctx.segment.at(Number(event.user_id)),
|
|
45
|
+
ctx.segment.text(
|
|
46
|
+
`你还没有创建${pickJj()}喵, 咱帮你创建了喵, 目前长度是10cm喵`,
|
|
47
|
+
),
|
|
48
|
+
],
|
|
49
|
+
false,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (db.isUserInTable(at)) {
|
|
56
|
+
const delta = getRandomNum();
|
|
57
|
+
await db.addJjLength(at, delta);
|
|
58
|
+
await event.reply(
|
|
59
|
+
[
|
|
60
|
+
ctx.segment.at(Number(event.user_id)),
|
|
61
|
+
ctx.segment.text(
|
|
62
|
+
`对方的${pickJj()}很满意喵, 嗦长了${delta}cm喵, 目前长度为${db.getJjLength(
|
|
63
|
+
at,
|
|
64
|
+
)}cm喵`,
|
|
65
|
+
),
|
|
66
|
+
],
|
|
67
|
+
false,
|
|
68
|
+
);
|
|
69
|
+
} else {
|
|
70
|
+
await db.addNewUser(at);
|
|
71
|
+
clearCooldown(cd.suo, uid);
|
|
72
|
+
await event.reply(
|
|
73
|
+
[
|
|
74
|
+
ctx.segment.at(Number(event.user_id)),
|
|
75
|
+
ctx.segment.text(
|
|
76
|
+
`TA还没有创建${pickJj()}喵, 咱帮TA创建了喵, 目前长度是10cm喵`,
|
|
77
|
+
),
|
|
78
|
+
],
|
|
79
|
+
false,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ScreenshotService } from "mioku";
|
|
2
|
+
import type { MiokiContext } from "mioki";
|
|
3
|
+
import type { ImpactConfig } from "../configs/base";
|
|
4
|
+
import type { ImpactDatabase } from "../db";
|
|
5
|
+
import type { CooldownState } from "../types";
|
|
6
|
+
|
|
7
|
+
export interface HandlerContext {
|
|
8
|
+
ctx: MiokiContext;
|
|
9
|
+
db: ImpactDatabase;
|
|
10
|
+
cd: CooldownState;
|
|
11
|
+
config: ImpactConfig;
|
|
12
|
+
/** 截图服务可能未启用,依赖图片输出的命令需要降级或拒绝 */
|
|
13
|
+
screenshot?: ScreenshotService;
|
|
14
|
+
event: any;
|
|
15
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { isOwner } from "mioki";
|
|
2
|
+
import { checkCooldown, clearCooldown, markCooldown } from "../state";
|
|
3
|
+
import {
|
|
4
|
+
getAtUserId,
|
|
5
|
+
getAvatarUrl,
|
|
6
|
+
getSenderName,
|
|
7
|
+
roundTo,
|
|
8
|
+
} from "../utils";
|
|
9
|
+
import type { HandlerContext } from "./types";
|
|
10
|
+
|
|
11
|
+
interface PrepMember {
|
|
12
|
+
user_id: number;
|
|
13
|
+
role: "owner" | "admin" | "member";
|
|
14
|
+
card?: string;
|
|
15
|
+
nickname?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SLEEP_BEFORE_REPLY_MS = 2000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 透/日 系列命令。subject 由命令文本决定:
|
|
22
|
+
* 含"群主" → owner,含"管理" → admin,否则 → member。
|
|
23
|
+
* 行为与 nonebot_plugin_impact.handle.Impart.yinpa 对齐。
|
|
24
|
+
*/
|
|
25
|
+
export async function handleYinpa(
|
|
26
|
+
h: HandlerContext,
|
|
27
|
+
subject: "owner" | "admin" | "member",
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const { ctx, db, cd, config, event } = h;
|
|
30
|
+
const uid = Number(event.user_id);
|
|
31
|
+
const uidKey = String(uid);
|
|
32
|
+
|
|
33
|
+
const cdState = checkCooldown(cd.fuck, uidKey, config.fuckCd);
|
|
34
|
+
// 主人不受 CD 限制
|
|
35
|
+
if (!cdState.ok && !isOwner(event)) {
|
|
36
|
+
await event.reply(
|
|
37
|
+
[
|
|
38
|
+
ctx.segment.at(uid),
|
|
39
|
+
ctx.segment.text(
|
|
40
|
+
`你已经榨不出来任何东西了, 请先休息${roundTo(cdState.remaining, 3)}秒`,
|
|
41
|
+
),
|
|
42
|
+
],
|
|
43
|
+
false,
|
|
44
|
+
);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
markCooldown(cd.fuck, uidKey);
|
|
48
|
+
|
|
49
|
+
const reqUserCard = getSenderName(event);
|
|
50
|
+
const selfId = Number(event.self_id);
|
|
51
|
+
let prepList: PrepMember[];
|
|
52
|
+
try {
|
|
53
|
+
const raw = await ctx
|
|
54
|
+
.pickBot(selfId)
|
|
55
|
+
.api("get_group_member_list", { group_id: Number(event.group_id) });
|
|
56
|
+
prepList = (raw as PrepMember[]) ?? [];
|
|
57
|
+
} catch (err) {
|
|
58
|
+
clearCooldown(cd.fuck, uidKey);
|
|
59
|
+
ctx.logger.error(`impact: 获取群成员列表失败: ${err}`);
|
|
60
|
+
await event.reply([ctx.segment.text("喵喵喵? 拿不到群成员列表了!")], false);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let lucky: number | null;
|
|
65
|
+
if (subject === "owner") {
|
|
66
|
+
lucky = await pickOwner(prepList, uid, reqUserCard, ctx, event);
|
|
67
|
+
} else if (subject === "admin") {
|
|
68
|
+
lucky = await pickAdmin(prepList, uid, reqUserCard, ctx, event);
|
|
69
|
+
} else {
|
|
70
|
+
lucky = await pickMember(prepList, uid, reqUserCard, ctx, event);
|
|
71
|
+
}
|
|
72
|
+
if (lucky == null) {
|
|
73
|
+
// pickXxx 内部已 finish 并清掉 CD
|
|
74
|
+
clearCooldown(cd.fuck, uidKey);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 1.0 ~ 100.0 ml 随机,保留 3 位
|
|
79
|
+
const volume = roundTo(1 + Math.random() * 99, 3);
|
|
80
|
+
await db.insertEjaculation(lucky, volume);
|
|
81
|
+
|
|
82
|
+
await sleep(SLEEP_BEFORE_REPLY_MS);
|
|
83
|
+
|
|
84
|
+
await db.updateActivity(lucky);
|
|
85
|
+
await db.updateActivity(uid);
|
|
86
|
+
|
|
87
|
+
const luckyMember = prepList.find((p) => p.user_id === lucky);
|
|
88
|
+
const luckyCard =
|
|
89
|
+
String(luckyMember?.card || "").trim() ||
|
|
90
|
+
String(luckyMember?.nickname || "").trim() ||
|
|
91
|
+
"群友";
|
|
92
|
+
const todayTotal = db.getTodayEjaculationVolume(lucky);
|
|
93
|
+
const seconds = 1 + Math.floor(Math.random() * 20);
|
|
94
|
+
|
|
95
|
+
await event.reply(
|
|
96
|
+
[
|
|
97
|
+
ctx.segment.text(
|
|
98
|
+
`好欸!${reqUserCard}(${uid})用时${seconds}秒 \n给 ${luckyCard}(${lucky}) 注入了${volume}毫升的脱氧核糖核酸, 当日总注入量为:${todayTotal}毫升\n`,
|
|
99
|
+
),
|
|
100
|
+
ctx.segment.image(getAvatarUrl(lucky)),
|
|
101
|
+
],
|
|
102
|
+
false,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function pickOwner(
|
|
107
|
+
prepList: PrepMember[],
|
|
108
|
+
uid: number,
|
|
109
|
+
reqUserCard: string,
|
|
110
|
+
ctx: any,
|
|
111
|
+
event: any,
|
|
112
|
+
): Promise<number | null> {
|
|
113
|
+
// 找不到群主时回退到自己
|
|
114
|
+
const owner = prepList.find((p) => p.role === "owner");
|
|
115
|
+
const lucky = owner?.user_id ?? uid;
|
|
116
|
+
if (lucky === uid) {
|
|
117
|
+
await event.reply([ctx.segment.text("你透你自己?")], false);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
await event.reply(
|
|
121
|
+
[ctx.segment.text(`现在咱将把群主\n送给${reqUserCard}色色!`)],
|
|
122
|
+
false,
|
|
123
|
+
);
|
|
124
|
+
return lucky;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function pickAdmin(
|
|
128
|
+
prepList: PrepMember[],
|
|
129
|
+
uid: number,
|
|
130
|
+
reqUserCard: string,
|
|
131
|
+
ctx: any,
|
|
132
|
+
event: any,
|
|
133
|
+
): Promise<number | null> {
|
|
134
|
+
let admins = prepList.filter((p) => p.role === "admin").map((p) => p.user_id);
|
|
135
|
+
if (admins.includes(uid)) {
|
|
136
|
+
// 自己是管理的话移除自己
|
|
137
|
+
admins = admins.filter((id) => id !== uid);
|
|
138
|
+
}
|
|
139
|
+
if (admins.length === 0) {
|
|
140
|
+
await event.reply([ctx.segment.text("喵喵喵? 找不到群管理!")], false);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const lucky = admins[Math.floor(Math.random() * admins.length)];
|
|
144
|
+
await event.reply(
|
|
145
|
+
[ctx.segment.text(`现在咱将随机抽取一位幸运管理\n送给${reqUserCard}色色!`)],
|
|
146
|
+
false,
|
|
147
|
+
);
|
|
148
|
+
return lucky;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function pickMember(
|
|
152
|
+
prepList: PrepMember[],
|
|
153
|
+
uid: number,
|
|
154
|
+
reqUserCard: string,
|
|
155
|
+
ctx: any,
|
|
156
|
+
event: any,
|
|
157
|
+
): Promise<number | null> {
|
|
158
|
+
const at = getAtUserId(event.message);
|
|
159
|
+
if (at != null) {
|
|
160
|
+
// 有 @ 就直接指定目标,不播报抽取提示
|
|
161
|
+
return at;
|
|
162
|
+
}
|
|
163
|
+
let candidates = prepList.map((p) => p.user_id);
|
|
164
|
+
candidates = candidates.filter((id) => id !== uid);
|
|
165
|
+
if (candidates.length === 0) {
|
|
166
|
+
await event.reply([ctx.segment.text("喵喵喵? 群里没有别的群友了!")], false);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const lucky = candidates[Math.floor(Math.random() * candidates.length)];
|
|
170
|
+
await event.reply(
|
|
171
|
+
[ctx.segment.text(`现在咱将随机抽取一位幸运群友\n送给${reqUserCard}色色!`)],
|
|
172
|
+
false,
|
|
173
|
+
);
|
|
174
|
+
return lucky;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sleep(ms: number): Promise<void> {
|
|
178
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
179
|
+
}
|
package/image.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import type { ScreenshotService } from "mioku";
|
|
2
|
+
import { escapeHtml, getAvatarUrl } from "./utils";
|
|
3
|
+
|
|
4
|
+
export interface RankRow {
|
|
5
|
+
name: string;
|
|
6
|
+
userId: number;
|
|
7
|
+
jjLength: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface InjectionPoint {
|
|
11
|
+
date: string;
|
|
12
|
+
volume: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const COLORS = [
|
|
16
|
+
"#1f77b4",
|
|
17
|
+
"#2ca02c",
|
|
18
|
+
"#d62728",
|
|
19
|
+
"#ff7f0e",
|
|
20
|
+
"#9467bd",
|
|
21
|
+
"#17becf",
|
|
22
|
+
"#bcbd22",
|
|
23
|
+
"#e377c2",
|
|
24
|
+
"#ff6347",
|
|
25
|
+
"#ffd700",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const BASE_CSS = `
|
|
29
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
30
|
+
body {
|
|
31
|
+
font-family: "MiSans", "PingFang SC", "Microsoft YaHei",
|
|
32
|
+
"Noto Sans CJK SC", "Hiragino Sans GB", "Apple Color Emoji",
|
|
33
|
+
"Noto Color Emoji", sans-serif;
|
|
34
|
+
background: #fff;
|
|
35
|
+
color: #1d1d1f;
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 渲染牛子排行榜:横向条形图(前五正向延伸,后五负向延伸)。
|
|
41
|
+
*/
|
|
42
|
+
export async function renderRankChart(
|
|
43
|
+
screenshot: ScreenshotService,
|
|
44
|
+
rows: RankRow[],
|
|
45
|
+
): Promise<string> {
|
|
46
|
+
const width = 960;
|
|
47
|
+
const rowHeight = 56;
|
|
48
|
+
const headerHeight = 96;
|
|
49
|
+
const padding = 32;
|
|
50
|
+
const labelWidth = 220;
|
|
51
|
+
const trackPad = 32;
|
|
52
|
+
const trackWidth = width - padding * 2 - labelWidth - trackPad;
|
|
53
|
+
const height = headerHeight + rowHeight * rows.length + padding;
|
|
54
|
+
|
|
55
|
+
const maxAbs = Math.max(
|
|
56
|
+
1,
|
|
57
|
+
...rows.map((r) => Math.abs(r.jjLength)),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const items = rows
|
|
61
|
+
.map((row, i) => {
|
|
62
|
+
const color = COLORS[i % COLORS.length];
|
|
63
|
+
const ratio = Math.min(1, Math.abs(row.jjLength) / maxAbs);
|
|
64
|
+
const barWidth = ratio * (trackWidth / 2 - 6);
|
|
65
|
+
const isPositive = row.jjLength >= 0;
|
|
66
|
+
const trackLeft = padding + labelWidth + trackPad;
|
|
67
|
+
const center = trackLeft + trackWidth / 2;
|
|
68
|
+
const barLeft = isPositive ? center : center - barWidth;
|
|
69
|
+
const labelText = `${row.jjLength.toFixed(2)} cm`;
|
|
70
|
+
return `
|
|
71
|
+
<div class="row" style="top:${headerHeight + i * rowHeight}px;">
|
|
72
|
+
<img class="avatar" src="${escapeHtml(getAvatarUrl(row.userId))}" />
|
|
73
|
+
<div class="name">${escapeHtml(truncate(row.name, 9))}</div>
|
|
74
|
+
<div class="track" style="left:${trackLeft}px; width:${trackWidth}px;">
|
|
75
|
+
<div class="axis"></div>
|
|
76
|
+
<div class="bar" style="
|
|
77
|
+
left:${barLeft}px;
|
|
78
|
+
width:${barWidth}px;
|
|
79
|
+
background:${color};
|
|
80
|
+
"></div>
|
|
81
|
+
<div class="value" style="${
|
|
82
|
+
isPositive
|
|
83
|
+
? `left:${barLeft + barWidth + 8}px;`
|
|
84
|
+
: `left:${barLeft - 70}px; text-align:right;`
|
|
85
|
+
}">${labelText}</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
`;
|
|
89
|
+
})
|
|
90
|
+
.join("");
|
|
91
|
+
|
|
92
|
+
const html = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8" />
|
|
93
|
+
<style>
|
|
94
|
+
${BASE_CSS}
|
|
95
|
+
.canvas {
|
|
96
|
+
position: relative;
|
|
97
|
+
width: ${width}px;
|
|
98
|
+
height: ${height}px;
|
|
99
|
+
background: #fafafa;
|
|
100
|
+
}
|
|
101
|
+
.title {
|
|
102
|
+
position: absolute;
|
|
103
|
+
left: 0; top: 28px;
|
|
104
|
+
width: 100%;
|
|
105
|
+
text-align: center;
|
|
106
|
+
font-size: 32px;
|
|
107
|
+
font-weight: 700;
|
|
108
|
+
color: #1d1d1f;
|
|
109
|
+
}
|
|
110
|
+
.subtitle {
|
|
111
|
+
position: absolute;
|
|
112
|
+
left: 0; top: 64px;
|
|
113
|
+
width: 100%;
|
|
114
|
+
text-align: center;
|
|
115
|
+
font-size: 16px;
|
|
116
|
+
color: #6e6e73;
|
|
117
|
+
}
|
|
118
|
+
.row {
|
|
119
|
+
position: absolute;
|
|
120
|
+
left: ${padding}px;
|
|
121
|
+
right: ${padding}px;
|
|
122
|
+
height: ${rowHeight - 8}px;
|
|
123
|
+
}
|
|
124
|
+
.avatar {
|
|
125
|
+
position: absolute;
|
|
126
|
+
left: 0; top: 0;
|
|
127
|
+
width: ${rowHeight - 12}px;
|
|
128
|
+
height: ${rowHeight - 12}px;
|
|
129
|
+
border-radius: 50%;
|
|
130
|
+
object-fit: cover;
|
|
131
|
+
border: 1px solid #e5e5ea;
|
|
132
|
+
background: #f2f2f7;
|
|
133
|
+
}
|
|
134
|
+
.name {
|
|
135
|
+
position: absolute;
|
|
136
|
+
left: 60px;
|
|
137
|
+
top: 50%;
|
|
138
|
+
transform: translateY(-50%);
|
|
139
|
+
width: ${labelWidth - 60}px;
|
|
140
|
+
font-size: 18px;
|
|
141
|
+
color: #1d1d1f;
|
|
142
|
+
white-space: nowrap;
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
text-overflow: ellipsis;
|
|
145
|
+
}
|
|
146
|
+
.track {
|
|
147
|
+
position: absolute;
|
|
148
|
+
top: 50%;
|
|
149
|
+
height: ${rowHeight - 18}px;
|
|
150
|
+
transform: translateY(-50%);
|
|
151
|
+
}
|
|
152
|
+
.axis {
|
|
153
|
+
position: absolute;
|
|
154
|
+
left: 50%;
|
|
155
|
+
top: -4px;
|
|
156
|
+
bottom: -4px;
|
|
157
|
+
width: 2px;
|
|
158
|
+
background: #d2d2d7;
|
|
159
|
+
}
|
|
160
|
+
.bar {
|
|
161
|
+
position: absolute;
|
|
162
|
+
top: 0;
|
|
163
|
+
bottom: 0;
|
|
164
|
+
border-radius: 4px;
|
|
165
|
+
opacity: 0.92;
|
|
166
|
+
}
|
|
167
|
+
.value {
|
|
168
|
+
position: absolute;
|
|
169
|
+
top: 50%;
|
|
170
|
+
transform: translateY(-50%);
|
|
171
|
+
font-size: 14px;
|
|
172
|
+
color: #1d1d1f;
|
|
173
|
+
white-space: nowrap;
|
|
174
|
+
width: 70px;
|
|
175
|
+
}
|
|
176
|
+
</style>
|
|
177
|
+
</head>
|
|
178
|
+
<body>
|
|
179
|
+
<div class="canvas">
|
|
180
|
+
<div class="title">JJ 排行榜</div>
|
|
181
|
+
<div class="subtitle">Top 5 与 Bottom 5(单位:cm)</div>
|
|
182
|
+
${items}
|
|
183
|
+
</div>
|
|
184
|
+
</body></html>`;
|
|
185
|
+
|
|
186
|
+
return screenshot.screenshot(html, {
|
|
187
|
+
width,
|
|
188
|
+
height,
|
|
189
|
+
fullPage: false,
|
|
190
|
+
type: "png",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 渲染历史注入折线图。
|
|
196
|
+
*/
|
|
197
|
+
export async function renderInjectionChart(
|
|
198
|
+
screenshot: ScreenshotService,
|
|
199
|
+
points: InjectionPoint[],
|
|
200
|
+
totalVolume: number,
|
|
201
|
+
): Promise<string> {
|
|
202
|
+
const width = 1024;
|
|
203
|
+
const height = 560;
|
|
204
|
+
const padding = { top: 80, right: 48, bottom: 80, left: 64 };
|
|
205
|
+
const plotW = width - padding.left - padding.right;
|
|
206
|
+
const plotH = height - padding.top - padding.bottom;
|
|
207
|
+
|
|
208
|
+
const maxValue = Math.max(1, ...points.map((p) => p.volume));
|
|
209
|
+
const niceMax = niceCeil(maxValue);
|
|
210
|
+
|
|
211
|
+
const ticksY = 5;
|
|
212
|
+
const yLabels = Array.from({ length: ticksY + 1 }, (_, i) => {
|
|
213
|
+
const v = (niceMax / ticksY) * i;
|
|
214
|
+
return {
|
|
215
|
+
value: v.toFixed(0),
|
|
216
|
+
y: padding.top + plotH - (v / niceMax) * plotH,
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const stepX = points.length > 1 ? plotW / (points.length - 1) : 0;
|
|
221
|
+
|
|
222
|
+
const dots = points.map((p, i) => {
|
|
223
|
+
const x = padding.left + stepX * i;
|
|
224
|
+
const y = padding.top + plotH - (p.volume / niceMax) * plotH;
|
|
225
|
+
return { x, y, point: p };
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const linePath = dots
|
|
229
|
+
.map((d, i) => `${i === 0 ? "M" : "L"} ${d.x.toFixed(2)} ${d.y.toFixed(2)}`)
|
|
230
|
+
.join(" ");
|
|
231
|
+
|
|
232
|
+
const dotsHtml = dots
|
|
233
|
+
.map(
|
|
234
|
+
(d) => `
|
|
235
|
+
<circle cx="${d.x}" cy="${d.y}" r="5" fill="#1f77b4" />
|
|
236
|
+
<text x="${d.x}" y="${d.y - 12}" text-anchor="middle" font-size="13" fill="#1d1d1f">${d.point.volume}</text>
|
|
237
|
+
`,
|
|
238
|
+
)
|
|
239
|
+
.join("");
|
|
240
|
+
|
|
241
|
+
const sampledLabels = sampleLabels(dots, 8);
|
|
242
|
+
const xLabelsHtml = sampledLabels
|
|
243
|
+
.map(
|
|
244
|
+
(d) => `
|
|
245
|
+
<text x="${d.x}" y="${padding.top + plotH + 22}" text-anchor="middle" font-size="13" fill="#6e6e73" transform="rotate(-30 ${d.x} ${padding.top + plotH + 22})">${escapeHtml(d.point.date)}</text>
|
|
246
|
+
`,
|
|
247
|
+
)
|
|
248
|
+
.join("");
|
|
249
|
+
|
|
250
|
+
const yAxisHtml = yLabels
|
|
251
|
+
.map(
|
|
252
|
+
(l) => `
|
|
253
|
+
<line x1="${padding.left}" y1="${l.y}" x2="${padding.left + plotW}" y2="${l.y}" stroke="#e5e5ea" stroke-dasharray="4 4" />
|
|
254
|
+
<text x="${padding.left - 8}" y="${l.y + 4}" text-anchor="end" font-size="13" fill="#6e6e73">${l.value}</text>
|
|
255
|
+
`,
|
|
256
|
+
)
|
|
257
|
+
.join("");
|
|
258
|
+
|
|
259
|
+
const html = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8" />
|
|
260
|
+
<style>
|
|
261
|
+
${BASE_CSS}
|
|
262
|
+
.canvas {
|
|
263
|
+
position: relative;
|
|
264
|
+
width: ${width}px;
|
|
265
|
+
height: ${height}px;
|
|
266
|
+
background: #fafafa;
|
|
267
|
+
}
|
|
268
|
+
.title {
|
|
269
|
+
position: absolute;
|
|
270
|
+
left: 0; top: 24px;
|
|
271
|
+
width: 100%;
|
|
272
|
+
text-align: center;
|
|
273
|
+
font-size: 28px;
|
|
274
|
+
font-weight: 700;
|
|
275
|
+
color: #1d1d1f;
|
|
276
|
+
}
|
|
277
|
+
.subtitle {
|
|
278
|
+
position: absolute;
|
|
279
|
+
left: 0; top: 56px;
|
|
280
|
+
width: 100%;
|
|
281
|
+
text-align: center;
|
|
282
|
+
font-size: 14px;
|
|
283
|
+
color: #6e6e73;
|
|
284
|
+
}
|
|
285
|
+
svg { position: absolute; left: 0; top: 0; }
|
|
286
|
+
</style>
|
|
287
|
+
</head>
|
|
288
|
+
<body>
|
|
289
|
+
<div class="canvas">
|
|
290
|
+
<div class="title">历史注入走势</div>
|
|
291
|
+
<div class="subtitle">总量 ${totalVolume.toFixed(2)} ml · 共 ${points.length} 天有记录</div>
|
|
292
|
+
<svg width="${width}" height="${height}">
|
|
293
|
+
${yAxisHtml}
|
|
294
|
+
<line x1="${padding.left}" y1="${padding.top + plotH}" x2="${padding.left + plotW}" y2="${padding.top + plotH}" stroke="#1d1d1f" stroke-width="2" />
|
|
295
|
+
<line x1="${padding.left}" y1="${padding.top}" x2="${padding.left}" y2="${padding.top + plotH}" stroke="#1d1d1f" stroke-width="2" />
|
|
296
|
+
<path d="${linePath}" fill="none" stroke="#1f77b4" stroke-width="2.5" />
|
|
297
|
+
${dotsHtml}
|
|
298
|
+
${xLabelsHtml}
|
|
299
|
+
</svg>
|
|
300
|
+
</div>
|
|
301
|
+
</body></html>`;
|
|
302
|
+
|
|
303
|
+
return screenshot.screenshot(html, {
|
|
304
|
+
width,
|
|
305
|
+
height,
|
|
306
|
+
fullPage: false,
|
|
307
|
+
type: "png",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function truncate(text: string, max: number): string {
|
|
312
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function niceCeil(v: number): number {
|
|
316
|
+
if (v <= 0) return 1;
|
|
317
|
+
const exp = Math.pow(10, Math.floor(Math.log10(v)));
|
|
318
|
+
const base = v / exp;
|
|
319
|
+
let mult: number;
|
|
320
|
+
if (base <= 1) mult = 1;
|
|
321
|
+
else if (base <= 2) mult = 2;
|
|
322
|
+
else if (base <= 5) mult = 5;
|
|
323
|
+
else mult = 10;
|
|
324
|
+
return mult * exp;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function sampleLabels<T>(items: T[], maxLabels: number): T[] {
|
|
328
|
+
if (items.length <= maxLabels) return items;
|
|
329
|
+
const step = (items.length - 1) / (maxLabels - 1);
|
|
330
|
+
const out: T[] = [];
|
|
331
|
+
for (let i = 0; i < maxLabels; i++) {
|
|
332
|
+
out.push(items[Math.round(i * step)]);
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|