Yihang Huang

Share < Enjoy > Code

博客从 Typecho 迁移到了 Hexo,以方便维护。icarus 主题缺乏维护、高度依赖 CDN、原生不支持 mermaid,故选择了新生力量 NexT。根据官方教程启用 Katex 后发现首页的图片 404 了,但是文章页面的图片是正常的。

定位问题

因为 icarus 主题是正常的,所以怀疑 NexT 主题有缺陷。cloenppoffice/hexo-theme-icarusnext-theme/hexo-theme-next后,定位到 index 的布局文件。

icarus

熟悉的 JSX。
alt text
alt text

NexT

是没见过的 Nunjucks,不过易读性很好。

alt text
alt text

两者只是布局不同,渲染的内容是一样的,故问题根源不在主题。

renderer

启用 KaTex 时需要切换渲染引擎,进一步猜测问题出在这里。

1
2
npm un hexo-renderer-marked
npm i hexo-renderer-markdown-it-plus

hexo-renderer-marked 是 Hexo 的官方支持仓库,其 README 中提到hexo-renderer-markdown-it是功能相同的但更安全的版本。hexo-renderer-markdown-it-plus是第三方维护的,其声称是hexo-renderer-markdown-it的进阶版,默认包含了更多渲染引擎。克隆这两个仓库,发现事实是hexo-renderer-markdown-it-plus不支持hexo-renderer-markdown-it所支持的 Post Asset Folder。

hexo-renderer-marked

如下部分用于处理图片路径。
alt text

hexo-renderer-markdown-it-plus

是一个带了一些默认插件的插件管理器,但却缺失了官方版本中处理图片路径的部分。又正因为处理图片路径的部分不是一个单独发布的插件,所以也没办法通过添加参数的方式使用。
alt text

解决问题

发布一个处理图片路径的 Hexo 插件

最开始是希望避免修改hexo-renderer-markdown-it-plus,单独发布一个插件插入到hexo-renderer-markdown-it-plus用来处理图片路径。实践上不可行,因为处理图片路径时需要 hexo 构建时的元信息和辅助函数,但是hexo-renderer-markdown-it-plus在使用插件时并没有传递这些元信息,只传递了待渲染内容和插件参数。为了健全hexo-renderer-markdown-it-plus功能,clone 源码后修改以下部分使得其完整传递信息。

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
class Renderer {
constructor(hexo) {
this.hexo = hexo;
}
render(data, options) {
var config = this.hexo.config.markdown_it_plus;
if (!(config instanceof Object)) {
config = {};
}
var parseConfig = checkConfig(config);
var md = require("markdown-it")(parseConfig);
if (config.plugins == undefined || config.plugins == null) {
config.plugins = [];
}

// config.plugins =
var plugins = checkPlugins(config.plugins, this.hexo);

md = plugins.reduce(function (md, pugs) {
if (pugs.enable) {
if (pugs.name == "markdown-it-toc-and-anchor") {
if (pugs.options == null) pugs.options = {};
if (!pugs.options.anchorLinkSymbol)
pugs.options.anchorLinkSymbol = "";
if (!pugs.options.tocFirstLevel) pugs.options.tocFirstLevel = 2;
return md.use(
require("./markdown-it-toc-and-anchor/index.js").default,
pugs.options
);
} else {
let plugin = require(pugs.name);
if (
typeof plugin !== "function" &&
typeof plugin.default === "function"
)
plugin = plugin.default;
if (pugs.options) return md.use(plugin, pugs.options);
else return md.use(plugin);
}
} else return md;
}, md);

return md.render(data.text, data);
}
}

然后再单独导出一个插件。
alt text

1
2
3
4
5
6
7
cd markdown-it-images
npm link
cd hexo-renderer-markdown-it-plus
npm link markdown-it-images
npm link
cd blog
npm link hexo-renderer-markdown-it-plus

验证 link,npm run clean && npm run server后首页图片恢复正常。
alt text

完善 hexo-renderer-markdown-it-plus

既然修改了 hexo-renderer-markdown-it-plus,不如直接内置图片路径处理。
alt text

forkCHENXCHEN/hexo-renderer-markdown-it-plus后提交修改到仓库,修改依赖package.jsonnpm install

1
2
3
4
# package.json
"dependencies": {
"hexo-renderer-markdown-it-plus": "github:PekingSpades/hexo-renderer-markdown-it-plus#75821d2",
}

额外插入 image render

无论是官方插件,还是第三方插件,都是通过以下方式注册渲染函数。

1
2
3
4
5
6
7
hexo.extend.renderer.register("md", "html", render, true);
hexo.extend.renderer.register("markdown", "html", render, true);
hexo.extend.renderer.register("mkd", "html", render, true);
hexo.extend.renderer.register("mkdn", "html", render, true);
hexo.extend.renderer.register("mdwn", "html", render, true);
hexo.extend.renderer.register("mdtxt", "html", render, true);
hexo.extend.renderer.register("mdtext", "html", render, true);

那希望既不修改hexo-renderer-markdown-it-plus,也不发布一个单独的图片路径处理的插件,也就是用同样的方法注册渲染函数,其只负责处理图片路径。实践上不可行,clonehexojs/hexo后发现同一种文件只能同时存在一个渲染函数。

alt text

验证

更新 EdgeOne 部署,首页图片正常显示。

PR

给作者提 PR。

问题

Video 元素无法接收到特定按键的 Keydown\Keyup 等事件。经排查,原因为浏览器安装了Global Speed: 视频速度控制插件。

alt text

该插件默认启用快捷键,作用于 HTMLMediaElement。

alt text

验证

插件导致云原神无法操作游戏对象,其未解决该问题。

解决

从源头入手

其开源仓库有不少相关 issue,但作者的白名单方案不适用于 On-Prem;给作者提出的新建议-插件可选标识未被采纳。😦

正向思路

检测是否安装了该插件。经查,其未在目标页面插入标识或特定元素。clone 源码,定位到src\contentScript\main\index.ts,留意到以下代码,位于构造函数中。该代码覆写了playbackRate等属性。

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
for (let key of ["playbackRate", "defaultPlaybackRate"] as ("playbackRate" | "defaultPlaybackRate")[]) {
const ogDesc = this.ogDesc[key]
let coherence = this.coherence[key]

let we = this

try {
Object.defineProperty(HTMLMediaElement.prototype, key, {
configurable: true,
enumerable: true,
get: function() {
we.ogDesc[key].get.call(this)
return we.active ? (native.map.has.call(coherence, this) ? native.map.get.call(coherence, this) : 1) : ogDesc.get.call(this)
},
set: function(newValue) {
if (we.active && !(this instanceof native.HTMLMediaElement)) {
we.ogDesc[key].set.call(this, newValue)
}
try {
let output = ogDesc.set.call(we.active ? we.dummyAudio : this, newValue)
let rate = ogDesc.get.call(we.active ? we.dummyAudio : this)
native.map.set.call(coherence, this, rate)
return output
} catch (err) {
throw err
}
}
})
} catch (err) { }
}

直接检测是否被覆写。

1
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "playbackRate").get.toString() === 'function get playbackRate() { [native code] }'

禁用插件时:
alt text

保持默认设置,启用插件时:
alt text

该方法无法判断插件是否为不活跃状态。

逆向思路

注意到https://www.polyv.net/播放器实现了类似功能。运行示例,设置为禁止倍速,在控制台搜索报错文字,快速定位到相关代码。

alt text

继续查找maxPlaybackRateLimitError没了线索,断点最后一次出现的所在函数的输出。

alt text

结合setLanguage判断该处为i18n相关代码。错误信息对应的key#025,但搜索#025无结果。进一步搜索同时出现#concat的地方,定位到如下处,初步判断为用于显示错误的函数,打断点追踪调用,定位到rateChangeHandle

alt text

发现启用插件时,!Je(w()(HTMLMediaElement.prototype,"playbackRate").get === true。打断点看wJe函数。抽丝剥茧后,w是打包后的Object.getOwnPropertyDescriptor

1
2
3
4
5
6
7
function (t, e, i) {
i(236);
var n = i(12).Object;
t.exports = function (t, e) {
return n.getOwnPropertyDescriptor(t, e);
};
},

Je如下:

1
2
3
4
5
6
7
8
var Je = function (t) {
return (
(e = t),
(i = v()(e)),
null !== e && ("object" === i || "function" === i) && Xe.test(t)
);
var e, i;
},

能猜到 v 函数是 polyfill 后的typeof,实际也是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function a(t) {
return (
(e.exports = a =
"function" == typeof r && "symbol" == typeof n
? function (t) {
return typeof t;
}
: function (t) {
return t &&
"function" == typeof r &&
t.constructor === r &&
t !== r.prototype
? "symbol"
: typeof t;
}),
a(t)
);
}

AI 解释为什么需要特殊处理symbol
alt text

Je函数中涉及的Xe正则如下。通过Function.prototype.toString.call(Object.prototype.hasOwnProperty)避免源码字符串化的空格差异,以这个为模板创建匹配任何函数名的native code的正则。

1
2
3
4
5
6
7
8
9
10
11
12
Xe = RegExp(
"^".concat(
Function.prototype.toString
.call(Object.prototype.hasOwnProperty)
.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")
.replace(
/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,
"$1.*?"
),
"$"
)
);

由此可得,Je函数是一个鲁棒性强的用于检测入参是否为native code function的函数;w用于获取属性描述符对象。

其他思路

有没有一种方案,既不需要用户卸载插件或者手动添加页面到白名单,又避免插件影响业务逻辑?

发现只有命中快捷键规则的按键才会被拦截。定位到src\contentScript\isolated\utils\EventsListener.ts文件,是一个工具类,用于注册事件监听器。

1
2
3
4
5
6
7
8
9
update = () => {
window.addEventListener("keydown", this.handleKeyDown, true)
window.addEventListener("keyup", this.handleKeyUp, true)
}
handleKeyDown = (e: KeyboardEvent) => {
this.keyDownCbs.forEach(cb => {
cb(e)
})
}

找到实际添加的回调,发现如果命中快捷键规则则e.preventDefault();e.stopImmediatePropagation(),和实际表现一致。

1
2
3
4
5
if (matches.some(v => v.kb.greedy)) {
this.blockKeyUp = true
e.preventDefault()
e.stopImmediatePropagation()
}

尝试构造特殊 DOM 结构,使之命中如下判断:

1
2
3
4
5
6
7
8
9
10
11
12
// stop if input fields
const target = e.target as HTMLElement
if (["INPUT", "TEXTAREA"].includes(target.tagName) || target.isContentEditable) {
return
}

const active = getLeaf(document, 'activeElement')
if (target !== active) {
if (["INPUT", "TEXTAREA"].includes(active.tagName) || (active as HTMLElement).isContentEditable) {
return
}
}

让 AI 快速写个 hack:

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
// 假设你的运行环境支持 chrome.dom.openOrClosedShadowRoot(或 element.openOrClosedShadowRoot)
const video = document.querySelector('video');

// 让 <video> 可聚焦(通常有 controls 时已可聚焦;这里稳妥起见)
video.tabIndex = 0;

// 在 <video> 上挂一个「关闭」的 shadow root,并开启 delegatesFocus,
// 这样对宿主的聚焦会自动转移到内部第一个可聚焦元素
let root: ShadowRoot;
try {
root = video.attachShadow({ mode: 'closed', delegatesFocus: true });
} catch (e) {
// 某些环境可能禁止在 <video> 上 attachShadow;若抛错,此策略不可用(见下方“注意事项”)
throw e;
}

// 放一个“诱饵”可编辑元素(任选其一:input/textarea/contenteditable)
const decoy = document.createElement('input'); // 或 document.createElement('textarea')
decoy.setAttribute('aria-hidden', 'true');
// 做到不可见且不拦截点击(避免影响 UI)
Object.assign(decoy.style, {
position: 'absolute',
width: '0',
height: '0',
opacity: '0',
pointerEvents: 'none',
border: '0',
padding: '0',
});
root.appendChild(decoy);

// 确保当视频获得焦点时,诱饵也保持焦点
video.addEventListener('focus', () => decoy.focus(), true);
video.addEventListener('mousedown', () => decoy.focus(), true);

// 先手动聚焦一次
decoy.focus();

其结果是Uncaught NotSupportedError: Failed to execute 'attachShadow' on 'Element': This element does not support attachShadow,构造失败,该方法不可行。

换种思路,避免插件注册事件监听器?在Debug界面,发现无法找到插件注册的回调函数。
alt text

让 AI 写addEventListener等函数的 patch。

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
useEffect(() => {
// 只在浏览器执行
(function () {
const _addEventListener = window.addEventListener;
const _removeEventListener = window.removeEventListener;
const _listeners: Record<string, Function[]> = {};

window.addEventListener = function (type: string, listener: any, options?: any) {
if (!_listeners[type]) {
_listeners[type] = [];
}
_listeners[type].push(listener);
return _addEventListener.call(window, type, listener, options);
};

window.removeEventListener = function (type: string, listener: any, options?: any) {
if (_listeners[type]) {
_listeners[type] = _listeners[type].filter((l) => l !== listener);
}
return _removeEventListener.call(window, type, listener, options);
};

(window as any).__printEventListeners = function (type?: string) {
if (type) {
console.log(`Event listeners for "${type}":`, _listeners[type] || []);
} else {
console.log("All event listeners:", _listeners);
}
};
})();
}, []);

发现依然无法找到插件注册的回调函数,该方法不可行。

结论

通过正向和逆向,得到一致结论,即通过检测HTMLMediaElementplaybackRate是否被覆写可以判断是否安装了该插件或类似插件。

如果实际业务对象是 Video 标签,则无法通过构造特殊 DOM 来绕过插件判断。如果不是,则可将 Video 实际内容绘制到 Canvas 或构造一个满足条件的元素叠在 Video 元素上,但涉及到其他业务代码的改造且成本大于收益。

在本文中,“上传”指 客户端(JS)将文件发送到服务端(Go),而“下载”则指 客户端从服务端获取文件。

文件下载的实现

Server 端(GO)

semaphore.go实现动态加权信号量分配,修改于golang.org/x/sync/semaphore

dchan.gosemaphore.go的基础上实现了一种带背压的 channel,使用 Little’s Law 简单方法自动调整缓冲区大小。所实现的 Write 和 Read 均为 Context-aware 的阻塞函数,可同时具有多个消费者和生产者。

这两个包允许通过 interface 传入可修改权重,可实现动态调整任务优先级。

之所以这样做,是因为实践证明 Gstreamer 的 Datachannel 的 buffer 缓冲区很大,无限制地压入待发送数据会导致很难低成本无开销地丢弃无用数据。同时,高带宽下避免磁盘 IO 繁忙,预缓冲足够的数据;低带宽时避免过度预读占用内存。

以下为不同带宽下的表现。

较低带宽下正常传输

较低带宽下终止传输

较高带宽下开始传输

较高带宽下传输完成

附图表说明。

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
// "github.com/guptarohit/asciigraph"
go func() {
data := make([][]float64, 3)

for {
select {
case <-newPeer.ctx.Done():
return
default:
data[0] = append(data[0], float64(newPeer.sendChan.InUse())/1024/1024)
data[1] = append(data[1], float64(newPeer.sendChan.Bps())/1024/1024)
data[2] = append(data[2], float64(newPeer.sendChan.Limit())/1024/1024)
for i := 0; i < len(data); i++ {
if len(data[i]) > 100 {
data[i] = data[i][len(data[i])-100:]
}
}

graph := asciigraph.PlotMany(data,
asciigraph.Precision(2),
asciigraph.Height(20),
asciigraph.SeriesColors(asciigraph.Red, asciigraph.Green, asciigraph.Blue),
asciigraph.SeriesLegends("allocated", "bps", "limit"),
asciigraph.Caption("graph of "+uid))
fmt.Println(graph)
time.Sleep(1000 * time.Millisecond)
}
}
}()

探索 WebRTC DataChannel 在不同消息体大小下的传输速率极限

浏览器为 Microsoft Edge,版本 139.0.3405.111 (正式版本) (64 位);使用dd if=/dev/urandom of=randomfile.dat bs=1m count=4096生成 4GB 文件,通过 WebRTC Datachannel 传输,ICE candidate 为 localhost udp,文件块大小为 12KB,下载耗时 140s。

第一次测试

第二次测试

第三次测试

文件分块为 60KB,下载耗时约 170s。
第一次测试

第二次测试

第三次测试

在不修改底层实现的前提下,将分块设置为小于默认 MTU 大小的 1KB,并不能突破 Gstreamer 的 Datachannel 的传输极限。

alt text

尝试修改分块大小,观察数据。当分块大小为 4KB 时,前几次测试前期能够始终保持 8K messagesReceived/s,随后浏览器卡住,无法完成下载测试。重启浏览器后,多次测试表现大致相同。

测试结果

Client 端

TODO

文件上传的实现

Server 端(GO)

combiner.go用于二叉合并,思想来自 blake。而blake3.go则是 blake3 的简单实现。

range.go待写,用于确定特定区间是否完成传输。

实现理由:为了实现一致性保证的断点续传,旧方案会先计算完整文件的哈希再上传,导致大文件上传时初始化阶段耗时长,上传进度始终为零,用户体感差。新方案改为分块计算哈希,边算边比较边传。

用于支持断点续传的临时文件

1
2
3
4
5
6
7
8
9
10
11
RDF/1
Version: 1
Algorithm: <0..15>
File-Size: <uint64> ; bytes
Chunk-Size: <uint64> ; bytes
Chunks: ceil(File-Size / Chunk-Size)
Chunk-Status-Length: ceil(Chunks / 8) ; bytes
Fingerprint-Length: L ; hex characters
Fingerprint-Encoding: packed-nibbles (ceil(L/2) bytes, odd L -> low nibble padded)
Trailer-Layout: [L:uint8][File-Size:uint64][Chunk-Size:uint64][Meta:uint8(V<<4|Alg)]
Byte-Order: big-endian for all uint64
1
2
3
4
5
+----------------------+----------------------+----------------------+------------------+
| Payload | Chunk-Status | Fingerprint | Trailer |
| (0..N) | ceil(Chunks/8) B | ceil(L/2) B | 18 B |
+----------------------+----------------------+----------------------+------------------+
^ EOF

Client 端

TODO

做这个项目有两个原因,一是看到了下图中的树洞帖子,回想起本科维护过的民间数学机考刷题网站;二是 AI 崛起,国产 Deepseek 成绩瞩目,想动手尝试一次尽量使用 AI 编写含前后端的项目。

alt text

课表查询

申请到的校园 API 信息量太少,难以实现检索,故只能从门户中寻找有用的接口。上课摸鱼了三天,一直在整理数据来源和思考这些数据如何整合,实现 1+1>21 + 1> 2

flowchart LR
    subgraph A[第一阶段:单源处理]
        A1[数据采集] --> A2[预处理与结构化]
    end

    subgraph B[第二阶段:多源整合]
        B1[以主体为核心] --> B2[特征匹配与交叉扩展]
    end

    A --提供--> B
    B --形成--> C1

    subgraph C[最终目标]
        direction LR
        C1[完整、一致的数据集]
        C1 --> D[数据建模]
        D --> E[✅ 支持高效查询与分析]
    end

数据采集与预处理流程

flowchart LR
    A[选定目标数据源<br>网页/API] --> B{评估稳定性与可靠性}
    B -- 不可靠 --> A
    B -- 可靠 --> C[爬取数据]
    C --> D[原始数据<br>(JSON/HTML/文本等)]
    D --> E{数据是否为非结构化文本?}
    E -- 是 --> F[使用正则表达式等方法<br>提取与分割关键信息]
    E -- 否 --> G[直接解析结构化的数据<br>(如JSON)]
    F --> H[初步结构化数据]
    G --> H
flowchart LR
    A[开始爬取任务] --> B[读取爬取目标参数<br>e.g. URL, 接口, 日期]
    B --> C{查询数据库记录<br>该目标是否已爬取过?}

    C -- 未爬取过 --> D[执行爬取任务]
    C -- 已存在记录 --> E{强制更新?}

    E -- 是 --> D
    E -- 否 --> G[跳过爬取<br>使用现有数据]

    D --> H[保存/覆盖原始数据至<br>'源数据存储']

    H --> I[更新或插入爬取记录至<br>'爬取记录表'<br>记录目标参数、时间、状态等]
    I --> J[结束本次任务]
    G --> J

多源数据交叉扩展流程

flowchart LR
    A[初始数据集合<br>(以某一主体为核心)] --> B(提取关键特征<br>如ID、名称、时间等)

    subgraph S[多源数据扩展]
        direction LR
        B --> C[根据特征匹配数据源2]
        B --> D[根据特征匹配数据源3]
        B --> E[根据特征匹配数据源...]
        C -- 补充信息 --> F[扩展后的数据记录]
        D -- 补充信息 --> F
        E -- 补充信息 --> F
    end

    F --形成--> Z[更完整的数据记录]

数据存储与查询

flowchart LR
    A[完整数据] --> B[数据建模]
    B --> C[映射与保存至数据库]

    subgraph C [数据库表]
        direction LR
        T1[Teacher]
        T2[Room]
        T3[Semester]
        T4[...]
    end

    C --> D[利用外键关联<br>支持高效复杂查询]
    C --> E[快速列举特征<br>前端支持选择特征]

在构造复杂查询函数时,AI 表现不佳,很像刚学数据库的小白。

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
async searchCoursesInner(searchDto: SearchCourseDto): Promise<Course[]> {
const query = this.courseRepository
.createQueryBuilder('course')
.leftJoinAndSelect('course.courseOfferings', 'courseOffering')
.leftJoinAndSelect('courseOffering.department', 'department')
.leftJoinAndSelect('courseOffering.academicYear', 'academicYear')
.leftJoin('courseOffering.semester', 'semester')
.addSelect(['semester.id', 'semester.type'])
.leftJoin('courseOffering.instructors', 'instructor')
.addSelect(['instructor.id', 'instructor.name'])
.leftJoin('courseOffering.classSessions', 'classSession')
.addSelect(['classSession.id', 'classSession.dayOfWeek', 'classSession.startSection', 'classSession.endSection', 'classSession.startWeek', 'classSession.endWeek'])
.leftJoin('classSession.room', 'room')
.addSelect(['room.id', 'room.name'])
.leftJoin('classSession.relateRooms', 'relateRoom')
.addSelect(['relateRoom.id', 'relateRoom.name']);

// 处理课程名称搜索
if (searchDto.title) {
query.leftJoin('course.detail', 'detail')
.addSelect(['detail.englishName'])
query.andWhere('(course.name LIKE :title OR detail.englishName LIKE :title)', {
title: `%${searchDto.title}%`
});
}

const applyCourseOfferingConditions = (
qb: WhereExpressionBuilder,
searchDto: SearchCourseDto
) => {
// 院系条件
if (searchDto.departmentId) {
qb.andWhere('courseOffering.departmentId = :departmentId', {
departmentId: searchDto.departmentId
});
}

// 教师姓名精确匹配
if (searchDto.instructorId) {
qb.andWhere('instructor.id = :instructorId', {
instructorId: searchDto.instructorId
});
}

// 学年条件
if (searchDto.academicYearId) {
qb.andWhere('courseOffering.academicYearId = :academicYearId', {
academicYearId: searchDto.academicYearId
});
}

// 学期条件
if (searchDto.semesterId) {
qb.andWhere('courseOffering.semesterId = :semesterId', {
semesterId: searchDto.semesterId
});
}
}

const applyClassSessionConditions = (
qb: WhereExpressionBuilder,
searchDto: SearchCourseDto
) => {
// 教室条件(修复relateRooms关联问题)
if (searchDto.roomId !== undefined) {
qb.andWhere(
new Brackets(subQb => {
subQb.where('room.id = :roomId', {
roomId: searchDto.roomId
})
.orWhere('relateRoom.id = :roomId', {
roomId: searchDto.roomId
});
})
);
}

// 周次范围
if (searchDto.weeks?.length === 2) {
const [startWeek, endWeek] = searchDto.weeks;
qb.andWhere('classSession.startWeek >= :startWeek', {startWeek})
.andWhere('classSession.endWeek <= :endWeek', {endWeek});
}

// 节次范围
if (searchDto.sections?.length === 2) {
const [startSection, endSection] = searchDto.sections;
qb.andWhere('classSession.startSection >= :startSection', {startSection})
.andWhere('classSession.endSection <= :endSection', {endSection});
}

// 星期几条件
if (searchDto.dayOfWeek !== undefined) {
qb.andWhere('classSession.dayOfWeek = :dayOfWeek', {
dayOfWeek: searchDto.dayOfWeek
});
}
}

// 构建复杂查询条件
query.andWhere(
new Brackets(qb => {
// 二级条件(CourseOffering级别)
applyCourseOfferingConditions(qb, searchDto);

// 三级条件(ClassSession级别)
applyClassSessionConditions(qb, searchDto);
})
);

return query.getMany();
}

alt text
alt text

空闲教室规划

使用类似方法获取和更新空闲教室数据。
给定时间段[s,t][s, t],从教室集合中选择 NN 条路径(可更换教室),使得总使用时间覆盖[s,t][s, t],且移动距离最小。算法通过启发式搜索(优先队列 + BFS)实现。

flowchart TD
    A[开始] --> B[预处理教室空闲时间段排序]
    B --> C[初始化优先队列]
    C --> D{优先队列是否为空?}
    D -- 是 --> E[输出解(反转解队列)]
    D -- 否 --> F[弹出当前状态<br>count, distance, endTime, path]
    F --> G{检查剪枝条件<br>已有10个解且当前距离>堆顶距离?}
    G -- 是 --> D
    G -- 否 --> H{endTime >= t?}
    H -- 是 --> I[加入解队列并保持最多10个解]
    I --> D
    H -- 否 --> J[尝试延续当前教室]
    J --> K[生成新路径并加入队列]
    H -- 否 --> L[尝试切换教室]
    L --> M[生成新路径并加入队列]
    K --> D
    M --> D

alt text

数据库表

使用 TypeORM 同步和操作数据库。无论是 ChatGPT 还是 DS,都能很好地完成任务。
alt text

起名与域名注册

在 AI 的帮助下,构建它只花了一周,所以起名为 7Day。
alt text

部署

部署在校内 CLab。
alt text
alt text

更新日志

2025-09-16

  1. 🐞 修复无法按照校区、教学楼、楼层检索课程的问题(错误将区域 ID 当作教室 ID,ANTD 级联未设置只允许勾选叶子节点)。 @安茗炫
  2. 🆕 支持多关键词搜索课程中英文名称、描述。
  3. 💄 更新二维码、关于我们以及首页文案。
  4. 💄 更新项目依赖。

引言

本次实训的需求为颐养中心。需求书和任务书的具体内容就不再赘述。

本篇文章的主要内容:如何在满足需求的基础下提高程序的可用性,核心功能的实现原理分析与具体代码,反思与总结。

把自己的思路整理并分享,于我,是一种享受,亦是一种反思、提升。

语言

众所周知,实训要求使用 Java 的 JavaFX 或 Swing 实现。不允许使用 Web 开发、不允许使用数据库。

对于许多大牛而言,Vue 早已盛行,Electron 横扫客户端,Go 渗入后端。这些语言、框架早已替代 JavaFX。

但是,有限且落后的语言,并不能阻止学习的步伐。决定一个程序员的上限的,不是程序员所使用的语言所决定的。

正文

总体页面布局

alt text

左侧个人信息栏和菜单栏。用于显示具有权限的菜单和个人信息。

右侧为主区域,上方放置操作按钮,下方放置列表。

左下方放置当前应用程序的基础信息。

上方为标题栏。标题栏居中放置标题,左侧放置 logo,右侧放置窗口按钮。

实现自定义窗口样式和阴影

这里的布局,没有使用第三方库。(因为我看了下第三方库自带的窗口样式,都不符合本次需求的主题)

我们只需要使用以下代码,即可隐藏 JavaFX 默认的窗口样式。

1
this.primaryStage.initStyle(StageStyle.TRANSPARENT);

但是,问题随之而来。隐藏默认的窗口样式后,会使得标题栏和边框及边框阴影消失。

即:关闭、最小化、标题都会消失,窗口没有阴影会显得与其他应用程序融为一体。

那么,我们就来解决这些问题。

实现自定义标题栏

标题栏就需要自己画一个。只需要利用 Scene Builder 和 BorderPane 即可轻松画出标题栏。

alt text

接下来,我们要实现事件响应,即关闭等按钮和窗口拖拽事件响应。

主要难点在实现窗口拖拽上。

alt text

因为这里涉及到复杂的坐标计算,我直接给出最终的代码。

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
package com.pension.utils.windowDrag;

import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.input.MouseEvent;
import javafx.stage.Stage;

public class DragListener implements EventHandler<MouseEvent> {

private double xOffset = 0;
private double yOffset = 0;
private final Stage stage;

public DragListener(Stage stage) {
this.stage = stage;
}

@Override
public void handle(MouseEvent event) {
event.consume();
if (event.getEventType() == MouseEvent.MOUSE_PRESSED) {
xOffset = event.getSceneX();
yOffset = event.getSceneY();
System.out.println(xOffset + " " + yOffset);
} else if (event.getEventType() == MouseEvent.MOUSE_DRAGGED) {
stage.setX(event.getScreenX() - xOffset);
if(event.getScreenY() - yOffset < 0) {
stage.setY(0);
}else {
stage.setY(event.getScreenY() - yOffset);
}
}
}

public void enableDrag(Node node) {
node.setOnMousePressed(this);
node.setOnMouseDragged(this);
}
}
1
2
3
4
5
6
7
8
9
10
package com.pension.utils.windowDrag;

import javafx.scene.Node;
import javafx.stage.Stage;

public class DragUtil {
public static void addDragListener(Stage stage, Node root) {
new DragListener(stage).enableDrag(root);
}
}

引入以上两个类到 Util 中,然后使用以下代码给节点绑定事件。

1
2
3
// 响应自己的标题栏的拖拽
// 第一个参数为舞台地址,第二个参数为标题栏所在的节点
DragUtil.addDragListener(primaryStage, controller.top);

实现窗口阴影

如果不实现窗口阴影,就是下面这种效果。没有窗口阴影会使得应用程序之间的分界不明显。

alt text

下面是加了窗口阴影后的效果。

alt text

经过大量尝试,没有一种方法可以直接实现原生的窗口阴影效果。因为,无论使用哪一种 Effect 效果,都只能使得阴影在节点的内部出现。要注意的是,我们这里要添加的阴影节点是最外层的节点。Dropshadow 可以实现在某个节点的外部添加阴影。但是,如果该节点(A)不存在父节点(B),那么给节点 A 添加 Dropshadow 会导致此阴影无法显示。

那么,既然不能给不存在父节点的子节点添加 Dropshadow 效果,我们就使用如下思路实现窗口阴影。

将我们的根面板(根面板指包含标题栏在内的用于显示应用程序全部内容的面板)整体移入至一个新面板中,使得根面板成为新面板的子节点。如下图所示,第一个 BorderPane 是新面板,第二个则是我们的根面板。为了使得阴影可以被显示出来,我们让根面板的大小比新面板要小一些。也就是说,给根面板添加 Padding,使得四条边与新面板的边界都保持一定的距离。

alt text

完成以上步骤后,我们设置新面板的背景为 Transparent。给根面板设置 Effect 效果。这里的 Effect 效果,是模仿 QQ 聊天界面窗口的阴影。具体的参数可以自行设定,但我个人感觉 QQ 聊天窗口的阴影比较舒适。

alt text

至此,我们就实现了自定义窗口的阴影效果。

用户管理界面

基础的增删改查功能不做赘述。

多选、批量操作功能实现

最终完成效果如下。

alt text

有些小伙伴利用第三方组件或库不用动手就能实现批量操作。

这里,我们只是利用第三方库来美化组件的样式(也就是你看到的比较漂亮的勾选框和按钮样式)。下面将会讲解批量操作的实现原理。

最初的实现方案是:在构造表格时,给每一行的第一列添加上勾选框。并且给每一个勾选框都添加上监听事件,如果该勾选框被勾选,则标记对应的用户。

显然,这有一个非常致命的缺陷,也就是,如果我们进行了翻页等操作,导致表格被重新渲染(也就是重新构造了整个表格),那么勾选的状态将会丢失。也就是,上面那一种方案,我们只是将勾选框单向地与每一个用户进行了关联。如果我们选中了第一页的 3 个用户,然后将表格翻页至第二页,(第二页不做任何操作)再翻回第一页,那么前面 3 个用户的勾选状态将会丢失。显然,这不符合一般程序的设计规范。

那么,我们将此方案进行修改。

在这一个系统中,每一个用户都是 Person 实体类的对象。我们给 Person 类添加一个属性,这一个属性就是一个勾选框。那么,刚刚的问题就非常好解决。不难发现,我们这样其实是将勾选框和人双向绑定在了一起。无论表格被翻到哪一页,每一个用户的勾选状态都像其他用户属性一样,始终跟随着用户。

如果用户点击了批量操作的按钮,比如批量修改或者批量删除的按钮,那么程序只需要遍历全部的用户,检查每一个用户的勾选状态,即可判定使用者是否勾选了该用户。

裁剪功能

头像裁剪功能随处可见,微信 QQ 上传头像都会提示裁剪头像。

JavaFX 虽然是老古董,但是实现头像裁剪(也就是实现图像裁剪,头像裁剪只不过是 1:1 裁剪)并非不可行。

alt text

实现难点与原理

不难发现,其实只需要处理两个组件,第一个是底部的原始图片,第二个是可以拖拽移动的矩形。

底部原始图片

加载原始图片的时候,需要注意:用户选择的图片是否过小过大,如果是,则需要对图片进行合适的缩放,以保证图片的显示效果。如果需要进行缩放,那么还要考虑到一个问题,用户的图片是否存在图片长宽比异常的问题。设想一下,我的裁剪图像的窗口只有 512x512,如果用户上传了一张 1x10000 的图片(以极端情况举例更好理解,前者为宽),如果你将宽度缩放至 512,那么为了不让图片变形,你就需要将长度缩放至 10000 的 512 倍。显然,这张图片会超出我们的显示窗口。因此,我们的程序要处理好这一类图片的缩放问题,而不能简单除暴地按照长或者宽进行缩放。但无论如何,只有两个缩放方法,要么按照长度缩放,要么按照宽度缩放(按照长度缩放,也就是说,将图片长度缩放到指定像素,而宽度跟随长度改变。)。因此,我们只需要计算两种方法缩放后的图片大小,然后进行判断并选择合适的缩放方法即可。

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
// 计算最终图片大小
// 因为实际判断代码较长,这里只给出图片过大进行缩放的判断代码
double maxWidth = 512;
double maxHeight = 512;
double minWidth = 200;
double minHeight = 200;

double finalWidth = 0;
double finalHeight = 0;

if (image.getHeight() >= maxHeight && image.getWidth() >= maxWidth) {
System.out.println("进行缩放");
// 需要进行图像缩小 此时图片太大
double resizeWidth = image.getWidth() * maxHeight / image.getHeight(); //按照最大高度进行缩放
System.out.println("按照最大高度进行缩放 width = " + resizeWidth + ", height = " + maxHeight);
double resizeHeight = image.getHeight() * maxWidth / image.getWidth();
System.out.println("按照最大宽度进行缩放 width = " + maxWidth + ", height = " + resizeHeight);
if (resizeWidth >= maxWidth) {
// 缩放后超出范围
// 高已经是最大值,但是图片还是很宽,要按照宽度伸缩
finalHeight = resizeHeight;
finalWidth = maxWidth;
} else {
finalHeight = maxHeight;
finalWidth = resizeWidth;
}
} else {
finalHeight = image.getHeight();
finalWidth = image.getWidth();
}

可拖拽的矩形

众所周知,JavaFX 画出一个矩形,同时需要两个参数,第一个参数是矩形左上角那个点的坐标,以及矩形的大小(长和宽)。

知道了这一点,我们就可以实现拖拽改变矩形大小。

alt text

当用户点击(鼠标长按的第一下也记为点击操作)时,获取用户的鼠标位置(坐标 A) ,并记录。

注意:坐标 A 并不一定是矩形的左上角的坐标。因为,如果用户是向左上角拖拽的,那么坐标 A 是矩形右下角的坐标。同理,向右上角拖拽时,坐标 A 是矩形左下角的坐标。

当用户开始拖拽时,获取用户的鼠标坐标 (坐标 B),并记录。

我们可以通过坐标 A 和坐标 B 的相对位置关系,计算出矩形的实际大小和矩形的左上角坐标,并绘制矩形。

需要注意的是,这里使用的坐标轴,X 轴是水平方向,方向向右。Y 轴是垂直方向,方向向下。

还需要注意的是,A 和 B 的坐标差并不是最终的矩形大小。因为我们要实现正方形裁剪,但是 A 和 B 的坐标差并不始终为 1:1。那么,我们这里采取的方案是:如果用户的鼠标拖拽出来的区域大小为 200x250(假设),那么我们就认为用户拖拽出来的大小为 200x200。即:始终取两者的最小值。当然,你也可以始终取最大值,但这与一般逻辑不相符。

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
// 计算矩形的左上角坐标和实际大小,并绘制矩形。
double offsetX = event.getX() - dragContext.mouseAnchorX; // 坐标B的X坐标与A的X坐标相减,获取相对X坐标
double offsetY = event.getY() - dragContext.mouseAnchorY; // 同理
if (offsetY >= 0 && offsetX >= 0) { // B在A的右下角,说明用户是向右下角拖拽的
// 不需要重新设定矩形的坐标,因为默认就是右下角拖拽
// 根据相对位置设定矩形大小
rect.setWidth(Math.min(offsetX, offsetY));
rect.setHeight(Math.min(offsetX, offsetY));
}
if (offsetY < 0 && offsetX < 0) { // B在A的左上角,说明是向左上角拖拽的
// 设定矩形大小(注意1:1问题)
rect.setWidth(Math.min(offsetX * (-1.0), offsetY * (-1)));
rect.setHeight(Math.min(offsetX * (-1.0), offsetY * (-1)));
// 那么,A坐标是矩形的右下角坐标,需要计算得出左上角坐标,这里还需要注意数值的正负问题
rect.setX(ImageCrop.clickX - Math.min(offsetX * (-1.0), offsetY * (-1))); // 这里的ClickX是A坐标的X坐标
rect.setY(ImageCrop.clickY - Math.min(offsetX * (-1.0), offsetY * (-1))); // 同上
}
if (offsetY >= 0 && offsetX < 0) {
rect.setWidth(Math.min(offsetX * (-1), offsetY));
rect.setHeight(Math.min(offsetX * (-1), offsetY));
rect.setX(ImageCrop.clickX - Math.min(offsetX * (-1), offsetY));
}
if (offsetY < 0 && offsetX >= 0) {
rect.setWidth(Math.min(offsetX, offsetY * (-1)));
rect.setHeight(Math.min(offsetX, offsetY * (-1)));
rect.setY(ImageCrop.clickY - Math.min(offsetX, offsetY * (-1)));
}

接下来,我们要实现拖拽移动矩形。实现的思路非常简单,如果用户是在矩形内的长按,那么就触发移动矩形的事件。

在用户点击(鼠标长按的第一下也记为点击操作)时,计算这一次点击事件的坐标 A 与矩形的左上角的坐标 B 的差。我们需要保存这一个差值(X 的坐标差和 Y 的坐标差)。显然,这一个差值,X 和 Y 都肯定是 >0 的,因为点击的位置一定是在矩形内的,也就是一定是在矩形左上角的右下方。

当用户开始拖拽时,获取当前的鼠标位置。利用刚刚保存的坐标差和当前鼠标的坐标来计算矩形的左上角坐标。计算出后,更新矩形的左上角坐标,即可实现拖拽移动。

通过以上的思路,即可实现拖拽生成 1:1 大小的矩形和长按移动。

这里的难点,主要是要充分理解各种鼠标事件的触发的先后顺序,以及计算各种坐标。

批量导入导出功能

批量导出,实现比较简单。批量导出的文件格式为 CSV 格式。CSV 其实是一种以逗号为分隔符的 TXT 文件。如果你尝试将你的一个 TXT 文件的内容修改为"1,2"(不包括引号),然后将文件后缀名修改为 csv。然后双击这个文件,你会发现 Excel 能够打开这样的文件。

alt text

也就是说,我们只要按照这个格式生成 csv 文件,就能实现批量导出信息到 Excel 文件中。

但是,难点在于,从 Excel 文件中批量导入信息到应用程序中。因为,我们平时使用的 Excel 都是默认 xlsx 或者 xls 格式。我们不能指望使用者先将 Excel 文件转成 csv 文件后,再在程序中批量导入。为了提供体验度,我们应该直接适配 xlsx 等 Excel 文件格式。

这里,为了方便读取 Excel 中的每一个单元格,我们使用第三方库:poi-ooxml-4.1。

然后引入下面这一个工具类。这一个工具类可以帮助我们读取 Excel 中的每一个单元格。

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
package com.pension;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;

import org.apache.poi.hssf.usermodel.HSSFFormulaEvaluator;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.FormulaEvaluator;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFFormulaEvaluator;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

/**
* excel文件读取工具类,支持xls,xlsx两种格式
* @author Andrew
*
*/
public class ExcelUtil {

/**
* excel文件读取指定列的数据
* @author Andrew
* @param excelPath 文件名
* @param args 需要查询的列号
* @return ArrayList<ArrayList<String>> 二维字符串数组
* @throws IOException
*/
@SuppressWarnings({ "unused" })
public static ArrayList<ArrayList<String>> excelReader(String excelPath,int ... args) throws IOException {
// 创建excel工作簿对象
Workbook workbook = null;
FormulaEvaluator formulaEvaluator = null;
// 读取目标文件
File excelFile = new File(excelPath);
InputStream is = new FileInputStream(excelFile);
// 判断文件是xlsx还是xls
if (excelFile.getName().endsWith("xlsx")) {
workbook = new XSSFWorkbook(is);
formulaEvaluator = new XSSFFormulaEvaluator((XSSFWorkbook) workbook);
}else {
workbook = new HSSFWorkbook(is);
formulaEvaluator = new HSSFFormulaEvaluator((HSSFWorkbook) workbook);
}

//判断excel文件打开是否正确
if(workbook == null){
System.err.println("未读取到内容,请检查路径!");
return null;
}
//创建二维数组,储存excel行列数据
ArrayList<ArrayList<String>> als = new ArrayList<ArrayList<String>>();
//遍历工作簿中的sheet
for (int numSheet = 0; numSheet < workbook.getNumberOfSheets(); numSheet++) {
Sheet sheet = workbook.getSheetAt(numSheet);
//当前sheet页面为空,继续遍历
if (sheet == null) {
continue;
}
// 对于每个sheet,读取其中的每一行
for (int rowNum = 0; rowNum <= sheet.getLastRowNum(); rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
ArrayList<String> al = new ArrayList<String>();
// 遍历每一行的每一列
for(int columnNum = 0 ; columnNum < args.length ; columnNum++){
Cell cell = row.getCell(args[columnNum]);
al.add(getValue(cell, formulaEvaluator));
}
als.add(al);
}
}
is.close();
return als;
}

/**
* excel文件读取全部信息
* @author Andrew
* @param excelPath 文件名
* @return ArrayList<ArrayList<String>> 二维字符串数组
* @throws IOException
*/
@SuppressWarnings({ "unused" })
public static ArrayList<ArrayList<String>> excelReader(String excelPath) throws IOException {
// 创建excel工作簿对象
Workbook workbook = null;
FormulaEvaluator formulaEvaluator = null;
// 读取目标文件
File excelFile = new File(excelPath);
InputStream is = new FileInputStream(excelFile);
// 判断文件是xlsx还是xls
if (excelFile.getName().endsWith("xlsx")) {
workbook = new XSSFWorkbook(is);
formulaEvaluator = new XSSFFormulaEvaluator((XSSFWorkbook) workbook);
}else {
workbook = new HSSFWorkbook(is);
formulaEvaluator = new HSSFFormulaEvaluator((HSSFWorkbook) workbook);
}

//判断excel文件打开是否正确
if(workbook == null){
System.err.println("未读取到内容,请检查路径!");
return null;
}
//创建二维数组,储存excel行列数据
ArrayList<ArrayList<String>> als = new ArrayList<ArrayList<String>>();
//遍历工作簿中的sheet
for (int numSheet = 0; numSheet < workbook.getNumberOfSheets(); numSheet++) {
Sheet sheet = workbook.getSheetAt(numSheet);
//当前sheet页面为空,继续遍历
if (sheet == null) {
continue;
}
// 对于每个sheet,读取其中的每一行
for (int rowNum = 0; rowNum <= sheet.getLastRowNum(); rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
// 遍历每一行的每一列
ArrayList<String> al = new ArrayList<String>();
for(int columnNum = 0 ; columnNum < row.getLastCellNum(); columnNum++){
Cell cell = row.getCell(columnNum);
al.add(getValue(cell, formulaEvaluator));
}
als.add(al);
}
}
is.close();
return als;
}

@SuppressWarnings("deprecation")
private static String getValue(Cell cell, FormulaEvaluator formulaEvaluator) {
if(cell==null){
return null;
}
switch (cell.getCellType()) {
case STRING:
return cell.getRichStringCellValue().getString();
case NUMERIC:
// 判断是日期时间类型还是数值类型
if (DateUtil.isCellDateFormatted(cell)) {
short format = cell.getCellStyle().getDataFormat();
SimpleDateFormat sdf = null;
/* 所有日期格式都可以通过getDataFormat()值来判断
* yyyy-MM-dd----- 14
* yyyy年m月d日----- 31
* yyyy年m月--------57
* m月d日 --------- 58
* HH:mm---------- 20
* h时mm分 --------- 32
*/
if(format == 14 || format == 31 || format == 57 || format == 58){
//日期
sdf = new SimpleDateFormat("yyyy-MM-dd");
}else if (format == 20 || format == 32) {
//时间
sdf = new SimpleDateFormat("HH:mm");
}
return sdf.format(cell.getDateCellValue());
} else {
// 对整数进行判断处理
double cur = cell.getNumericCellValue();
long longVal = Math.round(cur);
Object inputValue = null;
if(Double.parseDouble(longVal + ".0") == cur) {
inputValue = longVal;
}
else {
inputValue = cur;
}
return String.valueOf(inputValue);
}
case BOOLEAN:
return String.valueOf(cell.getBooleanCellValue());
case FORMULA:
//对公式进行处理,返回公式计算后的值,使用cell.getCellFormula()只会返回公式
return String.valueOf(formulaEvaluator.evaluate(cell).getNumberValue());
//Cell.CELL_TYPE_BLANK || Cell.CELL_TYPE_ERROR
default:
return null;
}
}
}

引入工具类后,只需要通过以下代码,即可获取指定 Excel 文件的全部内容。返回的格式是一个二维字符串集合。

1
ArrayList<ArrayList<String>> arrayLists = ExcelUtil.excelReader(file.getAbsolutePath());

那么,如果你通过上述代码获取下图的 Excel 内容,你将会获得一个一维长度为 4 的二维字符串集合。

alt text

1
2
3
4
// 下面一行得到的结果是:“测试2”
arrayLists.get(0).get(1); // 即单元格B2
// 下面一行得到的结果是: null,因为单元格的内容为空。
arrayLists.get(1).get(3); // 即单元格D2

但是,请注意:如果你运行下列代码,Java 虚拟机将会报错。以下代码将产生数组越界的错误。

1
2
// 下面一行将报错
arrayLists.get(2).get(4); // 即获取Excel表格中的位于第3行第五列的单元格,即单元格E3

因为你需要注意的是,在 Java 中二维数组的每一行的长度不一定完全相同。不像 C 语言一样,你创建一个 int[4][3]二维数组,每一行都有 3 个数字。

1
2
arrayLists.get(2).size(); // 返回的结果是4
arrayLists.get(0).size(); // 返回的结果是5

但是,如果你将 E3 单元格涂上黄色(也就是给这一个单元格的背景设置为黄色,单元格内依旧不填写任何内容),再次运行以上代码,你将发现以下结果。

1
2
3
// 下面一行不再报错,而是返回结果 null
arrayLists.get(2).get(4); // 单元格E3
arrayLists.get(2).size(); // 返回的结果是5

不难发现,其实读取 Excel 文件需要注意非常多的问题。一个单元格是否为空,不完全取决于这个单元格是否存在文本内容。每一行读取的长度并不完全相同。

查阅资料发现,读取 Excel 表格一直是一个难题。因为还涉及到单元格内容格式、单元格样式、单元格隐藏、多工作表、单元格内存在复杂 Excel 公式、单元格合并、多版本 Excel 文件格式兼容等问题。

有了以上的铺垫,我们就能顺利地实现从 Excel 文件中导入数据到应用程序中。当然,在解决 Excel 文件的复杂读取后,你还需要对用户提供的数据进行校验。例如,我们的每一个用户具有身份证、职位等信息。那么,导入时,就需要校验 Excel 文件中身份证一列的数据有效性,还要避免用户借助 Excel 批量导入的功能实施越权操作。

alt text

模板管理界面

所见即所得(拖拽添加)功能

这一个功能同样没有借助任何第三方库实现。实现原理可以扩展到任何需要拖拽功能的应用上。

拖拽添加功能演示(拖拽到哪添加到哪)
alt text

排序功能演示
alt text

拖拽功能实现原理

先让我们来思考几个问题,你在拖拽的时候发生了什么?拖拽的时候是什么东西在移动?从 A 拖拽到 B 的时候,如何实现数据交换?

不要把问题想复杂了,第一个问题的答案就是发生了鼠标移动的事件。

第二个问题的答案就是鼠标。

那么,在回答第三个问题前,我们先来思考一下鼠标的作用。鼠标,真的只是一个可以供你移动的图标吗?

经过查阅资料,鼠标其实是一个快递员。也就是中间人。也就意味着,鼠标是可以携带信息的。

那么第三个问题不就非常好回答了,在 A 上发生拖拽事件时,把信息传递给鼠标,然后拖拽到 B 处释放时,B 向鼠标索要信息。

借此,我们就能轻松实现拖拽添加的事件。

让我们来总结一下整个原理。

现在我们有左右两个列表。左侧列表 A 是全部问题(除去已经出现在 B 中的问题),右侧列表 B 是当前问卷的问题集合。注意:同一个问题不能同时出现在两个列表中,你不能让同一份问卷被多次添加同一个问题。当用户在左侧列表的某一行上(某一个问题)拖拽时,将这个问题的 ID 赋值给鼠标。当鼠标进入右侧列表,并且释放时,右侧列表向鼠标索要问题的 ID,然后右侧列表根据这一个 ID 在数据库中找到这一个问题,并把这一个问题添加到当前问卷。

  1. 难点 1

大致的过程,我们已经了解清楚了。但是,有几个细节我们需要注意。

刚刚我们提到,同一个问题,不能同时出现在两侧列表中。你不能想当然地这样写代码:当一个问题从左侧拖出时,就把这一个问题从左侧列表移除。万一用户只从左侧拖出,但是没有拖入到指定的位置(也就是右侧列表中的任何一个问题上)呢?显然,从左侧拖出,不代表着已经被拖入到右侧列表了。所以,这里需要检测用户是否已经拖入到右侧并且释放。

  1. 难点 2:拖到哪添加到哪

为了让效果更加完美,我们要实现:拖到哪添加到哪(假设右侧已经有 3 个问题 A、B、C,如果这个时候拖入问题 D 到 B 上,那么右侧的顺序应该为 A、D、B、C;如果拖入 D 到 A 上,那么右侧的顺序为 D、A、B、C;如果拖拽到末尾的空白处,那么右侧的顺序应该为 A、B、C、D)。

那么,想要实现上述功能,逻辑应该是这样的:

1.左侧列表监听到拖拽事件,把被拖拽的问题的 ID 赋值给鼠标

2.右侧列表中的某个问题监听到拖拽进入事件,保存当前鼠标的位置。(注意,这里不是右侧列表监听到拖拽进入事件)

3.右侧列表监听到拖拽释放事件,获取鼠标的问题 ID,获取鼠标的位置(这里的位置不是鼠标的 XY 坐标,是步骤 2 中保存的列表位置),然后将这一个问题添加到列表的指定位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 监听鼠标进入事件(注意:如果是鼠标拖拽时进入此cell,并不会触发hover事件)
vBox.setOnDragEntered(event -> {
System.out.println("右侧元素监听到了鼠标拖拽进入事件");
rightListHoverQuestionID = Integer.parseInt(vBox.getId().split(" - ")[1]);
int nowPosition = -1;
int _c = -1;
for (VBox vBox2 :
rightList.getItems()) {
_c++;
if (vBox.getId().split(" - ")[1].equals(vBox2.getId().split(" - ")[1])) {
nowPosition = _c;
break;
}
}
rightList.getSelectionModel().select(nowPosition);
});
  1. 难点 3:右侧列表的上下排序

JavaFX 的 ListView 组件并不提供交换两行的函数。那么,我们就需要自己实现。实现的时候,注意极端情况。因为我们在右侧的列表中添加了一个空行(也就是最后一行“拖拽到此处添加到末尾”),我们还需要注意右侧列表的实际问题数量其实是右侧列表的行数量-1。极端情况也不多,只有一个问题的时候,移动是无效的;一个问题在最下面,那么向下移动是无效的。同理,一个问题在最上面,那么向上移动是无效的。

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
/*
* @param questionID
* @param operation
* @return void
* @author CN-NEU-20206689
* @description: 右侧每个问题的上下移动按钮的事件
* @date 2021/7/22 10:20
*/
private void rightListUpDownQuestion(String questionID, int operation) {
// 如果operation为-1,则执行下移操作。1为上移操作
if (rightList.getItems().size() <= 2) {
// 只有一个问题或者没有问题
return;
}
// 先到这个问题的位置
int nowPosition = -1;
int _c = -1;
for (VBox vBox :
rightList.getItems()) {
_c++;
if (vBox.getId().split(" - ")[1].equals(questionID)) {
nowPosition = _c;
break;
}
}
// 如果是上移并且问题在第一个,那么不需要操作
if (operation == 1 && nowPosition == 0) {
return;
}
// 下移同理
if (operation == -1 && nowPosition == rightList.getItems().size() - 2) {
return;
}
ObservableList<VBox> observableList = FXCollections.observableArrayList();
// 核心:交换两行
for (int i = 0; i < rightList.getItems().size(); i++) {
if (i == nowPosition + (operation == -1 ? 0 : -1)) {
observableList.add(rightList.getItems().get(i + 1));
observableList.add(rightList.getItems().get(i));
i++;
} else {
observableList.add(rightList.getItems().get(i));
}
}

rightList.setItems(observableList);

}

其实,实现这个功能并不复杂。主要的难点在于:如何快速地实现数据交换、理解各种复杂的拖拽事件。

值得一提的是,实现这个功能的时候,不要把两个列表视作一个整体。左侧列表只负责响应开始拖拽事件。右侧列表只负责响应拖拽释放事件。右侧列表的每一行只负责响应拖拽进入事件。他们之间的关联只在于,同一个问题不能同时出现在两个列表中。

其实进一步思考,你能体会到操作系统设计的巧妙之处。鼠标不仅仅是鼠标。

评估记录界面

问卷快照功能

此功能的实现灵感来自陆陆同学。

那一天,陆陆问我:我能不能把整一个 AnchorPane 存到 JSON 中,这样我不就实现了还原用户操作的功能吗?

显然,AnchorPane 是不能存到 JSON 中,因为界面是 JavaFX 渲染出来的,用户的操作是虚拟的,获取 AnchorPane 只能保存布局,而不能保存用户的操作。

但是,如果我们利用节点的截图功能,那我们就能保存用户操作时所看到的界面。

实现原理

对节点截图非常简单,只需要通过以下的代码。

1
2
3
4
5
6
7
8
9
10
SnapshotParameters params = new SnapshotParameters();
params.setFill(Color.TRANSPARENT);// 设置透明背景或其他颜色
WritableImage image = node.snapshot(params, null); // node为即将被截图的节点
File file = new File(System.getProperty("user.dir") + File.separator + "node.png");
try {
ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", file);
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("生成快照完成,图片路径:" + file.getAbsolutePath());

显然,这个功能的实现不会这么简单。

因为,我们显示问卷评估界面的时候,是将所有问题放到一个 ScorePane 里面。这样,当问题过多的时候,会显示滚动条。但是,这也带来一个问题,如果我们想要对整个问卷进行截图,我们只能对 ScorePane 进行截图。但是,JavaFX 的截图特性是,只能截取已经显示的部分。也就是,在对 ScorePane 这一类可以滚动的面板进行截图时,是不会截取到没有滚动到的部分的。

说白了,我们要对 ScorePane 实现长截图的功能(和手机的长截图功能相似)。

既然是实现长截图功能,我们就借鉴手机实现长截图的思路(即:先截取一部分,然后滚动到下一部分,然后再截取,然后拼接全部的部分)。

但是,直接套用这个思路到 JavaFX 上会发现,获取 ScorePane 的现有位置并进行滚动,是一件困难的事情。因为你还要考虑到一个问题,假如 ScorePane 的内容高度为 600 像素,ScorePane 的显示高度为 400 像素,也就是有 200 像素需要通过滚动才能看到。如果你先对 ScorePane 的最上面 400 像素进行截图,然后再滚动到最下方进行截图,你会发现中间有 200 像素的内容是重复的。这意味着,你在合并截图时非常困难。据我所知,JavaFX 只能对一个节点进行完整截图,而不能选取一个节点的部分区域进行截图。

难道,我们就要放弃这种思路了吗?

不,我们把长截图的思路稍作转换。我们的 ScorePane 的子节点是一个个问题。如果我们对每一个问题节点进行截图,然后进行拼接,就完成了长截图的操作。这种转换后的思路,显然更加快捷且是有效的。

这里我们需要借助到工具类以实现图像拼接。

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
package com.pension.utils.image;

import java.awt.image.BufferedImage;
import java.io.IOException;

public class MergeImageUtil {
public static BufferedImage mergeImage(BufferedImage img1,
BufferedImage img2, boolean isHorizontal) throws IOException {
int w1 = img1.getWidth();
int h1 = img1.getHeight();
int w2 = img2.getWidth();
int h2 = img2.getHeight();

// 从图片中读取RGB
int[] ImageArrayOne = new int[w1 * h1];
ImageArrayOne = img1.getRGB(0, 0, w1, h1, ImageArrayOne, 0, w1); // 逐行扫描图像中各个像素的RGB到数组中
int[] ImageArrayTwo = new int[w2 * h2];
ImageArrayTwo = img2.getRGB(0, 0, w2, h2, ImageArrayTwo, 0, w2);

// 生成新图片
BufferedImage DestImage = null;
if (isHorizontal) { // 水平方向合并
DestImage = new BufferedImage(w1+w2, h1, BufferedImage.TYPE_INT_RGB);
DestImage.setRGB(0, 0, w1, h1, ImageArrayOne, 0, w1); // 设置上半部分或左半部分的RGB
DestImage.setRGB(w1, 0, w2, h2, ImageArrayTwo, 0, w2);
} else { // 垂直方向合并
DestImage = new BufferedImage(w1, h1 + h2, BufferedImage.TYPE_INT_RGB);
DestImage.setRGB(0, 0, w1, h1, ImageArrayOne, 0, w1); // 设置上半部分或左半部分的RGB
DestImage.setRGB(0, h1, w2, h2, ImageArrayTwo, 0, w2); // 设置下半部分的RGB
}

return DestImage;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 对节点进行依次截图 */
bufferedImage = null;
for ( int i = 0; i < vBoxMFXListView.getItems().size(); i++ )
{
SnapshotParameters params = new SnapshotParameters();
params.setFill( Color.WHITE );
WritableImage image = vBoxMFXListView.getItems().get( i ).snapshot( params, null );
/* 对节点截图进行拼接 */
if ( bufferedImage == null )
{
bufferedImage = SwingFXUtils.fromFXImage( image, null );
} else {
try {
bufferedImage = MergeImageUtil.mergeImage( bufferedImage, SwingFXUtils.fromFXImage( image, null ), false );
} catch ( IOException e ) {
e.printStackTrace();
}
}
}

alt text

其他界面

其他界面因为只是 JavaFX 基础组件的运用,以及一些基础功能的实现,这里就不再做赘述了。

alt text

alt text

反思

因为第一次使用 Java 语言制作应用程序,在数据结构的设计方面不大理想,尽管后期进行了一定的修改和弥补,但还是有相当一部分的结构不是最优的。经过此次的实训,我发现我在软件架构和数据结构设计方面有所欠缺。

在设计方面,经过一系列客观评价,总体设计处在能看能用的水平。上次假期阅读的《写给大家看的设计书》也派上了用场。

Java 方面,加深了一些概念的理解。不难发现,程序中涉及到大量的单元格渲染,这导致整个应用程序的首次响应时间非常长。我尝试着使用多线程、节点缓存等方法进行优化,但效果非常不理想。很遗憾,这次没用上 Maven 来构建项目。

实训有收获了不少,学会如何整理需求文档,预先安排好项目结构,做好一定的项目规划,试着开发更加先进且实用的功能。尝试从零实现一个功能,能学习到非常多方面的知识。

虽然这是一次实训任务,但就像我开发墨灵一样,把一个应用程序设计成“可用、实用、易用”而不仅仅是“能用”。

说明

本次项目基于 JDK16,JavaFX SDK11。因为与大多数同学的环境不同,故源代码就不再整理开源了(本项目依赖了超过 15 个库,因为第一次开发 Java 项目,还没熟悉 Maven,所以开源也非常麻烦)。

To 学弟们

如果你想要高效地完成实训任务,建议你采取以下步骤:

  1. 电脑配置好 JDK 16+
  2. 下载 Scene Builder 11
  3. 下载并安装 IDEA、配置 JDK16、下载并配置 JavaFX SDK11
  4. 充分使用 JFoenix 等第三方库
  5. 完成需求书,而不是完成任务书。做好界面,而不是让实践报告看上去又臭又长。
  6. 准备好风扇以及网络通讯设备。
  7. 时刻做好答辩准备。
  8. 从第一天开始做,而不是从第三周开始做。

alt text

alt text

(。•ˇˇ•。)我没能给墨灵过上 4 岁生日。
关闭之际,我想回顾这近 1300 天的历程。
墨灵是 mkplayer 开源项目的改进版,也是我作为计算机初学者的第一个练手的项目。诞生之际,我没想过会有这么多人会去用它。
随着墨灵的长大,它的经历越来越丰富,故事越来越多。

技术

前端

JS

当时只学会了最基本的 JS,还没接触 Vue 等。没有使用 ES6,因为浏览器兼容(vivo 手机浏览器…)问题。

没用上 Worker 、PWA 那些玩意。使用各种小玩意来优化前端性能。不得不叹息一句:WebSQL 普及的真差。

总有人问我,前端 JS 为什么加密?前端加密一定是可解的。接口不希望轻易暴露,加密也只防君子不防小人。事实证明:It worked.

CSS

大量使用动画,使用完善的 WEUI 库来支持夜间模式。是从 WEUI 库的源码中学会如何只使用 CSS 来完成夜间和白天的主题切换。
alt text
alt text

异常上报

使用 Sentry、frontjs 平台监控。主要用来防止更新版本后 bug 爆发。

埋点统计

依次使用了百度统计、Google Analysis、自建程序,最后回到了百度统计。因为埋点过多,自部署的开源埋点无法处理大量的数据导致服务器屡次阵亡,最后放弃。百度统计比谷歌统计更加清晰;谷歌统计非常细化,只适用于商业化站点。

www.frontjs.com


后端

PHP

至今还在疑惑 PHP 为何会 503。现在能说得通是肉鸡扫服务器时提交了垃圾数据,程序没有做异常处理,然后。

无数据库:因为不需要。程序的缓存和大量小文件数据都存放在高效的文件系统中。

唯一遇到的问题就是数据迁移与备份。硬盘中存储了超过 900 万的缓存。初期未预知到文件这么多,导致硬盘 inode 爆满。

alt text

部署

服务器

为了控制成本(穷),各种优惠服务器真是能嫖就嫖。主站、API、鉴权分别部署在三台主机上,这些服务器高性能高 IO,稳定可靠。下载服务器部署在若干主机上,这些服务器低性能、高带宽高存储。

前后大大小小的服务器部署了至少有 50 台。牢记:快照、先备份再操作。

域名

域名前后换了几个。还是 COM 最香。

DNS

用的 DNSPOD 企业基础版,主要用到的是负载均衡功能。下载独立使用一个域名,根据主机权重在 DNS 层面实现负载均衡。这样最简单,但显然这样的负载均衡不是严格按照权重分配的。

CDN

用的腾讯云全球 CDN。特殊时期: CLoudflare Pro 版本。阿里 CDN 功能过于细分,不适合。腾讯云 CDN 无攻击减免,下图为被攻击惨况。

alt text

运行状态

主机监控使用的是阿里云云监控,用于监控全部主机的 CPU 等核心信息;
alt text

外部监控使用的是 UptimeRobot PRO(一个国外的平台),用于监控域名、主机、CDN 的外部可通性和可用性。

alt text

攻击

这三年,很荣幸体会到 DNS FLOOD 攻击、云服务器 DDOS 攻击、CDN DDOS 攻击、API 接口伪造攻击。没有很好的对付策略,放平心态。用 PHP 写了一套自己的 IP 评分机制,用来阻止 API 接口攻击。
alt text
alt text

其他

域名多次被腾讯封锁,猜测是被别人恶意举报,被网友们提交申诉救回来了。是个奇迹,我自己没想到能起死回生。

因为兼容性强,用上了付费 SSL 证书。为国内环境开启了 OCSP。

版本控制

没用上 Git 来实现版本控制,无法追溯 bug 让人头痛。

一些数据

持续运行了 1300 天左右;每天播放约 70 万首歌曲(不去重);每天平均搜索过 13 万次(不去重);用户群创建了 12 个,后面建不动群了,干脆就把整个群清空再加人;代码除去库,标准格式化后估摸 1 万行,感觉没啥意义。

总结

值得被吐槽的地方很多,当时的我还要兼顾高考,只能做到这水平了。
如果有机会,肯定会用上最新更适合的技术来写。

每个部分实现起来并不复杂。然鹅,完整地跑起来并让它持续地跑是一件相当有挑战的事情。一个人顾及方方面面,确实不易。因为平时很忙,所以断断续续地在维护。维护的不连续性也产生了很多麻烦。

现在回看,我觉得最糟糕的是:很多东西没有深入理解或者学习,就用上了。比如 :SSL 原理不懂,就给部署上了。JS、CSS 没系统学,边写边抄。

下一次的目标

或许有一天我会“卷土重来”。始终维护旧项目,会让自己的技术落后,一直困在舒适圈里面。至始至终,代码是一个人在写。所以,都是自己想怎么来就怎么来。如果有那么一天,我会进行重构。模块化、组件化、规范化,是下一次的目标。有感于文章


非技术总结

刚部署之时

起初,我只是当作一个练手程序去做。所以,我的绝大多数时间都是用在“写新功能”(自娱自乐)上。那个时候,更新墨灵是一件非常简单的事情:写代码 → 推送到服务器 → 完成。

小有名气之时

当墨灵的用户越来越多的时候,问题随之暴露。

  1. 第一个阶段添加的新功能,未做浏览器兼容测试,未做细节优化,功能只是处于能用的阶段。一个好的程序的功能应该是“易用的”,而不是“能用的”。随着用户提交的反馈越来越多,墨灵开始细化功能,打磨用户的每一次点击。
  2. 我发现我也不能再像之前一样更新墨灵,因为我的一次隐藏着 bug 的更新会波及到非常多的用户。所以,更新墨灵变成了一件富有挑战的事情:收集用户反馈、确定要更新的内容 → 编写代码 → 本地多次测试 → 凌晨推送更新 → 线上测试 → 决定是否回滚代码 → 完成。 挺累,但挺好。

初具规模

(╯‵□′)╯︵┴─┴ 当墨灵的用户再翻倍的时候,问题又来了。

  1. 服务器“爆炸”了。服务器其实没有那么神秘,它和你家的电脑、手机没有本质区别。服务器爆炸意味着服务器太忙了,也意味着该加钱了。
  2. 费用“爆炸”了。买服务器要钱,钱包受伤。
  3. 我“爆炸”了。服务器满载,我挺开心的,自己的东西能被人用,谁不开心呢?然而,我发现了不善的访客,他们在恶意消耗服务器资源。墨灵一直没有做登录功能,不是我不能,是我不想。对于用户而言,登录是一种麻烦。对于我而言,登录功能意味着把用户进行分类,或者说我可以选择差异对待用户。这不是墨灵的初衷。因此,每一个用户对于我们来说都是匿名访问。然而,这种“匿名”给我们带来了麻烦,不足 0.1% 的恶意用户消耗着我们 80% 的资源。我花了大量的时间,在尽量避免影响正常用户,不加入登录功能的情况下,去解决这个问题。值得开心的是,这个问题被解决了。遗憾的是,我投入了大量的精力以至于拖延了整体更新进度;会影响极少数网络环境异常但确实是正常用户的访问。我写了这么长的一段来说这个问题,是有原因的:不仅仅是我,任何网站的开发者,在用户量上升的阶段一定会遇到这个问题。开发者们都非常头痛,用户的不理解往往加剧了这个问题。

墨灵教会我的,也是最重要的:一个程序的功能完整是基础,最最最重要的是功能易用。功能上线前的一次次测试,是为了提高功能的易用性。一个程序的某个功能不能正常运作,不应该是告诉用户“开发者预期的操作过程”,而是程序本身应该足够完善,无论用户做了什么动作,用户都能得到它预期的结果。这就好像,一个好的产品,不需要说明书,无论是傻瓜还是精通的人,无论是老人还是年轻人,都能轻易上手。条条大路通罗马,应该是一个程序设计的准则。当然,这也是墨灵每一次测试的准则。

感谢曾今用过墨灵,还看完这封信的你,你们对我的肯定是墨灵走下来的动力。

感谢陪墨灵一路走来的伙伴们,感谢你们愿意为我分担琐事,与我一同体验欢乐。

感谢曾今攻击过我们,滥用我们服务的伙计们,你们让我感受到我技术的不足,促使我提升自己。最初攻击的乐趣你们收获了,最后攻击打不过防御策略的无奈你们也收获了。

这个项目已经下线很久了,只不过没有多少时间来认真回顾这个项目。

快站库

目标用户:搜狐旗下快站站长

技术栈含前端 JS/CSS、后端 PHP、网络 CDN 等。性质为原创商用非开源。于 2016 年 7 月筹划、试上线;2017 年 3 月 CDN 全面部署,正式发布;2018 年 6 月全线关闭,作品终止。

alt text

介绍

这个项目基于快站平台(Kuaizhan.com 第三方建站工具),用户在这里获取代码后,可以按照自己的想法深度优化自己的快站平台。Kuaizhan Code Library 趋简避繁,将代码最简化,旨在快速帮助不会代码的平台用户设计自己的快站。是的,这些代码简而轻,却优化了该平台官方没有设计到的方面。

上图展示的是 Kuaizhan Code Library Version 5 . 相比之前的版本,前端页面,新增搜索、代码复制到剪贴板,后端提供更多,更细致化的 API…

Kuaizhan Code Library 还有相关延申的商业服务。2019 年 12 月,相关商业服务正式下线。

2019 年的我在整理文件的时候找到了这些资料。截图保存后就删档啦!下面截图是该作品的部分合作商户。

alt text

总结

作品迭代了多次,在最后一版才更新了搜索功能。
因为这个库的内容非常多,一直没有找到好的方案来实现前端搜索。这一定程度上,降低了用户体验。搜索这个功能其实一开始就应该被加上。

这个作品折腾最多的,就是 CDN 网络。CDN 用于快速分发文件,是该作品最重要的一部分。经过对比,我选择了腾讯云的 CDN。腾讯云 CDN 当初还是很不完善,我也踩了不少坑。

尽管这只是一个免费使用的作品,但是还是受到了很多快站站长的喜爱。
根据搜狐快站开放平台的统计,直接覆盖 B 端用户(快站站长)超过 10000 个站点。根据腾讯云的 CDN 统计,间接覆盖 C 端用户(快站站长的站点用户)超过一亿独立 IP。数据不包含非正常数据,作品上线期间,CDN 受到多次 CC 攻击。

2017 年初,接入搜狐快站开放平台。该作品和官方进行了很好的对接。接入后不久,因作品被广泛接入各大站点,我收到了搜狐快站邀请入职的 Offer。

题外话

现在看来,快站早已是一个过时的产品。

alt textalt textalt textalt textalt text

0%