OpenFaas 部署/应用

安装

OpenFaaS 依赖 Kubernetes 集群, 参考 kubernetes 安装文档, 首先关闭 swap

1
2
3
4
5
# 删除swap所在行
sudo vi /etc/fstab
sudo swapoff -a
# 验证
free -h

安装一个 container runtime 官方推荐 containerd,安装成功后,初始化控制节点

1
2
3
4
5
6
7
8
sudo kubeadm init --pod-network-cidr=10.244.0.0/16

# 如果报错 [ERROR FileContent--proc-sys-net-ipv4-ip_forward]: /proc/sys/net/ipv4/ip_forward contents are not set to 1
# Kubernetes 节点需要开启 IPv4 转发(IP forwarding),这样 Node 可以把 Pod 的网络流量转发到其他节点或外部网络。

sudo nano /etc/sysctl.conf
net.ipv4.ip_forward=1 # 添加或打开注释
sudo sysctl -p # 保存

初始化成功后参照控制台输出执行 配置命令

安装一个 网络插件 以便于容器间通信,或访问外部网络

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kubectl apply -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml

# 如果报错Failed to check br_netfilter: stat /proc/sys/net/bridge/bridge-nf-call-iptables: no such file or directory
# 说明 内核缺少 br_netfilter 模块或相关配置没有启用,这是 Flannel 启动 VXLAN 网络所必须的。

sudo modprobe br_netfilter # 加载 br_netfilter 模块
lsmod | grep br_netfilter # 确认模块加载

# 开启 bridge-nf-call-iptables
echo "br_netfilter" | sudo tee /etc/modules-load.d/k8s.conf
echo "net.bridge.bridge-nf-call-iptables = 1" | sudo tee /etc/sysctl.d/k8s.conf
echo "net.bridge.bridge-nf-call-ip6tables = 1" | sudo tee -a /etc/sysctl.d/k8s.conf
sudo sysctl --system

# 重新部署
kubectl delete -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml
kubectl apply -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml

OpenFaaS 安装参考 官方文档, 安装 openfaas-ce 后,命令行会返回一段信息, 依次执行这些命令

1
2
3
4
5
6
7
8
# 获取 faas-cli(OpenFaaS 的命令行工具),用来操作 OpenFaaS
arkade get faas-cli

# 安装后查看版本
faas-cli version

# 等待 OpenFaaS Gateway 部署完成,gateway 是 OpenFaaS 的 核心入口 / UI / API Server。
kubectl rollout status -n openfaas deploy/gateway

如果命令 kubectl rollout status -n openfaas deploy/gateway 不成功, 检查 coredns 的 forward 配置

1
2
3
4
5
kubectl edit configmap coredns -n kube-system

forward . 192.168.48.1 # 修改为宿主机ip

kubectl rollout restart deployment coredns -n kube-system # 重启服务

UI 界面访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 查看端口 
kubectl get svc -n openfaas
# gateway-external NodePort 10.103.137.90 <none> 8080:31112/TCP 39m
# 此时就可以通过 31112 端口访问 ui 界面了


# 登录
echo -n $PASSWORD | faas-cli login --gateway http://192.168.48.133:31112 --username admin --password-stdin
# Calling the OpenFaaS server to validate the credentials...
# WARNING! You are not using an encrypted connection to the gateway, consider using HTTPS.
# credentials saved for admin http://192.168.48.133:31112

echo $PASSWORD

# 修改密码
echo -n "NewPassword123" | base64

kubectl get secret basic-auth -n openfaas -o yaml
# 替换 basic-auth-password

# 重启服务
kubectl rollout restart deployment gateway -n openfaas

系统重启准备

1
2
3
4
5
6
7
8
9
10
kubectl drain openfass --ignore-daemonsets --force --delete-emptydir-data

# --ignore-daemonsets → 不驱逐 DaemonSet Pod
# --force → 强制在单节点上驱逐 Pod
# --delete-emptydir-data → 删除使用 emptyDir 的 Pod
sudo reboot

# 把节点状态改回 可调度
kubectl uncordon openfass
kubectl get nodes

Puppeteer + 火山 OCR 实现自动登录和爬虫操作

火山 OCR Api 接入

需要在用户中心获取 AK,SK 密钥

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
// request.js

"use strict";

const crypto = require("crypto");
const util = require("util");
const url = require("url");
const qs = require("querystring");
const fs = require("fs");

/**
* 不参与加签过程的 header key
*/
const HEADER_KEYS_TO_IGNORE = new Set([
"authorization",
"content-type",
"content-length",
"user-agent",
"presigned-expires",
"expect",
]);

// do request example
function request(signParams) {
signParams.headers = {
...signParams.headers,
["X-Date"]: getDateTimeNow(),
};
signParams.bodySha = getBodySha(signParams.body);

// 正规化 query object, 防止串化后出现 query 值为 undefined 情况
for (const [key, val] of Object.entries(signParams.query)) {
if (val === undefined || val === null) {
signParams.query[key] = "";
}
}
const authorization = sign(signParams);
return fetch(
`https://iam.volcengineapi.com/?${qs.stringify(signParams.query)}`,
{
headers: {
...signParams.headers,
Authorization: authorization,
},
method: signParams.method,
body: signParams.body,
}
);
}

function sign(params) {
const {
headers = {},
query = {},
region = "",
serviceName = "",
method = "",
pathName = "/",
accessKeyId = "",
secretAccessKey = "",
needSignHeaderKeys = [],
bodySha,
} = params;

const datetime = headers["X-Date"];
const date = datetime.substring(0, 8); // YYYYMMDD
// 创建正规化请求
const [signedHeaders, canonicalHeaders] = getSignHeaders(
headers,
needSignHeaderKeys
);
const canonicalRequest = [
method.toUpperCase(),
pathName,
queryParamsToString(query) || "",
`${canonicalHeaders}\n`,
signedHeaders,
bodySha || hash(""),
].join("\n");

const credentialScope = [date, region, serviceName, "request"].join("/");
// 创建签名字符串
const stringToSign = [
"HMAC-SHA256",
datetime,
credentialScope,
hash(canonicalRequest),
].join("\n");
// 计算签名
const kDate = hmac(secretAccessKey, date);
const kRegion = hmac(kDate, region);
const kService = hmac(kRegion, serviceName);
const kSigning = hmac(kService, "request");
const signature = hmac(kSigning, stringToSign).toString("hex");

return [
"HMAC-SHA256",
`Credential=${accessKeyId}/${credentialScope},`,
`SignedHeaders=${signedHeaders},`,
`Signature=${signature}`,
].join(" ");
}

function hmac(secret, s) {
return crypto.createHmac("sha256", secret).update(s, "utf8").digest();
}

function hash(s) {
return crypto.createHash("sha256").update(s, "utf8").digest("hex");
}

function queryParamsToString(params) {
return Object.keys(params)
.sort()
.map((key) => {
const val = params[key];
if (typeof val === "undefined" || val === null) {
return undefined;
}
const escapedKey = uriEscape(key);
if (!escapedKey) {
return undefined;
}
if (Array.isArray(val)) {
return `${escapedKey}=${val
.map(uriEscape)
.sort()
.join(`&${escapedKey}=`)}`;
}
return `${escapedKey}=${uriEscape(val)}`;
})
.filter((v) => v)
.join("&");
}

function getSignHeaders(originHeaders, needSignHeaders) {
function trimHeaderValue(header) {
return header.toString?.().trim().replace(/\s+/g, " ") ?? "";
}

let h = Object.keys(originHeaders);
// 根据 needSignHeaders 过滤
if (Array.isArray(needSignHeaders)) {
const needSignSet = new Set(
[...needSignHeaders, "x-date", "host"].map((k) => k.toLowerCase())
);
h = h.filter((k) => needSignSet.has(k.toLowerCase()));
}
// 根据 ignore headers 过滤
h = h.filter((k) => !HEADER_KEYS_TO_IGNORE.has(k.toLowerCase()));
const signedHeaderKeys = h
.slice()
.map((k) => k.toLowerCase())
.sort()
.join(";");
const canonicalHeaders = h
.sort((a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : 1))
.map((k) => `${k.toLowerCase()}:${trimHeaderValue(originHeaders[k])}`)
.join("\n");
return [signedHeaderKeys, canonicalHeaders];
}

function uriEscape(str) {
try {
return encodeURIComponent(str)
.replace(/[^A-Za-z0-9_.~\-%]+/g, escape)
.replace(
/[*]/g,
(ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`
);
} catch (e) {
return "";
}
}

function getDateTimeNow() {
const now = new Date();
return now.toISOString().replace(/[:-]|\.\d{3}/g, "");
}

// 获取 body sha256
function getBodySha(body) {
const hash = crypto.createHash("sha256");
if (typeof body === "string") {
hash.update(body);
} else if (body instanceof url.URLSearchParams) {
hash.update(body.toString());
} else if (util.isBuffer(body)) {
hash.update(body);
}
return hash.digest("hex");
}

module.exports = request;

puppeteer 爬虫

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
const puppeteer = require("puppeteer");
const fs = require("fs");

const request = require("./request");

// const { createWorker } = require("tesseract.js");

// const worker = await createWorker("eng");
// const ret = await worker.recognize(fs.readFileSync("./authCode.jpg"));
// console.log(ret.data.text);

(async () => {
const browser = await puppeteer.launch({
headless: true,
defaultViewport: { width: 1400, height: 900 },
});
const page = await browser.newPage();

// 设置导航的默认超时时间为永不超时
page.setDefaultNavigationTimeout(0);

// 如果添加请求拦截器,需要添加下面一句代码
// 如果使用响应拦截器,则不需要添加,添加会导致代码不向下执行
// await page.setRequestInterception(true);

page.on("response", async (response) => {
const url = response.url();

// 拦截验证码图片,保存到本地
if (url.includes("image.php")) {
const img = await response.buffer();
fs.writeFileSync(`./authCode.jpg`, img);
}
});

await Promise.all([
page.waitForNavigation(),
page.goto("https://hdsky.me/login.php"),
]);

await page.type("input[name='username']", "username");
await page.type("input[name='password']", "password");

// 调用 OCR api 识别验证码
const AccessKeyId = "___YOUR_ACCESS_KEY___";
const SecretKey = "___YOUR_SECRET_KEY___";

const img = fs.readFileSync("./authCode.jpg");
const imgBase64 = img.toString("base64");

const body = new URLSearchParams();
body.append("image_base64", imgBase64);

const {
data: {
line_texts: [authCode],
},
} = await request({
headers: {
// 火山文档提示必传,但是 sdk 会过滤掉
"content-type": "application/x-www-form-urlencoded",
},
method: "POST",
// api 文档必填参数
query: {
Action: "OCRNormal",
Version: "2020-08-26",
},
accessKeyId: AccessKeyId,
secretAccessKey: SecretKey,
serviceName: "cv",
region: "cn-north-1",
body,
}).then((res) => res.json());

console.log(authCode);

await page.type("input[name='imagestring']", authCode);

// 点击事件导致页面导航,需要等待导航结束
await Promise.all([
page.waitForNavigation(),
page.click("input[type='submit']"),
]);

await Promise.all([
page.waitForNavigation(),
page.click("a[href='torrents.php']"),
]);

const loop = async (startPage) => {
const list = await page.$$("table tr[class*=progresstr]");

for (let i = 0; i < list.length; i++) {
const item = list[i];

// 监听新页面创建事件,并获取详情页的 page 对象
const detailPromise = new Promise((resolve) => {
browser.once("targetcreated", async (target) => {
if (target.type() === "page") {
const newPage = await target.page();
resolve(newPage);
}
});
});

const a = await item.$(
"td:nth-child(2) table > tbody > tr > td:nth-child(1) > a"
);
await a.click();

const detailPage = await detailPromise;

await detailPage.waitForSelector("#saythanks", {
timeout: 0,
});

await detailPage.click("#saythanks");

console.log(`第 ${startPage} 页,第 ${i + 1} 条已点赞`);

await new Promise((resolve) => setTimeout(resolve, 4000));

await detailPage.close();
}
};

const loop2 = async () => {
const startPage = fs.readFileSync("./skypage.txt", "utf8");

const search = await page.$("input[type=number]:nth-child(2)");
await search.click({ clickCount: 3 });
await search.type(startPage);

await Promise.all([
page.waitForNavigation(),
page.click("input[type=submit]:nth-child(3)"),
]);

await loop(startPage);

if (startPage > 0) {
fs.writeFileSync("./skypage.txt", (startPage - 1).toString(), "utf8");
loop2();
}
};

loop2();

// await browser.close();
})();

debian 执行任务报错

Failed to launch the browser process: error while loading shared libraries: libnss3.so: cannot open shared object file

可能是没想相关的执行环境安装以下依赖

1
2
3
sudo apt-get install libpangocairo-1.0-0 libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 libxi6 libxtst6 libnss3 libcups2 libxss1 libxrandr2 libasound2 libatk1.0-0 libgtk-3-0

sudo apt-get install -y libgbm-dev

Dify 工作流

下载 Dify 源码

1
git clone https://github.com/langgenius/dify.git

启动 Dify

进入 Dify 源代码的 docker 目录,执行一键启动命令:

1
2
cd dify/docker
docker compose up -d

如果您的系统安装了 Docker Compose V2 而不是 V1,请使用 docker compose 而不是 docker-compose。通过$ docker compose version 检查版本号:Docker

1
2
3
4
5
6
7
8
[+] Running 7/7
✔ Container docker-web-1 Started 1.0s
✔ Container docker-redis-1 Started 1.1s
✔ Container docker-weaviate-1 Started 0.9s
✔ Container docker-db-1 Started 0.0s
✔ Container docker-worker-1 Started 0.7s
✔ Container docker-api-1 Started 0.8s
✔ Container docker-nginx-1 Started

检查容器是否正常运行,包括 3 个业务服务 api / worker / web,以及 4 个基础组件 weaviate / db / redis / nginx,都要处于启动状态。注意下面每一个服务的 status 都要是 Up 状态才算真正启动成功。

1
2
3
4
5
6
7
8
9
10
docker compose ps

NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
docker-api-1 langgenius/dify-api:0.3.2 "/entrypoint.sh" api 4 seconds ago Up 2 seconds 80/tcp, 5001/tcp
docker-db-1 postgres:15-alpine "docker-entrypoint.s…" db 4 seconds ago Up 2 seconds 0.0.0.0:5432->5432/tcp
docker-nginx-1 nginx:latest "/docker-entrypoint.…" nginx 4 seconds ago Up 2 seconds 0.0.0.0:80->80/tcp
docker-redis-1 redis:6-alpine "docker-entrypoint.s…" redis 4 seconds ago Up 3 seconds 6379/tcp
docker-weaviate-1 semitechnologies/weaviate:1.18.4 "/bin/weaviate --hos…" weaviate 4 seconds ago Up 3 seconds
docker-web-1 langgenius/dify-web:0.3.2 "/entrypoint.sh" web 4 seconds ago Up 3 seconds 80/tcp, 3000/tcp
docker-worker-1 langgenius/dify-api:0.3.2 "/entrypoint.sh" worker 4 seconds ago Up 2 seconds 80/tcp, 5001/tcp

升级 Dify

进入 Dify 源代码的 docker 目录,按顺序执行以下命令:

1
2
3
4
5
cd dify/docker
git pull origin main
docker compose down
docker compose pull
docker compose up -d

FAQ

  • 首页正常显示,当点击设置管理员用户页面一直在加载中,浏览器控制台报错,
1
/console/api/setup 502

检查服务的状态 status 栏并是不是 Up 状态

1
docker compose ps

查看日志

1
docker logs docker-api-1

这是由于 Docker 运行权限不够,需要被赋予更高的权限。

在 docker-compose.yaml 文件中,给 services 下每一个 service 最后一行都加上 privileged: true,然后重新启动 Dify 即可。

  • 由于在第一个问题中,Ubuntu 系统中,本地修改了 docker-compose.yaml 文件,在使用 git pull origin main 会提示本地有文件没有提交:
1
2
3
4
5
6
7
8
9
10
error: Your local changes to the following files would be overwritten by merge:
docker/docker-compose.yaml
Please, commit your changes or stash them before you can merge.

# 需要提交修改
git config --global user.email "xxx"
git config --global user.name "xxx"

git add docker-compose.yaml
git commit -m "local change"
  • Docker 无法拉取镜像
1
2
3
4
5
6
7
8
9
10
// daemon.json可能会不存在,直接 vi /etc/docker/daemon.json可以创建
sudo vi /etc/docker/daemon.json

{
"registry-mirrors": ["新的镜像源地址"]
}

systemctl restart docker //重启docker

docker info //查看镜像源有没有配置成功

React Router v7 概览

v7 可以让 React Router 作为框架来使用,利用提供的 cli 工具创建项目,提供了相关的构建包,以 React Router 的视角开发项目。以下是官方提供的特性说明:

  • Vite 捆绑器和开发服务器集成
  • 热模块替换
  • 代码分割
  • 带类型安全的路由约定
  • 文件系统或基于配置的路由
  • 带类型安全的数据加载
  • 带类型安全的操作
  • 操作后页面数据的自动重新验证
  • SSR、SPA 和静态呈现策略
  • 待定状态和乐观 UI 的 api
  • 部署适配器

特殊文件

react-router.config.ts

可选,全局的配置文件

root.ts

必须,唯一的必须路由,是 routes 目录有所有路由的父路由,也用于描述 HTML 文档。

可以把 React Router 提供的全局组件写在这里,只会渲染一次。

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
import type { LinksFunction } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";

import "./global-styles.css";

export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

{/* 所有路由上的所有meta导出都会在这里渲染 */}
<Meta />

{/* 所有路由上的所有link导出都会在这里渲染 */}
<Links />
</head>
<body>
{/* 子路由*/}
<Outlet />

{/* 管理客户端渲染时候的滚动条位置 */}
{/* If you use a nonce-based content security policy for scripts, you must provide the `nonce` prop. Otherwise, omit the nonce prop as shown here. */}

<ScrollRestoration />

<Scripts />
</body>
</html>
);
}

Content Security Policy (CSP)
CSP 是一种浏览器安全机制,用于防止跨站脚本攻击(XSS)等安全问题。通过配置 CSP,开发者可以限制页面加载的资源(如脚本、样式等)的来源和执行方式。

Nonce
nonce 是 CSP 中的一个概念,表示一个随机生成的字符串(一次性值)。通过在 CSP 中配置 nonce,可以允许特定的内联脚本或动态加载的脚本执行,而不会违反 CSP 规则。

如果你使用了基于 nonce 的 CSP 策略:
如果你的 CSP 配置中使用了 nonce 来允许脚本执行,那么你需要在 React Router 的相关组件中传递 nonce 属性。这是因为 React Router 可能会动态加载或执行一些脚本,这些脚本需要符合你的 CSP 策略。

如果你没有使用基于 nonce 的 CSP 策略:
如果你的 CSP 配置不涉及 nonce,或者你不需要对脚本进行特殊限制,那么你可以忽略 nonce 属性(如示例中所示).

假设你的 CSP 配置如下:

1
Content-Security-Policy: script-src 'nonce-abc123';

这意味着只有带有 nonce=”abc123” 的脚本才能执行。在这种情况下,你需要在 React Router 中传递 nonce 属性:

1
<Router nonce="abc123">{/* Your routes here */}</Router>

layout-export

一个单独导出的组件,可以避免在 Root Component,HydrateFallback, ErrorBoundary 重复声明 app 的框架元素。

routes.ts

必须, 统一的路由配置文件,它将自动对每个路由进行代码拆分,为参数和数据提供类型安全,并在用户导航到数据时自动加载具有挂起状态访问权的数据。

1
2
3
4
5
6
import type { RouteConfig } from "@react-router/dev/routes";
import { route } from "@react-router/dev/routes";

export default [
route("contacts/:contactId", "routes/contact.tsx"),
] satisfies RouteConfig;
index route

当路由没有匹配任何路径的时候,他会在 Outlet 中显示空白,index 可以当做是默认路由的视图。

1
2
3
4
5
6
7
8
// app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { index, route } from "@react-router/dev/routes";

export default [
index("routes/home.tsx"),
route("contacts/:contactId", "routes/contact.tsx"),
] satisfies RouteConfig;
layout route

可以在路由的配置文件中,描述组件的嵌套关系.

1
2
3
4
5
6
7
8
9
10
11
12
import type { RouteConfig } from "@react-router/dev/routes";
import { index, layout, route } from "@react-router/dev/routes";

export default [
layout("layouts/sidebar.tsx", [
index("routes/home.tsx"),

// 会渲染到 layouts/sidebar.tsx 这个 layout 下的 Outlet 里面。
route("contacts/:contactId", "routes/contact.tsx"),
]),
route("about", "routes/about.tsx"),
] satisfies RouteConfig;

特殊组件

用于展示路由

1
2
3
4
5
6
7
8
9
10
11
// app/root.tx
import { Outlet } from "react-router";
export default function App() {
return (
<>
<div id="detail">
<Outlet />
</div>
</>
);
}

核心方法

clientLoader

只在浏览器中调用,为路由组件提供数据

1
2
3
4
5
6
7
8
9
10
11
// app/root.tsx

export async function clientLoader() {
const contacts = await getContacts();
return { contacts };
}

export default function App({ loaderData }: any) {
const { contacts } = loaderData;
// ...
}
HydrateFallback

如果通过 react-router.config.ts 配置为客户端渲染,那么在 app 根文件执行之前是没有任何内容。

提供一个 HydrateFallback 方法,在 app 被渲染前提供能内容。他会被直接添加到 index.html 文件中。

1
2
3
4
5
6
7
8
// app/root.tsx
export function HydrateFallback() {
return (
<div id="loading-splash">
<p>Loading, please wait...</p>
</div>
);
}
URL Params Loader

在 loader 方法中获取

1
2
3
4
5
6
7
8
9
10
11
12
13
// existing imports
import type { Route } from "./+types/contact";

export async function loader({ params }: Route.LoaderArgs) {
const contact = await getContact(params.contactId);
return { contact };
}

export default function Contact({ loaderData }: Route.ComponentProps) {
const { contact } = loaderData;

// existing code
}

在组件中获取

1
2
3
4
export default function SidebarLayout({ params }: Route.ComponentProps) {
console.log(params);
return; //...
}
useNavigation

返回当前导航信息,包括导航状态。 导航需要等到 loader 结束时才会渲染页面,因此可以使用导航状态展示 loading 信息。

1
2
3
4
5
6
7
8
9
10
11
12
import { useNavigation } from "react-router";

export default function SidebarLayout() {
const navigation = useNavigation();

return (
<div
id="detail"
className={navigation.state === "loading" ? "loading" : ""}
></div>
);
}

类型安全

React Router 自动为每个路由生成类型文件存放在 +types/<route file>.d.ts.

1
2
3
4
5
6
7
// app/root.tsx
import type { Route } from "./+types/root";

export default function App({ loaderData }: Route.ComponentProps) {
const { contacts } = loaderData;
// ...
}

预渲染静态路由

对于没有内容的页面,希望在加载的时候不展示 loading,而是直接展示内容。

指定哪些页面需要预渲染,他们会在打包阶段,打包为静态文件。

1
2
3
4
5
6
7
//react-router.config.ts
import { type Config } from "@react-router/dev/config";

export default {
ssr: false,
prerender: ["/about"],
} satisfies Config;

抛出错误

在 loader 中抛出错误, 会被 root.tsx 的 ErrorBoundary 捕获

1
2
3
4
5
6
7
export async function clientLoader({ params }: Route.LoaderArgs) {
const contact = await getContact(params.contactId);
if (!contact) {
throw new Response("Not Found", { status: 404 });
}
return { contact };
}

表单提交

默认情况下,表单的提交会触发 history 的修改,因此使用 Form 组件,它会就近拦截表单的提交,并将请求通过 fetch 发送到最近的 action 中处理。

Form 组件上的 action 会作为请求提交的地址,用于匹配路由。

action 的规则是如果路由匹配那么对应的路由文件中必须存在 action 处理函数否则报错,如果没有对应的路由文件(例如写在 layout 文件中),会使用 root 中的 action 处理。

需要注意的是使用 action 不能将 react-router.config.ts 中的 ssr 设置为 false,因为这是服务端的功能。

clientLoader 也需要修改为 loader 从服务端获取数据。

1
2
3
4
5
6
7
// root.tsx

// 如果在路由上没有其他的文件写 action 就会到root的action中处理
export async function action() {
const contact = await createEmptyContact();
return { contact };
}
1
2
3
4
5
6
7
8
9
import { Form } from "react-router";

export default function SidebarLayout() {
return (
<Form method="post">
<button type="submit">New</button>
</Form>
);
}

删除信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 当前文件 app/routes/contact.tsx 对应的路由是 contacts/:contactId
// 因此表但会提交到 contacts/:contactId/destroy
// 需要为此路由创建对应的文件

<Form
action="destroy"
method="post"
onSubmit={(event) => {
const response = confirm("Please confirm you want to delete this record.");
if (!response) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
1
2
3
4
5
6
7
8
9
10
// app/routes/destroy-contact.tsx
import { redirect } from "react-router";
import type { Route } from "./+types/destroy-contact";

import { deleteContact } from "../data";

export async function action({ params }: Route.ActionArgs) {
await deleteContact(params.contactId);
return redirect("/");
}
1
2
3
4
5
// app/routes.ts

export default [
route("contacts/:contactId/destroy", "routes/destroy-contact.tsx"),
] satisfies RouteConfig;

TrueNas 应用配置

MinIO

参考安装文档, 挂载点配置需要开启 ACL, 文件默认保存 export 目录下。

Nginx 配置参考MinIO 配置文档, 需要添加chunked_transfer_encoding off;

NextCloud

  • dataSet 创建
    父文件夹需要有以下权限 user:www-data,group:www-data,user:netdata,group:docker,user:root,group:root,user:apps
    user 文件夹需要有以下权限 user:www-data,group:www-data,user:apps
    data 文件夹需要有以下权限 user:www-data,group:www-data,user:apps
    db 文件夹需要有以下权限 user:netdata,group:docker,user:root,group:root,user:apps

  • APT Packages 需要添加 ffmpeg smbclient

  • host 需要填写真实访问的域名

  • Database Password 注意不要有 #@?& 等特殊符号

  • Certificate ID 选择默认证书,避免 nextCloud 提示警告,如果前端有 nginx 代理,需要使用 https

  • 在 config/config.php 中添加 maintenance_window_start 消除调度任务的警告

  • nginx配置

虚拟机配置

Nas 服务器开机虚拟化功能,配置以下字段,其余字段可以保持默认

Boot Method : UEFI

CPU Mode : Host Passthrough

Select Disk Type : VirtIO
virtIO 是一个优化的虚拟磁盘驱动程序,专为虚拟化环境设计,提供更高的性能和较低的开销。它支持更高的数据传输速率,特别适用于 I/O 密集型的应用场景。

Adapter Type:VirtIO

如果安装后提示 CD-ROM 加载错误,可以删除 CD-ROM 设备后重试

Jellyfin

nginx 代理按照官方文档操作

Babel 源码与插件

调式源码

1
2
3
4
5
6
7
8
git clone https://github.com/<your-github-username>/babel
cd babel
make bootstrap

# 在想要调试文件的入口处添加断点
# -i 指定测试package
# -t 指定测试用例 fixtures
yarn run jest -i packages/babel-parser -t 'es2016/simple parameter list/arrow function'

插件开发最小化环境

相关依赖包

1
2
3
4
5
6
7
{
"babel-plugin-tester": "^11.0.4",
"jest": "^29.7.0",
"ts-jest": "^29.2.6",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
}

测试文件入口与 fixtures 用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//__test__/index.ts

import { pluginTester } from "babel-plugin-tester";
import insertLogPlugin from "../plugins/babel-plugin-insert-log";
import path from "path";

pluginTester({
plugin: insertLogPlugin,

babelOptions: {
plugins: ["@babel/plugin-syntax-jsx"],
},
fixtures: path.join(__dirname, "fixtures"),
});
1
2
3
4
5
6
7
// __tests__/fixtures/in-arrow-function/code.ts
const a = () => console.log(1);

// __tests__/fixtures/in-arrow-function/output.ts
const a = () => {
console.log(1);
};

jest 配置文件, 排除 fixtures 目录下的测试用例,避免多次执行

1
2
3
4
5
6
7
8
/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
testEnvironment: "node",
transform: {
"^.+.tsx?$": ["ts-jest", {}],
},
testMatch: ["**/__tests__/**/*", "!**/fixtures/**/*"],
};

babel-parser

使用多个类继承,完善 parser 功能

1
2
3
4
5
6
7
8
export default class Parser extends StatementParser{}
abstract class StatementParser extends ExpressionParser{}
abstract class ExpressionParser extends LValParser{}
abstract class LValParser extends NodeUtils{}
abstract class NodeUtils extends UtilParser{}
abstract class UtilParser extends Tokenizer{}
abstract class Tokenizer extends CommentsParser{}
class CommentsParser extends BaseParser{}

解析 le\\u0074 x = 5 流程:

  • 实例化 Parser,调用 parse 方法开始解析

  • 初始化 file, grogram 节点

  • 尝试解析 token

    • 跳过空白符,注释等

    • 根据第一个字符判断要如何解析, 比如 l 为一个小写字母, 会被当作 Identifier 解析

    • 尝试读取完成的标识符

      this.state.pos += str <= 0xffff ? 1 : 2; 如果字符 charCode 大于 0xffff 例如 , 则向后移动两个字符

      如果匹配到 \, 则判断是否是一个 Unicode 转义序列,后面三位必须是 \\u 开头,如果不是则标记错误

      如果是一个转移字符,尝试读取这个转义字符并返回, 因此第一个 token 会是 let

    • 解析 Program 节点,let 作为 ExpressionStatement 加入 Program 节点,保存在 body 数组中

      继续尝试解析 x = 5 这个表达式作为 AssignmentExpression,x 和 5 作为 Identifier 和 NumericLiteral 加入 AssignmentExpression 分别作为 left 和 right

最终形成的树结构就是 ast.

OpenWrt 安装与设置

下载固件

OpenWrt 官方版本,所有的第三方版本都是基于官方的自定义编译,
immortalwrt 是针对国内用户编译的第三方版本,推荐优先使用。[GitHub]

点击 All Downloads

选择适配的 x86 版本

SquashFS 和 Ext4 是两种不同的文件系统,各自有不同的特点和应用场景:

  • SquashFS 只读的文件系统,经过压缩后可以节省存储空间。默认情况下不支持直接写入,因此需要将可写数据放在单独的 Overlay 文件系统上。
  • Ext4 一种常用的可读写文件系统,支持动态修改。

由于 OpenWrt 并不会将所有硬盘剩余空间作为根路径,因此不同的文件格式对应不同的磁盘组成,在安装后需要对根分区扩容。

  • Ext4: /boot + /根分区(读写) + 剩余未分区磁盘
  • SquashFS: /boot + /rom(只读) + /根分区(读写) + 剩余未分区磁盘 (优先选择此版本,方便迁移和系统恢复)

安装

参考官方安装文档,采用写入镜像文件方式,由于 OpenWrt 没有提供安装引导,所以需要用 微 PE 或 老毛桃[https://www.laomaotao.net/] 等工具制作一个 WinPE 启动盘, 将用到的固件,和固件写入工具保存到启动盘中,当进入 WinPE 系统后是用写入工具写入镜像。

Windows Preinstallation Environment(Windows PE),Windows 预安装环境,是带有有限服务的最小 Win32 子系统,基于以完整 Windows 环境或者保护模式运行的 Windows 3.x 及以上内核。这类系统一般很小。它包括了运行 Windows 安装程序及脚本、连接网络共享、自动化基本过程以及执行硬件验证所需的最小功能。

另外需要下载一个镜像写入工具 Win32 Disk ImagerbalenaEtcher

将镜像写入工具, 解压出的 OpenWrt 镜像(.iso)文件, 复制到制作好的 WinPE 启动 U 盘中。

将 U 盘插入到软路由中(软路由需要接上鼠标,键盘,显示器),开机时入到软路由的 BIOS,将默认的固态硬盘启动改为 U 盘启动。

保存重启后,进入 WinPE 系统,首先使用 WinPE 自带的 DiskGenius 工具,删除软路由硬盘的所有分区并保存。打开写入工具,选择磁盘和镜像并写入。

写入成功后,重启系统再次进入 BIOS,将 U 盘启动修改回硬盘启动,保存并再次重启。

启动成功后,会看见有命令行信息,如果卡住不动,尝试按一下回车键,这样会进入 OpenWrt 系统。

首次登录提示修改密码,执行 passwd 命令,修改 root 账户密码,此密码也是网页登录的密码。

修改网络配置信息,执行命令 vi /etc/config/network,

将 config device 的 list_ports 修改为 eth1
将 interface lan 的 option ipaddr 修改为内网不会冲突的 IP 地址例如 192.168.100.110.10.0.1
将 interface wan 和 interface wan6 的 option device 修改为 eth0

wan 口表示外网,也就是网络运营商的网线,通常更习惯将第一个网口作为插入外网网线,后面插入的都是内网设备,这样更有条理性

保存后重启设备,访问前需要重新连接一下设备:

  • 软路由插入电源并开机
  • 运营商光猫的网线,链接到软路由的 eth0 网口。
  • wifi 路由器需要在管理后台,设置为 AP 有线中继模式,用一个网线将软路由的 eth1 网口,链接到路由器的第一个网口。
  • 其他内网设备可以通过网线连接软路由或路由器,也可以直接通过 wifi 网络链接.

这是虽然不能上网,单已经可以通过内网中其他设备访问 /etc/config/network 中设置的 lan 口的 ipaddr 访问到 OpenWrt 的 UI 界面。

pve 虚拟机安装

上传镜像文件到 pve, 创建虚拟机, 删除虚拟机的默认磁盘

1
2
3
qm importdisk [虚拟机ID] [镜像路径] [磁盘]

qm importdisk 4444 /var/lib/vz/template/iso/immortalwrt-24.10.3-x86-64-generic-squashfs-combined-efi__1_.img storage

虚拟机 => 硬件 添加未启用的磁盘
虚拟机 => 选线 修改刚刚添加的磁盘为第一启动项

wan 口配置

最先的配置一定是软路由可以上网,输入账号密码进入到软路由的 UI 界面,点击菜单 网络 => 接口 点击 wan 口的编辑按钮,协议切换为 PPPoE ,输入账号密码,即可上网。

此方式是通过 PPPoE 拨号上网,需要致电运营商,将网络改为桥接模式,并提供账号密码。

lan 口配置

网络 => 接口 => 设备 选择 br_lan, 在网桥端口中将其他的端口都添加到 br_lan 这个网桥中,方便让局域网中的设备互相访问

磁盘扩容

在菜单 状态 => 概览 中可以看到,磁盘空间只有几百 M, 这是因为 OpenWrt 默认不会将剩余空间作为根节点。

官方 openwrt 参考 官方的扩容操作

immortalwrt 在安装前可以使用以下命令修改镜像的分区大小

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
# 将镜像文件扩容
dd if=/dev/zero bs=1M count=98304 >> immortalwrt-24.10.3-x86-64-generic-squashfs-combined-efi__1_.img

# 使用parted扩容文件系统

parted immortalwrt-24.10.3-x86-64-generic-squashfs-combined-efi__1_.img
GNU Parted 3.5
Using /var/lib/vz/template/iso/immortalwrt-24.10.3-x86-64-generic-squashfs-combined-efi__1_.img
Welcome to GNU Parted! Type 'help' to view a list of commands.
(parted) p
Error: The backup GPT table is corrupt, but the primary appears OK, so that will be used.
OK/Cancel? OK
Warning: Not all of the space available to
/var/lib/vz/template/iso/immortalwrt-24.10.3-x86-64-generic-squashfs-combined-efi__1_.img appears to be used, you can fix the GPT
to use all of the space (an extra 30 blocks) or continue with the current setting?
Fix/Ignore? Fix
Model: (file)
Disk /var/lib/vz/template/iso/immortalwrt-24.10.3-x86-64-generic-squashfs-combined-efi__1_.img: 348MB
Sector size (logical/physical): 512B/512B
Partition Table: gpt
Disk Flags:

Number Start End Size File system Name Flags
128 17.4kB 262kB 245kB bios_grub
1 262kB 33.8MB 33.6MB fat16 legacy_boot
2 33.8MB 348MB 315MB

(parted) resizepart 2 100%
(parted) q

immortalwrt 也可以安装后扩容

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
opkg update

opkg install fdisk lsblk losetup f2fs-toolsopkg install lsblk fdisk resize2fs losetup blkid f2fs-tools tree

# 查看磁盘信息
# df -h
# fdisk -l

# 替换为自己的磁盘
fdisk /dev/sda

# 查看磁盘
Command (m for help): p

Device Boot Start End Sectors Size Id Type
/dev/sda1 65536 98303 32768 16M 83 Linux
/dev/sda2 131072 745471 614400 300M 83 Linux

# 删除
Command (m for help): d
Partition number (1,2, default 2): 2

Partition 2 has been deleted.

# 新建磁盘 需要注意 sector 起始位置一定要与原有磁盘信息中 start 大小相同
Command (m for help): n
Partition type
p primary (1 primary, 0 extended, 3 free)
e extended (container for logical partitions)
Select (default p): p
Partition number (2-4, default 2): 2
First sector (2048-31268863, default 2048): 131072
Last sector, +/-sectors or +/-size{K,M,G,T,P} (131072-31268863, default 31268863):

Created a new partition 2 of type 'Linux' and of size 14.8 GiB.
Partition #2 contains a squashfs signature.


# 一定要选 n ,不要修改标识符
Do you want to remove the signature? [Y]es/[N]o: n

# 保存
Command (m for help): w
The partition table has been altered.

对文件系统扩容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
losetup
# 记录 offset 的值
# NAME SIZELIMIT OFFSET AUTOCLEAR RO BACK-FILE DIO LOG-SEC
# /dev/loop0 0 6291456 1 0 /mmcblk0p2 0 512

# 使用相同的 offset 创建一个临时的 循环设备
losetup -f -o 6291456 /dev/sda2

# 挂在这个设备以便于记录日志, 如果不挂在会导致不能访问
mount /dev/loop1 /mnt

# 卸载设备后可以执行扩容
umount /dev/loop1

resize.f2fs /dev/loop1

修改分区大小后会导致磁盘 UUID 改变,需要更新

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

blkid
# /dev/loop0: LABEL="rootfs_data" UUID="b32ce334-3193-428b-9ae3-2a156379ecc8" BLOCK_SIZE="4096" TYPE="f2fs"
# 记录 sda2 的新的 PARTUUID
# /dev/sda2: BLOCK_SIZE="262144" TYPE="squashfs" PARTUUID="ff2d03b0-3d06-4001-bb97-367a0265a03f"

vi /boot/grub/grub.cfg

# 修改对应的 PARTUUID 为最新值
# menuentry "ImmortalWrt" {
# linux /boot/vmlinuz root=PARTUUID=484c7fc2-ae74-a97d-23da-c3b75cf79602 rootwait console=tty1 console=ttyS0,115200n8 noinit
# }
# menuentry "ImmortalWrt (failsafe)" {
# linux /boot/vmlinuz failsafe=true root=PARTUUID=484c7fc2-ae74-a97d-23da-c3b75cf79602 rootwait console=tty1 console=ttyS0,1
# }


# 或者使用以下脚本更新
ROOT_BLK="$(readlink -f /sys/dev/block/"$(awk -e \
'$9=="/dev/root"{print $3}' /proc/self/mountinfo)")"
ROOT_DISK="/dev/$(basename "${ROOT_BLK%/*}")"
ROOT_DEV="/dev/${ROOT_BLK##*/}"
ROOT_UUID="$(partx -g -o UUID "${ROOT_DEV}" "${ROOT_DISK}")"
sed -i -r -e "s|(PARTUUID=)\S+|\1${ROOT_UUID}|g" /boot/grub/grub.cfg

# 最后重启
reboot

软件安装

docker

安装后菜单不显示,尝试退出系统重新登陆

1
2
opkg update
opkg install docker dockerd luci-app-dockerman

将 docker 区域设置为 lan 区域的目标区域,以便于可以在局域网中访问 docker 应用

ttyd

ttyd 是一个在线的命令行工具,安装 luci-i18n-ttyd-zh-cn 中文版本,会显示在系统菜单中,如果不显示重启设备。

passwall

安装 passwall 插件, 如果安装时提示 无法执行 opkg install 命令:SyntaxError: Unexpected end of JSON input 尝试更换其他的软件源再次尝试

导入 x2ray 分享链接后,一定要把节点配置中 [域名] 和 [WebSocket Host] 都设置为分享链接中的域名,并在基本设置中开启主开关,选择节点后才能使用。如果配置正确还是不能使用,重新启动后再次使用。

DDns

由于家庭网络等外界因素,导致 IP 会不定时的修改,如果想要无论何时都能让 绑定的域名解析到当前 IP 上就需要 DDns 服务。

DDns 服务会定时在后台检测当前 IP 是否修改,如果 IP 已经发生变动,DDns 服务会发送请求通知域名服务商,重新绑定域名解析的 IP 地址,从而实现动态域名解析。

在 OpenWrt 软件库中安装 ddns-go 和 luci-i18n-ddns-go-zh-cn 软件包, 可以方便的选择常用的运营商进行配置。

启用 ddns-go 服务并打开 web 界面, 跳转到 腾讯云 DNDSPOD 创建密钥。

IPv4 中选择通过网卡获取 IP, 并添加域名。

在运营商的域名解析页面,添加一条记录,先默认填入软路由的内网地址。

点击保存可以看到域名已经被正确的解析

外网访问内网应用

本设置的目的是使用不同的二级域名访问内网的各个应用,由于 OpenWrt 的防火墙只提供网络层的接口转发,而域名访问属于应用层,需要使用 nginx 作为反向代理工具代理域名请求并指向内网地址.[说明文档]

完整的请求过程是,外网的 https 请求进入防火墙,防火墙放行 => 软路由配置 nginx 监听指定域名和端口的请求 => 转发请求到内网的其他服务器

  • 系统 => 软件包 中安装 luci-nginx , 由于 luci-nginx 默认配置中包括强制将 http 重定向为 https 所以在软件安装后 OpenWrt 需要重新刷新登录。

    如果使用 ssh 工具登录时提示以下错误

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    [user@hostname ~]$ ssh root@pong
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
    Someone could be eavesdropping on you right now (man-in-the-middle attack)!
    It is also possible that a host key has just been changed.
    The fingerprint for the RSA key sent by the remote host is
    6e:45:f9:a8:af:38:3d:a1:a5:c7:76:1d:02:f8:77:00.
    Please contact your system administrator.
    Add correct host key in /home/hostname /.ssh/known_hosts to get rid of this message.
    Offending RSA key in /var/lib/sss/pubconf/known_hosts:4
    RSA host key for pong has changed and you have requested strict checking.
    Host key verification failed.

    可以使用以下命令

    1
    2
    3
    4
    # 移除 known_hosts 此 ip 的所有记录值
    ssh-keygen -R 192.168.3.10
    # 也可以使用
    rm ~/.ssh/known_hosts

    如果使用的是 ttyd 插件,可以在 ttyd 配置中开启 ssl, 证书的默认路径是

    1
    2
    /etc/nginx/conf.d/_lan.crt
    /etc/nginx/conf.d/_lan.key

    luci-nginx 使用 cui 统一了系统的配置,会通过 /etc/config/nginx 配置文件和模板文件(/etc/nginx/uci.conf.template) 生成 /etc/nginx/uci.conf 文件, uci.conf 文件最终会被 nginx 使用,并且 nginx 每次重启前都会重新生成此文件,因此不要手动更改生成的 /etc/nginx/uci.conf 文件中的内容。

    如果想要保持 http 访问可以修改 /etc/config/nginx 配置文件中的内容,注释掉 _lan 和 _redirect2ssl 添加一个新的配置 http_lan

    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
    config main global
    option uci_enable 'true'

    #config server '_lan'
    # list listen '443 ssl default_server'
    # list listen '[::]:443 ssl default_server'
    # option server_name '_lan'
    # list include 'restrict_locally'
    # list include 'conf.d/*.locations'
    # option uci_manage_ssl 'self-signed'
    # option ssl_certificate '/etc/nginx/conf.d/_lan.crt'
    # option ssl_certificate_key '/etc/nginx/conf.d/_lan.key'
    # option ssl_session_cache 'shared:SSL:32k'
    # option ssl_session_timeout '64m'
    # option access_log 'off; # logd openwrt'

    #config server '_redirect2ssl'
    # list listen '80'
    # list listen '[::]:80'
    # option server_name '_redirect2ssl'
    # option return '302 https://$host$request_uri'

    config server 'http_lan'
    list listen '80'
    list listen '[::]:80'
    #注意不要添加这个配置规则
    #因为限制了对nginx的访问地址为内网设备,会导致外网访问失效。
    #list include 'restrict_locally'
    list include 'conf.d/*.locations'

    使用命令 service nginx reload 重启 nginx, 这样就恢复了 http 访问

    重启过程中会提示错误, 可以按照提示使用 nginx -T -c ‘/etc/nginx/uci.conf’ 命令测试配置文件

    1
    2
    3
    root@ImmortalWrt:~# service nginx reload
    nginx_init: NOT using conf file!
    show config to be used by: nginx -T -c '/etc/nginx/uci.conf'

    如果文件报错会提示具体错误,并指明所在位置

  • 外网使用域名访问

    目前的网络环境是,外网使用 _.iftrue.me 访问, 内网使用 _.iftrue.me 访问, 因此配置好外网的访问端口后,可以直接把内网的请求转发到外网的监听端口

    首先配置外网域名解析, 在 cloudflare 选中域名 =》 DNS 添加一条 AAAA 解析, Name 填入 * 表示泛域名, Content 地址临时填入一个 192.168.48.1 本地地址

    添加 DDNS GO 解析将域名同步到 cloudflare , 需要添加一个 cloudflare Api token , Overview =》Get your Api token =》create token =》 Edit zone DNS =》选择对应的域名, 在 DDNS GO 中添加 token, 并解析域名

    添加 cloudflare 规则, Rules =》 Overview =》 添加 Origin Rules =》 可以将对域名的请求转发到特定的端口

    创建一个 Origin Certificates 用于 Origin server 和 cloudflare 之间的验证, SSL/TSL => Origin Certificates, 保存 PEM 和 KEY

    修改 uci 模板 /etc/nginx/uci.conf.template,在 http 模块中添加 ssl 通用配置

    1
    2
    3
    4
    5
    6
    7
    ssl_certificate /etc/nginx/conf.d/_lan.crt;
    ssl_certificate_key /etc/nginx/conf.d/_lan.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    将证书传到服务器的执行目录下

    如果使用 scp 命令包错 ash: /usr/libexec/sftp-server: not found, 需要在软件包中安装 sftp-server

    配置一个反向代理的案例,/etc/nginx/conf.d/openwrt.conf

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    server {
    listen 9348 ssl;
    listen [::]:9348 ssl;

    # qb.iftrue.me 用于内网域名的请求
    server_name qb.iftrue.me qb.iftrue.me;

    location / {
    proxy_pass http://192.168.48.189:10095;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    }
    }

    最后需要防火墙放行外网请求,Incoming Ipv6 from Wan to this device ,port 9348

  • 内网使用域名访问

    给 openwrt lan 配置一个额外的 ip 用于转发,将 内网域名全部解析到此 ip, nginx 监听 443 端口,并将所有请求转发到外网的反向代理配置中

    Network =》 DHCP DNS =》 添加地址 /iftrue.me/192.168.48.3 把 *.iftrue.me 解析到 192.168.48.3

    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
    # 添加一个默认规则,如果所有的都没有匹配走404
    server {
    listen 9348 default_server;
    listen [::]:9348 default_server;
    server_name _;

    return 404;
    }

    server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name *.iftrue.me;

    ssl_certificate /etc/nginx/conf.d/_club.crt;
    ssl_certificate_key /etc/nginx/conf.d/_club.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    location / {
    proxy_pass https://192.168.48.1:9348;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    }
    }

OpenVPN

软件包中安装 luci-app-openvpn-server

  • 协议 udp ipv4
  • 端口 1194
  • 客户端网段: 自动
  • 客户端推送配置
    comp-lzo adaptive
    redirect-gateway def1 bypass-dhcp
    dhcp-option DNS 192.168.48.1 (填写 openWrt 的 IP)
    route 192.168.48.0 255.255.255.0 (填写 openWrt 的 网段,最后一位需要是 0)
  • 下载客户端配置文件,客户端安装 openvpn, 配置存放在 user/openvpn/client.opvn

可以重新生成证书文件,时间比较长,需要等待浏览器加载结束

点击网络接口,重新加载会多出一个 vpn0 的接口

编辑配置文件,允许多个客户端链接

1
2
3
4
vi /etc/config/openvpn

# 添加配置
option duplicate_cn "1"

配置 NAT 转发规则,允许访问内网设备,网络 => 防火墙 => NAT 规则

openWrt 23.05.4 以及以后版本不需要配置

1
2
# 使用 POSTROUTING 进行规则转发
iptables -t nat -A POSTROUTING -o br-lan -j MASQUERADE

HomeProxy 自定义路由

  • 创建路由节点,可以定义流量使用哪个 vpn 代理,一定要先设置,因为默认出站会用到这些节点。
    节点的出站选择直接出站

  • 创建 DNS 服务

    标签: Google

    类型: HTTPS

    地址: dns.google

    路径: /dns-query

    端口: 443

    出站: 选择路由节点中最稳定的节点(BWG)

  • DNS 设置

    默认策略: 仅 IPV4

    默认 DNS 服务器: Google

    缓存: 关闭

    EDNS 客户端子网: (202.101.172.36 浙江) 选择一个距离当前宽带运营商最近的一个 IP 地址,这可以让 Google DNS 查询的时候选择一个离运营商最近的服务器

  • 路由设置

    路由模式: 自定义路由

    绕过国内流量: 开启,可以在防火墙就直接转发国内流量,性能更好

    复写目标地址: 一般用于视频网站解锁,Google DNS 获取到了目标 IP,sing-box 通过嗅探获取到了 netflix 域名,发送的目标是代理服务器,发送数据中的内容为请求 netflix 的 IP 以及 netflix 网站的域名,当代理受到这个请求后发现当前服务器不支持解锁,导致访问失败
    开启复写后,发送数据中的目标地址变为 netflix 域名,当发送到代理服务器后会在代理服务器的环境中做 DNS 解析, 它获取到的不是真实 IP, 而是专门的反代服务器 IP 用于解锁。

    默认出站: 选择路由节点中最稳定的节点(BWG)

    默认 DNS 出站: Google

  • 规则集

    https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo-lite/geoip/cn.srs 国内 ip
    https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo-lite/geosite/cn.srs 国内网址
    https://raw.githubusercontent.com/xmdhs/sing-box-ruleset/rule-set/AdGuardSDNSFilterSingBox.srs 广告网址

    出站选择默认,这会使用路由设置中的默认出站用于访问这些资源。

  • 路由规则

    直连,选择国内的两个规则集
    内网设备,可以通过 IP 来指定一个路由规则,以便于在出站时使用其他的代理 vpn

  • DNS 规则

    Block,选择广告的资源集,在 DNS 解析的时候就禁止访问
    直连,选择国内的域名规则集,DNS 服务器选择 WAN 自动下发的 DNS 服务,这样当请求一个国内的域名时,可以直接用运营商的 DNS 解析

WireGuard

安装 luci-proto-wireguard qrencode

Netword =》 Interfaces =》新建协议 Wireguard Vpn

通用设置 =》 生成新的密钥对 =》 添加监听端口 =》Ip 地址选择一个与内网不冲突的网段 10.0.6.0

防火墙设置 =》 加入 lan 区域

对端设置 =》 创建新的密钥对 =》ip 地址选择一个网段内的 ip =》生成二维码 =》修改链接地址为配置了 DDNS 的域名 =》扫描二维码或复制配置添加

O2OA 私有化部署

安装 mysql 数据库

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
# 安装服务
sudo apt install -y mysql-server

# 检查服务是否启动
systemctl status mysql

# 首次登录没有密码
sudo mysql -u root -p

# 创建对应的数据库

CREATE DATABASE my_database
CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
# 查看所有数据库
SHOW DATABASES;

# 删除数据库
DROP DATABASE database_name;
# 修改字符集和排序
ALTER DATABASE database_name
CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;

# 创建新用户
# 'localhost':表示该用户只能从本地机器连接到数据库。如果希望该用户可以从任何主机连接,可以使用 '%',如 'new_user'@'%'。
CREATE USER 'new_user'@'localhost' IDENTIFIED BY 'your_password';
# 查看所有用户
SELECT User, Host FROM mysql.user;


#ALL PRIVILEGES:赋予所有权限。
# my_database.*:指示该用户可以对 my_database 数据库中的所有表进行操作。
# 'localhost':表示该用户只能从本地连接。
GRANT ALL PRIVILEGES ON my_database.* TO 'new_user'@'localhost';

# 刷新权限
FLUSH PRIVILEGES;

# 查看端口
SHOW VARIABLES LIKE 'port';

cat /etc/mysql/mysql.conf.d/mysqld.cnf

下载安装包

下载 安装包

解压并复制到 /opt

1
2
3
unzip  o2server-8.2.3-linux-x64.zip

mv o2server /opt

配置服务器端口

O2OA 服务器端口配置文件所在位置:o2server/config/node_127.0.0.1.json

如果目录里没有该文件或者没有 config 目录,可以新建一个 config 目录,然后从 configSample 目录里 COPY 一个到新建的 config 目录下。

1
# 自行修改web端口配置

自启动服务

因为需要用 root 用户启动服务,所以先配置开机启动服务

1
2
3
4
./service_linux.sh o2server start_linux.sh

systemctl enable o2server
systemctl start o2server

配置数据库链接

1
jdbc:mysql://127.0.0.1:3306/o2oa_db?autoReconnect=true&allowPublicKeyRetrieval=true&useSSL=false&useUnicode=true&characterEncoding=UTF-8&useLegacyDatetimeCode=false&serverTimezone=GMT%2B8

Three.js 基础

模型类型

.fbx

FBX 是 FilmBoX 这套软件所使用的格式,后改称 Motionbuilder。因为 Motionbuilder 扮演的是动作制作的平台,所以在前端的 modeling 和后端的 rendering 也都有赖于其它软件的配合,所以 Motionbuilder 在档案的转换上自然下了一番功夫。

FBX 最大的用途是用在诸如在 Max、Maya、Softimage 等软件间进行模型、材质、动作和摄影机信息的互导,这样就可以发挥 Max 和 Maya 等软件的优势。可以说,FBX 方案是非常好的互导方案。

.glTF

glTF 是一种可以减少 3D 格式中与渲染无关的冗余数据并且在更加适合 OpenGL 簇加载的一种 3D 文件格式。glTF 的提出是源自于 3D 工业和媒体发展的过程中,对 3D 格式统一化的急迫需求。如果用一句话来描述:glTF 就是三维文件的 JPEG ,三维格式的 MP3。在没有 glTF 的时候,大家都要花很长的的时间来处理模型的载入。

很多的游戏引擎或者工控渲染引擎,都使用的是插件的方式来载入各种格式的模型。可是,各种格式的模型都包含了很多无关的信息。就 glTF 格式而言,虽然以前有很多 3D 格式,但是各种 3D 模型渲染程序都要处理很多种的格式。对于那些对载入格式不是那么重要的软件,可以显著减少代码量,所以也有人说,最大的受益者是那些对程序大小敏感的 3D Web 渲染引擎,只需要很少的代码就可以顺利地载入各种模型了。

此外,glTF 是对近二十年来各种 3D 格式的总结,使用最优的数据结构,来保证最大的兼容性以及可伸缩性。这就好比是本世纪初 xml 的提出。glTF 使用 json 格式进行描述,也可以编译成二进制的内容:bglTF。glTF 可以包括场景、摄像机、动画等,也可以包括网格、材质、纹理,甚至包括了渲染技术(technique)、着色器以及着色器程序。同时由于 json 格式的特点,它支持预留一般以及特定供应商的扩展。

.obj

OBJ 文件是 Alias|Wavefront 公司为它的一套基于工作站的 3D 建模和动画软件”Advanced Visualizer”开发的一种标准 3D 模型文件格式,很适合用于 3D 软件模型之间的互导。目前几乎所有知名的 3D 软件都支持 OBJ 文件的读写。OBJ 文件是一种文本文件,可以直接用写字板打开进行查看和编辑修改。

物体 位移/缩放/旋转

1
2
3
4
5
6
7
8
cube.position.x = 0;
cube.position.set(0, 0, 0);

cube.scale.y = 1;
cube.scale.set(0, 1, 0);

cube.rotate.z = Math.PI / 4;
cube.rotate.set(0, 0, Math.PI / 4);

一个物体的 position/scale/rotate 属性描述一个物体的 位置/缩放/旋转,当这个物体再世界坐标系中,那么他的 位移/缩放/旋转 相对于世界坐标系。

如果物体在另一个物体中,那么他的 位移/缩放/旋转 相对于父元素的位置。

1
2
3
4
// 由于物体位置相对于父元素,因此cube在坐标原点
parentCube.add(cube);
parentCube.position.x = -3;
cube.position.x = 3;

画布适应窗口的变化

1
2
3
4
5
6
7
8
window.addEventListener("resize", () => {
// 更新摄像头宽高比
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();
// 更新渲染器
renderer.setSize(window.innerWidth, window.innerHeight);
});

通过 GUI 快速调试参数

通过安装 dat.gui (不推荐)

1
import * as dat from "dat.gui";

新方法是使用 threejs 自带的 GUI (推荐)

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
import { GUI } from "three/examples/jsm/libs/lil-gui.module.min";

const gui = new GUI();
const handle = {
click: () => console.log("click"),
fullscreen: () => console.log("fullscreen"),
};
// 添加两个按钮,点击和全屏
gui.add(handle, "click").name("点击");
gui.add(handle, "fullscreen").name("全屏");

//修改数值,最小值-10,最大值10,步长1
gui.add(cube.position, "x").name("x轴位置").min(-10).max(10).step(1);

//使用 folder 分组
let folder = gui.addFolder("按钮组");
folder.add(handle, "click").name("点击");
folder.add(handle, "fullscreen").name("全屏");

//事件
gui.add(handle, "click").onChange(noop).onFinishChange(noop);

//颜色
const colors = {
cubeColor: "#ff0000",
};
gui
.addColor(colors, "cubeColor")
.name("颜色")
.onChange(() => cube.material.color.set(val));

Geometry 几何体

顶点/索引

threejs 中使用 position类型化数组描述顶点的位置信息。三个为一组,绘制时会将一组顶点绘制为一个三角形,复杂的几何体也是由多个三角形构成。

顶点的排列顺序(绕序)影响绘制的效果,逆时针 (Counter Clockwise, CCW) 排列的面视为正面, 顺时针排列视为反面。模型情况下,背面不会渲染以便提高性能。

Three.js 使用相机的视角和顶点位置之间的关系来判断一个面是正面还是反面。当从相机位置看时,顶点按照逆时针排列的面会被认为是正面;相反,顺时针排列的面会被认为是背面。顶点顺序的判断依赖于面法向量(normal vector)。法向量是垂直于面的一个矢量,它通过面顶点的排列顺序来决定。如果法向量指向相机方向,面被认为是正面,否则是反面。

1
2
3
4
5
6
7
8
const position = new Float32Array([
1, 1, 0, -1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0, -1, -1, 0,
]);
const geometry = new THREE.BufferGeometry();

const indices = new Uint16Array();
geometry.setAttribute("position", new THREE.BufferAttribute(position, 3));
const mesh = new THREE.Mesh(geometry, material);

但是查看 position 属性会发现有 6 个顶点,这时可以通过建立索引,共用顶点优化顶点数量。

1
2
3
4
5
6
// 去除掉可以公用的顶点,
const position = new Float32Array([1, 1, 0, -1, -1, 0, 1, -1, 0, -1, 1, 0]);

// 使用索引来描述
const indices = new Uint16Array([0, 1, 2, 0, 3, 1]);
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
顶点分组

为几何体的顶点分组,可以为每个组设置单独的材质。

1
2
3
4
geometry.addGroup(0, 3, 0); // 开始位置,数量,材质索引
geometry.addGroup(3, 3, 1);

const mesh = new THREE.Mesh(geometry, [material, material2]);
顶点转换

对几何体的顶点操作(position, rotate, translate)通常是不推荐的,这会改变几何体原有的顶点信息(attribute.position), 通常在物体(Mesh)上修改。

UV

UV 决定了 2D 纹理如何映射到 3D 空间中,U V 分别代表 2D 纹理的横纵坐标(为了与 3D 中的 X Y Z 坐标区分)。在模型建好之后会将纹理展开到 2D 平面中,这一过程叫做 UV 展开。常用的算法有投影展开法(Projection Mapping),迭代展开法(Iterative Unwrapping),Least Squares Conformal Mapping (LSCM) 和 Angle-Based Flattening (ABF) 等。

如果一个物体缺失 UV 信息,那么纹理将无法映射到物体表面。如果 UV 坐标没有覆盖(1,1)区域,剩余位置颜色会自动按 UV 坐标边缘采集。

法线

法线是垂直与平面的向量,决定了光纤如何反射。

1
2
3
4
5
6
7
8
9
10
// 法线辅助器
import { VertexNormalsHelper } from "three/examples/jsm/helpers/VertexNormalsHelper.js";
const geometry = new THREE.BufferGeometry();
// ...
// 自动计算法线
geometry.computeVertexNormals();
// ...
// 添加法线辅助器
const helper = new VertexNormalsHelper(mesh, 0.2, 0x00ff00);
scene.add(helper);
包围盒

包围盒用于可视化的检视物体,或用于碰撞检测。

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
fbxLoader.load(floorFbx, function (group) {
// 通过名称或ID查找到模型中指定的物体
const mesh = group.getObjectById(40);
// const mesh = group.getObjectByName('a23')

// 由于物体的父级可能存在几何变化导致与物体的尺寸不同,因此需要更新物体的世界矩阵
// 更新父级, 更新子集
mesh.updateWorldMatrix(true, true);

// 需要手动调用几何体包围盒计算函数
const geometry = mesh.geometry;
geometry.computeBoundingBox();

// 获取包围盒计算结果
const box = geometry.boundingBox;

// 使用世界矩阵更新包围盒
box.applyMatrix4(mesh.matrixWorld);

// 创建包围盒辅助器显示包围盒
const boxHelper = new THREE.Box3Helper(box, 0xff0000);

// 添加到场景中
scene.add(boxHelper);
});

多个物体包围盒

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const mesh = group.getObjectById(40);
const mesh1 = group.getObjectById(41);
const mesh2 = group.getObjectById(42);

const boxUnion = new THREE.Box3();
[mesh, mesh1, mesh2].forEach((mesh) => {
mesh.updateWorldMatrix(true, true);
const geometry = mesh.geometry;
geometry.computeBoundingBox();
const box = geometry.boundingBox;
box.applyMatrix4(mesh.matrixWorld);
const boxHelper = new THREE.Box3Helper(box, 0xff0000);
scene.add(boxHelper);

boxUnion.union(box);
});

const boxHelper = new THREE.Box3Helper(boxUnion, 0xff0000);
scene.add(boxHelper);

使用 setFromObject,自动计算和世界轴对齐的一个对象 Object3D (含其子对象)的包围盒,计算对象和子对象的世界坐标变换。

1
2
3
4
5
6
7
8
9
10
const boxUnion = new THREE.Box3();
[mesh, mesh1, mesh2].forEach((mesh) => {
const box = new THREE.Box3().setFromObject(mesh);
const boxHelper = new THREE.Box3Helper(box, 0xff0000);
scene.add(boxHelper);
boxUnion.union(box);
});

const boxHelper = new THREE.Box3Helper(boxUnion, 0xff0000);
scene.add(boxHelper);
几何体居中/获取中心
1
2
3
4
5
6
7
8
9
// 会将几何体的中心放到世界中心
geometry.center();

// 但是Mesh对象可能有几何变换,导致物体看上去仍然偏移
// 需要重置Mesh的几何信息
mesh.position.set(0, 0, 0);

// 获取包围盒的中心点
const vec3 = box.getCenter(new THREE.Vector3());

边缘几何体/线框几何体

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
// 通过名称或ID查找到模型中指定的物体
const mesh = group.getObjectById(40);

const geometry = mesh.geometry;

// 创建边缘几何体
const edgeGeometry = new THREE.EdgesGeometry(geometry);

// 创建线框几何体
// const wireGeometry = new THREE.WireframeGeometry(geometry);

// 创建线段材质
const lineMaterial = new THREE.LineBasicMaterial({
color: 0xffffff,
});

// 创建线段物体
const lineMesh = new THREE.LineSegments(edgeGeometry, lineMaterial);

// 由于新创建的物体丢失了源物体的世界矩阵信息,因此位置大小可能表现不同
// 需要使用源物体的世界矩阵信息重新赋值

// 更新源物体的世界矩阵信息
// mesh.updateWorldMatrix();

// 应用源物体的世界矩阵
lineMesh.matrix.copy(mesh.matrixWorld);

//将矩阵信息解构到物体的变换信息上
lineMesh.matrix.decompose(
lineMesh.position,
lineMesh.quaternion,
lineMesh.scale
);
scene.add(lineMesh);

材质与纹理

three.js 中的材质就是几何体表面的材料。所有材质均继承自 Material。ThreeJS 的材质分为:基础材质、深度材质、法向量材质、琥珀材质、冯氏材质、标准材质、着色器材质、基础线材质以及虚线材质。材质就像物体的皮肤,让几何体看起来像金属还是木板,是否透明,什么颜色。

纹理的基类是 Texture,通过给其属性 Image 传入一个图片从而构造出一个纹理。纹理是材质的属性,材质和几何体 Gemotry 构成 Mesh

材质基类 Material
  • transparent: 定义此材质是否透明,可以配合 opacity 使用,控制透明程度。
纹理基类(Texture)
  • colorSpace

    1.THREE.NoColorSpace: 这意味着没有应用任何特定的颜色空间,纹理的颜色数据会被原样使用。这个选项通常用于已经处于需要的颜色空间中的纹理,或者那些不依赖于颜色空间的特定用途。

    2.THREE.SRGBColorSpace:在此颜色空间中,颜色数据以 SRGB 格式存储。SRGB 是一个 RGB 标准,它试图将色彩的表现和人眼感知到的颜色更好地匹配。相对于线性颜色空间,SRGB 颜色空间在暗区提供了更多的颜色级别。使用此颜色空间时,需要注意图像的颜色可能会被转换为非线性的 SRGB 格式。

    3.THREE.LinearSRGBColorSpace 这也是一个以 SRGB 格式存储颜色数据的颜色空间,但颜色数据被当作线线性性数据处理。在进行计算和处理时,这种颜色空间可以提供更精确的结果。但是由于人眼对光线的感知,50%感觉的亮度只需要 18%的发光强度,这可能导致物体颜色过浅。

  • needsUpdate 指定需要重新编译材质。如果动态设置纹理需要将此属性设置为true

基础材质 MeshBasicMaterial
  • map: 颜色贴图。默认会随几何体的大小拉伸。 开启 transparent 属性,会影响有透明通道图片的效果
MeshMatcapMaterial

由一个材质捕捉(MatCap,或光照球(Lit Sphere))纹理定义。mapcap 编码了光照,颜色等信息,因此不对光照做出反应。可以投射阴影,但是不会接受阴影。
是一个低成本实现光照效果得材质,缺点是固定了光照信息,不能对光照反应。

MeshLambertMaterial

该材质使用基于非物理的 Lambertian 模型来计算反射率。 这可以很好地模拟一些表面(例如未经处理的木材或石材),但不能模拟具有镜面高光的光泽表面(例如涂漆木材)

MeshPhongMaterial

该材质使用非物理的 Blinn-Phong 模型来计算反射率。可以模拟高光的或玻璃效果,但是由于不是物理模型,因此玻璃效果不能与场景中其他物体作用,只能通过透明度设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 实现玻璃效果,只限于与环境光线相互作用
// 加载环境贴图是必须的,映射模式需要修改为折射
envMap.mapping = THREE.EquirectangularRefractionMapping;
// scene.environment = envMap;
// scene.background = envMap;

const box = new THREE.BoxGeometry(1, 1, 1);
const bujianMatertal = new THREE.MeshPhongMaterial({
refractionRatio: 0.7, // 空气的折射|反射率
// 当设置为 THREE.EquirectangularRefractionMapping 值为折射系数
// 越高越像玻璃
reflectivity: 0.99,
envMap: envMap,
});

const boxMesh = new THREE.Mesh(box, bujianMatertal);

// 必须要有环境光,与环境光相互作用
const light = new THREE.AmbientLight(0xffffff, 1);
scene.add(light);

FOG 雾

通常会把背景颜色和雾的颜色设置成相同颜色,可以让雾融入到场景中

镭射光纤,选择物体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
window.addEventListener("pointermove", onPointerMove);
function onPointerMove(event) {
// 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
}

function rayTest() {
raycaster.setFromCamera(pointer, camera);

const intersects = raycaster.intersectObjects(mesharr);
if (intersects.length === mesharr.length) return;

// intersects 是一个按深度排序的数组
for (let i = 0; i < intersects.length; i++) {}
}

function animate() {
rayTest();
//...
}

补间动画

THREE.js 集成了 Tween 动画库

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
import { Tween, Easing } from "three/examples/jsm/libs/tween.module.js";

const tween = new Tween(mesh.position);
// 移动到某个位置
tween.to({ x: 2 }, 1000);
// 位置更新时回调
tween.onUpdate(() => {
// console.log(mesh.position.x);
});
//重复次数
tween.repeat(10);
// 是否往返
tween.yoyo(true);
// 是否延迟
tween.delay(1000);

// 设置缓动
tween.easing(Easing.Bounce.In);
tween.start();

// 动画链
// tween.chain(tween2);
// tween2.chain(tween);

// 更新动画
function animate() {
tween.update();
}

FocalBoard 私有化部署

最后更新

2024-12-13

官方安装包

安装 官方包, [官方文档]

1
2
3
wget https://github.com/mattermost/focalboard/releases/download/v0.15.0/focalboard-server-linux-amd64.tar.gz
tar -xvzf focalboard-server-linux-amd64.tar.gz
sudo mv focalboard /opt

安装数据库

1
sudo apt install postgresql postgresql-contrib

以 postgres 用户创建一个新的数据库

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
sudo --login --user postgres
psql

CREATE DATABASE <your-db-user>;
CREATE USER <your-db-user> WITH PASSWORD <'your-password'>;

# 查看schema列表
\dn

# 查看用户列表
\du

# 查看数据库列表

\l+

# 赋予用于操作数据库的所有权限
GRANT ALL PRIVILEGES ON DATABASE <your-db> TO <your-db-user>;

GRANT ALL ON SCHEMA public TO <your-db-user>;

\c <your-db-user> postgres
# You are now connected to database "your-db-user" as user "postgres".

# 赋予有用操作public schema的权限
GRANT ALL ON SCHEMA public TO <your-db-user>;

# 退出 postgres 用户
\q

exit

配置链接信息

1
2
3
4
vi /opt/focalboard/config.json

"dbtype": "postgres",
"dbconfig": "postgres://your-db-user:your-db-password@localhost/boards?sslmode=disable&connect_timeout=10"

启动服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sudo vi /lib/systemd/system/focalboard.service

[Unit]
Description=Focalboard server

[Service]
Type=simple
Restart=always
RestartSec=5s
ExecStart=/opt/focalboard/bin/focalboard-server
WorkingDirectory=/opt/focalboard

[Install]
WantedBy=multi-user.target
1
2
3
4
5
sudo systemctl daemon-reload
sudo systemctl start focalboard.service
sudo systemctl enable focalboard.service

curl localhost:8000

nginx 配置

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
server {
listen 443;
listen [::]:443;
server_name focal.iftrue.me;

location ~ /ws/* {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 50M;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_buffers 256 16k;
proxy_buffer_size 16k;
client_body_timeout 60;
send_timeout 300;
lingering_timeout 5;
proxy_connect_timeout 1d;
proxy_send_timeout 1d;
proxy_read_timeout 1d;
proxy_pass http://192.168.48.148:8000;
}

location / {
client_max_body_size 50M;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_buffers 256 16k;
proxy_buffer_size 16k;
proxy_read_timeout 600s;
proxy_cache_revalidate on;
proxy_cache_min_uses 2;
proxy_cache_use_stale timeout;
proxy_cache_lock on;
proxy_http_version 1.1;
proxy_pass http://192.168.48.148:8000;
}
}

FAQ

  • error [2025-03-17 04:28:23.400 Z] Table creation / migration failed caller=”sqlstore/sqlstore.go:75” error=”pq: permission denied for schema public”

    参照数据库配置步骤,需要赋予用户操作 public schema 的权限

  • Copyrights © 2015-2025 SunZhiqi

此时无声胜有声!

支付宝
微信