Skip to content

Commit cf4f0e4

Browse files
committed
feat: ✨ 支持自定义项目前缀和动态类型判断
- ✨ 新增自定义项目 ID 前缀配置(支持 TAP、M、F 等多前缀) - ✨ 实现动态 story/issue 类型判断(先尝试 story,失败后尝试 issue) - ✨ 添加类型缓存机制,避免重复 API 请求 - ✨ 支持匹配至少 7 位数字的项目 ID(如 TAP-6478011619) - ✅ 修复 GitLab Jira 集成环境下的链接替换失败问题 - ✅ 修复 ResizeObserver 空值导致的类型错误 - ✅ 支持新版飞书页面的 URL 编码数据格式 - 🔧 优化链接替换策略:只替换 #TAP-xxx 部分,保留原链接功能 - 🔧 优先处理 GitLab 自动生成的 Jira 链接(a.gfm.gfm-issue) - 🔧 添加 MutationObserver 支持动态加载内容 - 🧹 清理所有调试日志(54 个),只保留错误日志 - 📝 更新 README 添加详细的本地安装和开发说明 📊 影响面评估: - 影响所有使用自定义前缀的 GitLab 项目 - 兼容旧版 M-xxx (story) 和F-xxx (issue) 格式 - 需要用户在配置页面设置项目 ID 前缀 - 首次访问自定义前缀的项目时会有轻微延迟(动态类型判断) 💡 使用说明: - M-xxx 自动识别为 Story 类型 - F-xxx 自动识别为 Issue 类型 - 其他前缀会自动判断类型并缓存结果
1 parent cc3b827 commit cf4f0e4

File tree

6 files changed

+569
-76
lines changed

6 files changed

+569
-76
lines changed

README.md

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,107 @@
1414

1515
[Chrome 应用商店](https://chromewebstore.google.com/detail/gitlab-link-to-lark/ocmkgfnifakgckfeofcoakiniljdjcfp)
1616

17-
## 源码安装
17+
## 本地开发安装
18+
19+
### 1. 克隆项目
20+
21+
```bash
22+
git clone https://github.com/wangbax/gitlab-link-to-lark.git
23+
cd gitlab-link-to-lark
24+
```
25+
26+
### 2. 安装依赖
27+
28+
要求:Node.js >= 18
1829

1930
```bash
20-
# node -v >= 18
21-
# 默认使用 yarn 安装依赖,如未安装 yarn,可使用 npm 安装
22-
# yarn
31+
# 使用 yarn(推荐)
32+
yarn install
33+
34+
# 或使用 npm
35+
npm install
36+
```
37+
38+
### 3. 构建项目
39+
40+
```bash
41+
# 使用 yarn
2342
yarn build
24-
# npm
43+
44+
# 或使用 npm
2545
npm run build
2646
```
2747

28-
选择 dist 文件夹中的构建文件并在浏览器中安装它
48+
构建完成后,产物会生成在 `dist` 目录中。
49+
50+
### 4. 在浏览器中加载扩展
51+
52+
#### Chrome/Edge 浏览器
53+
54+
1. 打开浏览器,访问扩展管理页面:
55+
- Chrome: `chrome://extensions/`
56+
- Edge: `edge://extensions/`
57+
58+
2. 开启右上角的「开发者模式」
2959

3060
<img src="./docs/install-1.png" alt="install" />
61+
62+
3. 点击「加载已解压的扩展程序」
63+
3164
<img src="./docs/install-2.png" alt="install" />
65+
66+
4. 选择项目的 `dist` 文件夹
67+
3268
<img src="./docs/install-3.png" alt="install" />
3369

34-
# 使用
70+
5. 扩展安装成功!
71+
72+
### 5. 配置扩展
3573

36-
打开浏览器扩展管理页面,找到 GitLab link to Lark 插件,点击 Options,填写 GitLab 地址和飞书命名空间,点击提交
74+
安装完成后,点击扩展图标或在扩展管理页面点击「选项」进行配置:
3775

3876
<img src="./docs/install-4.png" alt="install" />
3977

78+
需要配置以下信息:
79+
- **飞书命名空间**:你的飞书项目空间名称(如:`pojq34`
80+
- **项目 ID 前缀**:GitLab 中使用的项目 ID 前缀,多个用逗号分隔(如:`TAP,M,F`
81+
82+
**注意**
83+
- `M-xxx` 会自动识别为 Story 类型
84+
- `F-xxx` 会自动识别为 Issue 类型
85+
- 其他自定义前缀(如 `TAP-xxx`)会自动动态判断是 Story 还是 Issue
86+
87+
## 开发说明
88+
89+
### 目录结构
90+
91+
```
92+
├── dist/ # 构建产物
93+
├── src/
94+
│ ├── js/ # JavaScript 源码
95+
│ │ ├── background.js # 后台脚本
96+
│ │ ├── index.js # 内容脚本
97+
│ │ ├── options.js # 配置页面
98+
│ │ └── ...
99+
│ ├── html/ # HTML 页面
100+
│ └── assets/ # 静态资源
101+
├── gulpfile.js # 构建配置
102+
└── package.json
103+
```
104+
105+
### 开发模式
106+
107+
```bash
108+
# 监听文件变化并自动构建
109+
npm run watch
110+
```
111+
112+
修改代码后,需要在浏览器扩展管理页面点击「重新加载」按钮。
113+
114+
# 使用
115+
116+
配置完成后,访问 GitLab 项目页面,插件会自动将 `#TAP-xxx``#M-xxx``#F-xxx` 等格式的项目 ID 转换为可点击的飞书链接。
117+
40118
# Preview
41119

42120
<img src="./docs/preview.png" alt="preview" />

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "gitlab-link-to-lark",
33
"description": "一个浏览器插件,用于提供 GitLab 项目中关联的飞书项目 ID 转换为飞书链接。方便在 GitLab 项目中快速跳转查看飞书项目信息 --- build for cyy(づ ̄3 ̄)づ╭❤~",
4-
"version": "1.3.0",
4+
"version": "2.0.0",
55
"scripts": {
66
"build": "NODE_ENV=production gulp build",
77
"dev": "NODE_ENV=development gulp dev"
@@ -14,5 +14,6 @@
1414
"prettier": "^3.2.5",
1515
"rollup": "^4.17.2",
1616
"rollup-plugin-dotenv": "^0.5.1"
17-
}
18-
}
17+
},
18+
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
19+
}

src/html/options.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,24 @@ <h1 class="text-2xl font-bold mb-8">配置</h1>
120120
(多个域名触发请用 , 作为分割)
121121
</p>
122122
</div>
123+
<div class="relative z-0 w-full mb-5">
124+
<div>
125+
<input
126+
type="text"
127+
name="prefixes"
128+
placeholder=" "
129+
class="pt-3 pb-2 block w-full px-0 mt-0 bg-transparent border-0 border-b-2 appearance-none focus:outline-none focus:ring-0 focus:border-black border-gray-200"
130+
/>
131+
<label
132+
for="prefixes"
133+
class="absolute duration-300 top-3 -z-1 origin-0 text-gray-500"
134+
>项目 ID 前缀</label
135+
>
136+
</div>
137+
<p class="text-xs text-gray-500">
138+
输入识别的项目 ID 前缀,多个用逗号分隔,如 TAP,M,F (默认为 m,f)
139+
</p>
140+
</div>
123141
<div
124142
class="relative z-0 w-full mb-5 text-sm text-red-600 hidden"
125143
id="error"

src/js/background.js

Lines changed: 175 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,26 +42,119 @@ const extractScriptContent = (text) => {
4242
return ""; // 如果没有找到匹配,返回空字符串
4343
};
4444

45-
const getLarkProjectInfoByDetail = (text) => {
45+
const getLarkProjectInfoByDetail = (text, type, id) => {
46+
47+
// 打印响应的前面部分,看看 HTML 结构
48+
4649
const reg = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi;
4750
const scripts = text.match(reg);
51+
52+
if (!scripts) {
53+
return {
54+
error: true,
55+
data: null,
56+
};
57+
}
58+
4859
let content = "";
60+
let foundWorkItem = 0;
61+
62+
// 尝试多种模式查找数据
63+
4964
for (let i = 0; i < scripts.length; i++) {
50-
const text = scripts[i];
51-
const isContain =
52-
text.includes("window.detail") &&
53-
text.includes(`/work_item/${type}/${id}`);
65+
const scriptText = scripts[i];
5466

55-
if (isContain) {
56-
content = text;
67+
// 旧版:window.detail + /work_item 路径
68+
const hasWindowDetail = scriptText.includes("window.detail");
69+
const hasWorkItemPath = scriptText.includes(`/work_item/${type}/${id}`);
70+
71+
// 新版关键字
72+
const hasWorkItem = scriptText.includes("work_item");
73+
const hasIdString = scriptText.includes(id);
74+
75+
// 打印包含 work_item + id 的 script
76+
if (hasWorkItem && hasIdString) {
77+
78+
// 检查是否包含响应数据(有 "data" 或 "result" 字段)
79+
const hasDataField = scriptText.includes('%22data%22%3A') || scriptText.includes('"data":');
80+
const hasPayloadOnly = scriptText.includes('%22payload%22%3A') || scriptText.includes('"payload":');
81+
82+
// 检查 API 类型
83+
const isDemandFetch = scriptText.includes('APIDemandFetchWorkItem');
84+
const isMinimalInfo = scriptText.includes('APIFetchMinimalInfoWorkItem');
85+
const isFindBasic = scriptText.includes('APIFindBasicWorkItemByIDV2');
86+
87+
88+
// 只检查包含响应数据的 script(不是 payload)
89+
if (!hasDataField && hasPayloadOnly) {
90+
continue;
91+
}
92+
93+
// 优先使用 APIDemandFetchWorkItem 或 APIFindBasicWorkItemByIDV2,它们的错误信息更准确
94+
// APIFetchMinimalInfoWorkItem 即使不存在也会返回 200,所以跳过
95+
if (isMinimalInfo && !isDemandFetch && !isFindBasic) {
96+
continue;
97+
}
98+
99+
// 检查是否有错误标记(URL 编码格式)
100+
// "code":404 编码后是 %22code%22%3A404
101+
// "code":200 编码后是 %22code%22%3A200
102+
const hasError404 = scriptText.includes('%22code%22%3A404') || scriptText.includes('"code":404');
103+
const hasCode200 = scriptText.includes('%22code%22%3A200') || scriptText.includes('"code":200');
104+
105+
106+
// 如果有 404 错误,说明这个类型不存在,跳过
107+
if (hasError404) {
108+
continue;
109+
}
110+
111+
// 检查新格式的类型标识(需要检查 URL 编码和非编码两种格式)
112+
// URL 编码: %22work_item_api_name%22%3A%22story%22 -> "work_item_api_name":"story"
113+
const hasStoryType =
114+
scriptText.includes('"work_item_api_name":"story"') ||
115+
scriptText.includes('"work_item_type":"story"') ||
116+
scriptText.includes('%22work_item_api_name%22%3A%22story%22') ||
117+
scriptText.includes('%22work_item_type%22%3A%22story%22');
118+
119+
const hasIssueType =
120+
scriptText.includes('"work_item_api_name":"issue"') ||
121+
scriptText.includes('"work_item_type":"issue"') ||
122+
scriptText.includes('%22work_item_api_name%22%3A%22issue%22') ||
123+
scriptText.includes('%22work_item_type%22%3A%22issue%22');
124+
125+
126+
// 如果检测到类型匹配且没有错误,立即返回
127+
if (hasStoryType && type === 'story' && hasCode200) {
128+
return {
129+
error: false,
130+
data: { type: 'story' },
131+
};
132+
}
133+
if (hasIssueType && type === 'issue' && hasCode200) {
134+
return {
135+
error: false,
136+
data: { type: 'issue' },
137+
};
138+
}
139+
}
140+
141+
if (hasWorkItemPath) foundWorkItem++;
142+
143+
// 旧版匹配规则
144+
if (hasWindowDetail && hasWorkItemPath) {
145+
content = scriptText;
57146
break;
58147
}
59148
}
60-
if (!content)
149+
150+
151+
if (!content) {
61152
return {
62153
error: true,
63154
data: null,
64155
};
156+
}
157+
65158
content = content.replace(/\n/g, "");
66159
content = content.replace(new RegExp("\\\\x3C", "g"), "<");
67160
content = content.replace(new RegExp("\x3C", "g"), "<");
@@ -134,14 +227,34 @@ const getLarkProjectInfoByPrefetchList = (text) => {
134227
};
135228
};
136229

230+
// 类型缓存:记录每个 tid 对应的实际类型
231+
const typeCache = new Map();
232+
137233
async function getLarkProjectInfo({ tid, app }) {
138-
const [t, id] = tid.split("-");
139-
const type = t === "m" ? "story" : "issue";
234+
const [prefix, id] = tid.split("-");
235+
const prefixLower = prefix.toLowerCase();
236+
237+
// 快速路径:保持向后兼容
238+
let type = "story";
239+
240+
if (prefixLower === "m") {
241+
type = "story";
242+
} else if (prefixLower === "f") {
243+
type = "issue";
244+
} else {
245+
// 对于其他前缀,检查缓存
246+
if (typeCache.has(tid)) {
247+
type = typeCache.get(tid);
248+
} else {
249+
type = await detectProjectType(app, id, tid);
250+
}
251+
}
252+
140253
let url = `${LARK_DOMAIN_HOST}/${app}/${type}/detail/${id}`;
141254
const res = await fetch(url);
142255
const text = await res.text();
143-
let info = { tid };
144-
const detailInfo = getLarkProjectInfoByDetail(text);
256+
let info = { tid, actualType: type };
257+
const detailInfo = getLarkProjectInfoByDetail(text, type, id);
145258
if (detailInfo.data) {
146259
info = {
147260
...info,
@@ -157,6 +270,56 @@ async function getLarkProjectInfo({ tid, app }) {
157270
return info;
158271
}
159272

273+
// 动态检测项目类型:先尝试 story,失败后尝试 issue
274+
async function detectProjectType(app, id, tid) {
275+
276+
// 先尝试 story
277+
try {
278+
let url = `${LARK_DOMAIN_HOST}/${app}/story/detail/${id}`;
279+
const res = await fetch(url);
280+
const text = await res.text();
281+
282+
const detailInfo = getLarkProjectInfoByDetail(text, 'story', id);
283+
if (detailInfo.data) {
284+
typeCache.set(tid, "story");
285+
return "story";
286+
}
287+
288+
const prefetchListInfo = getLarkProjectInfoByPrefetchList(text);
289+
if (prefetchListInfo.data) {
290+
typeCache.set(tid, "story");
291+
return "story";
292+
}
293+
} catch (e) {
294+
console.error('[Lark Background] Story request failed:', e.message);
295+
}
296+
297+
// 尝试 issue
298+
try {
299+
let url = `${LARK_DOMAIN_HOST}/${app}/issue/detail/${id}`;
300+
const res = await fetch(url);
301+
const text = await res.text();
302+
303+
const detailInfo = getLarkProjectInfoByDetail(text, 'issue', id);
304+
if (detailInfo.data) {
305+
typeCache.set(tid, "issue");
306+
return "issue";
307+
}
308+
309+
const prefetchListInfo = getLarkProjectInfoByPrefetchList(text);
310+
if (prefetchListInfo.data) {
311+
typeCache.set(tid, "issue");
312+
return "issue";
313+
}
314+
} catch (e) {
315+
console.error('[Lark Background] Issue request failed:', e.message);
316+
}
317+
318+
// 两次都失败,默认返回 story
319+
typeCache.set(tid, "story");
320+
return "story";
321+
}
322+
160323
chrome.runtime.onInstalled.addListener((details) => {
161324
if (details.reason === "install") {
162325
// 打开选项页

0 commit comments

Comments
 (0)