refactor: bootstrap → tailwind/shadcn-vue infra + sonner toast

Set up Tailwind v4 + shadcn-vue infrastructure and replace the first
Bootstrap component (Toast) as a vertical slice that proves the approach.

Infrastructure (refactor/01 phase A):
- Add tailwindcss + @tailwindcss/vite (devDependencies) and wire the plugin
  in vite.config.js
- Add shadcn-vue runtime deps: reka-ui, class-variance-authority, clsx,
  tailwind-merge, lucide-vue-next
- Add components.json and frontend/lib/utils.js (cn helper)
- Define design tokens via shadcn CSS variables (oklch, neutral baseColor)
  in frontend/style/style.css
- Wire dark mode: store.setDarkMode now toggles <html>.dark for the
  Tailwind dark: variant
- Coexist with Bootstrap (no removal yet)

Toast → vue-sonner (refactor/01 phase B item 1):
- Replace internals of widgets/Toast.vue (Bootstrap Toast → vue-sonner)
- Keep store.setAlert() public API unchanged so all 5 call sites stay put
- Map alertStyle text-success/warning/danger/info to sonner severities

Refactor planning:
- Add AGENTS.md so future AI sessions know the project layout, JS-only
  preference, and where to find refactor progress
- Add refactor/ with a 4-task plan, completion discipline, and per-task
  review checklists

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Opus 4.6 committed 2026-04-15 02:37:58 +08:00
1 parent fd7284cbae
commit 0a5b0c1914
18 files changed
+657 -65

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
# AGENTS.md
> 本文件给 AI 协作者(Claude / Codex 等)阅读。说明项目结构、约定、当前重构状态。
---
## 项目简介
**MyIP** (IPCheck.ing) 是一个开源 IP 工具箱,提供 IP 查询、连通性测试、WebRTC 检测、DNS 泄漏检测、网速测试、Whois、MTR、安全检查等功能。前后端同仓,前端 SPA + Node 后端 API。
---
## 技术栈
| 层 | 技术 |
|---|---|
| 前端框架 | Vue 3 (Composition API, `<script setup>`) |
| 状态管理 | Pinia |
| 路由 | vue-router (hash mode) |
| 国际化 | vue-i18n(en / zh / fr / tr) |
| 构建 | Vite |
| UI(重构中) | Bootstrap 5 + bootstrap-icons → 正在迁移到 Tailwind + shadcn-vue |
| 后端 | Express 5 |
| Auth | Firebase Auth(可选,按环境变量启用) |
| PWA | Serwist |
| 测试 | **Node 内置 test runner**(`node --test`,无第三方测试框架) |
| 其它 | chart.js / svgmap / @cloudflare/speedtest / maxmind / whoiser |
---
## 语言与代码风格约定
- **必须使用 JavaScript,不要主动引入 TypeScript。**
- 新文件用 `.js` / `.vue`,`<script setup>` 不带 `lang="ts"`
- 不要把 `jsconfig.json` 改成 `tsconfig.json`
- 用户明确要求 TS 时才使用
- Vue 组件统一 `<script setup>` 写法
- 路径别名 `@` → `frontend/`(见 `jsconfig.json` 与 `vite.config.js`)
- 注释可以写中文,提交信息按现有风格
---
## 目录结构
```
.
├── AGENTS.md ← 本文件
├── refactor/ ← ⚠️ 重构进行中,所有 AI 改动前先看这里
│ ├── README.md ← 重构索引与总进度
│ ├── 01-bootstrap-to-tailwind.md
│ ├── 02-app-vue-split.md
│ ├── 03-add-tests.md
│ └── 04-store-hardcoded.md
│
├── index.html
├── vite.config.js
├── jsconfig.json
├── package.json
│
├── frontend/ ← 前端源码(Vite root 的别名 @)
│ ├── App.vue ← 当前是上帝组件,待拆分(见 refactor/02)
│ ├── main.js ← 入口、Pinia/i18n/router 装载、全局 tooltip 指令
│ ├── store.js ← Pinia 主 store,含硬编码 achievements(见 refactor/04)
│ ├── firebase-init.js
│ ├── sw.js ← Service Worker 源文件(Serwist)
│ ├── router/ ← 路由(与 Bootstrap Offcanvas 强耦合,见 refactor/01)
│ ├── locales/ ← i18n 文案 + security-checklist 数据
│ ├── style/style.css ← 全局样式(含 bootstrap import)
│ ├── utils/ ← 工具函数(valid-ip / transform-ip-data / 快捷键 / 分析等)
│ │ └── getips/ ← 各家 IP 查询源的客户端封装
│ └── components/
│ ├── *.vue ← 顶层功能区组件(IpInfos / SpeedTest / Advanced 等)
│ ├── ip-infos/ ← IP 卡片相关子组件
│ ├── advanced-tools/ ← MTR / Ping / Whois / Mac / 安全清单 等高级工具
│ ├── widgets/ ← Modal / Toast / Preferences / Help 等通用小部件
│ └── svgicons/ ← 内联 SVG 图标
│
├── api/ ← 后端 API handlers(被 backend-server.js 装载)
├── common/ ← 前后端共享:valid-ip、maxmind 服务、referer-check 等
├── public/ ← 静态资源(logos / achievements 图)
├── tests/ ← Node test runner 测试(已含 valid-ip / api-smoke)
├── backend-server.js ← Express API 服务(默认端口 11966)
└── frontend-server.js ← 仅用于 npm start 的静态文件服务
```
---
## 重构进行中 ⚠️
项目正在分阶段重构。**任何代码改动前,请先阅读 `refactor/README.md`**,确认改动是否落在某个进行中的重构子任务里、有没有冲突、有没有约定的迁移路径。
当前 4 个重构子项目(顺序非强制,但建议从 1 开始):
1. `refactor/01-bootstrap-to-tailwind.md` — Bootstrap → Tailwind + shadcn-vue
2. `refactor/02-app-vue-split.md` — App.vue 上帝组件拆分
3. `refactor/03-add-tests.md` — 增加单元测试
4. `refactor/04-store-hardcoded.md` — store.js 硬编码治理
**勾选完成项前的纪律:** 每个子任务标记 `[x]` 之前,必须重新扫一遍当前计划文件相关代码,确认没有遗漏的引用、未迁移的同类调用、漏掉的边界场景。详见 `refactor/README.md` 里的"完成纪律"一节。
---
## 开发命令
```bash
npm run dev # 同时启动 Vite + 后端(nodemon)
npm run build # 前端构建
npm run preview # Vite 预览构建产物
npm run start # 启动构建后的前端 + 后端
npm test # 跑 tests/*.test.js(Node 内置 test runner)
npm run check # test + build,提交前自检
```
---
## 不要做的事
- 不要主动引入 TypeScript
- 不要在不属于当前 4 个重构子任务范围的代码上做"顺手优化"
- 不要在没看 `refactor/` 的情况下批量修改组件样式或 store 结构
- 不要改 `jsconfig.json` 的语言模式
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": false,
"tailwind": {
"config": "",
"css": "frontend/style/style.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}
+23
View File
@@ -0,0 +1,23 @@
<script setup>
import { Primitive } from 'reka-ui';
import { cn } from '@/lib/utils';
import { buttonVariants } from './variants.js';
const props = defineProps({
variant: { type: String, default: 'default' },
size: { type: String, default: 'default' },
as: { type: String, default: 'button' },
asChild: { type: Boolean, default: false },
class: { type: [String, Array, Object], default: undefined },
});
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+2
View File
@@ -0,0 +1,2 @@
export { default as Button } from './Button.vue';
export { buttonVariants } from './variants.js';
+27
View File
@@ -0,0 +1,27 @@
import { cva } from 'class-variance-authority';
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
+21
View File
@@ -0,0 +1,21 @@
<script setup>
// shadcn-vue 风格的 Sonner 包装:暗色模式从 Pinia store 同步
// 见 refactor/01 阶段 B
import { computed } from 'vue';
import { Toaster } from 'vue-sonner';
import { useMainStore } from '@/store';
import 'vue-sonner/style.css';
const store = useMainStore();
const theme = computed(() => (store.isDarkMode ? 'dark' : 'light'));
</script>
<template>
<Toaster
:theme="theme"
position="bottom-right"
rich-colors
close-button
/>
</template>
+2
View File
@@ -0,0 +1,2 @@
export { default as Sonner } from './Sonner.vue';
export { toast } from 'vue-sonner';
+20 -65
View File
@@ -1,78 +1,33 @@
<template>
<div class="toast-container p-3 jn-toast">
<div id="toastInfoMask" class="toast" :class="{ 'dark-mode': isDarkMode }" role="alert" ref="toastEl"
aria-live="assertive" aria-atomic="true">
<div class="toast-header" :class="{ 'dark-mode-title': isDarkMode }">
<strong class="me-auto" :class="alert.alertStyle">{{ alert.alertTitle }}</strong>
<button type="button" class="btn-close" :class="{ 'dark-mode-close-button': isDarkMode }"
data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
{{ alert.alertMessage }}
</div>
</div>
</div>
<Sonner />
</template>
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
// 重构于 refactor/01 阶段 B:Bootstrap Toast → vue-sonner
// 对外契约不变:组件挂载一次,监听 store.alert,调用 store.setAlert(...) 即弹出
import { computed, watch } from 'vue';
import { useMainStore } from '@/store';
import { Toast } from 'bootstrap';
import { Sonner, toast } from '@/components/ui/sonner';
const store = useMainStore();
const isDarkMode = computed(() => store.isDarkMode);
const alert = computed(() => store.alert);
const toastEl = ref(null);
// 将旧的 alertStyle(Bootstrap text-* 类名)映射为 sonner 的方法
const STYLE_TO_TOAST = {
'text-success': toast.success,
'text-warning': toast.warning,
'text-danger': toast.error,
'text-info': toast.info,
};
// 监听 Pinia store 中的 alert 变化
watch(alert, (newVal) => {
if (newVal.alertToShow) {
showToast();
if (!newVal || !newVal.alertToShow) {
return;
}
}, { immediate: true, deep: true });
// 显示 Toast
const showToast = () => {
let duration = alert.value.alertDuration || 2000;
if (toastEl.value) {
const toastInfoMask = new Toast(toastEl.value, {
delay: duration
});
toastInfoMask.show();
} else {
console.error("Toast element not found");
}
}
const adjustButtonPosition = () => {
const screenWidth = window.innerWidth;
const contentWidth = 1600; // 主内容区域的宽度
const spaceOnRight = (screenWidth - contentWidth) / 2;
const button = document.querySelector('.jn-toast');
if (screenWidth > 1600) { // 只在屏幕宽度大于1600px时调整
button.style.right = `${spaceOnRight + 0}px`; // 保持20px的距离
} else {
button.style.right = '0'; // 在小屏幕上使用默认位置
}
}
onMounted(() => {
window.addEventListener('resize', adjustButtonPosition);
adjustButtonPosition();
});
const fn = STYLE_TO_TOAST[newVal.alertStyle] || toast.message;
fn(newVal.alertTitle, {
description: newVal.alertMessage,
duration: newVal.alertDuration || 2000,
});
}, { deep: true });
</script>
<style scoped>
.jn-toast {
position: fixed;
z-index: 10001;
right: 0;
bottom: 0;
margin-bottom: 2pt;
margin-right: 40pt;
max-width: 80vw;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
// shadcn-vue cn() 帮助函数:合并 class,处理 Tailwind 冲突
// 见 refactor/01
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
+4
View File
@@ -134,8 +134,12 @@ export const useMainStore = defineStore('main', {
this.shouldRefreshEveryThing = payload;
},
// 设置黑暗模式
// 同步 .dark class 到 <html>,让 Tailwind 的 dark: 变体生效(refactor/01)
setDarkMode(value) {
this.isDarkMode = value;
if (typeof document !== 'undefined') {
document.documentElement.classList.toggle('dark', !!value);
}
},
// 设置 IP 数据库的使能状态
updateIPDBs({ id, enabled }) {
+78
View File
@@ -2,6 +2,84 @@
@import 'bootstrap-icons/font/bootstrap-icons.min.css';
@import 'flag-icons/css/flag-icons.min.css';
/* === Tailwind v4 + shadcn-vue 基建(重构进行中 — 见 refactor/01) === */
@import 'tailwindcss';
/* 暗色模式:用 .dark class 触发(由 store.isDarkMode 同步到 <html>) */
@custom-variant dark (&:where(.dark, .dark *));
/* 设计 tokens — shadcn-vue 标准 CSS 变量 */
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.5rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 0.1);
--input: oklch(1 0 0 / 0.15);
--ring: oklch(0.556 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
/* === 基建 end === */
.jn-title {
display: flex;
justify-content: space-between;
+8
View File
@@ -21,6 +21,8 @@
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"circle-progress.vue": "^3.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.2.1",
"country-code-lookup": "^0.1.5",
"detect-gpu": "^5.0.70",
@@ -31,23 +33,29 @@
"firebase": "^12.12.0",
"flag-icons": "^7.5.0",
"http-proxy-middleware": "^3.0.5",
"lucide-vue-next": "^1.0.0",
"maxmind": "^5.0.6",
"pinia": "^3.0.4",
"reka-ui": "^2.9.6",
"svgmap": "2.19.3",
"tailwind-merge": "^3.5.0",
"tar": "^7.5.13",
"ua-parser-js": "^2.0.9",
"vue": "^3.5.32",
"vue-i18n": "^11.3.2",
"vue-markdown-render": "^2.3.0",
"vue-router": "^5.0.4",
"vue-sonner": "^2.0.9",
"whoiser": "^1.18.0"
},
"devDependencies": {
"@serwist/vite": "^9.5.7",
"@tailwindcss/vite": "^4.2.2",
"@vitejs/plugin-vue": "^6.0.6",
"code-inspector-plugin": "^1.5.1",
"nodemon": "^3.1.14",
"serwist": "^9.5.7",
"tailwindcss": "^4.2.2",
"vite": "^8.0.8"
}
}
+87
View File
@@ -0,0 +1,87 @@
# 01 — Bootstrap → Tailwind + shadcn-vue
**目标**:彻底移除 Bootstrap,UI 层完全迁移到 Tailwind v4 + shadcn-vue,bootstrap-icons 替换为 lucide-vue-next。最终 `package.json` 不再依赖 `bootstrap` / `bootstrap-icons`。
**状态**:🟢 进行中(阶段 A 已完成)
**影响范围**:30+ Vue 文件、router/index.js 中的 DOM 操作、main.js 中的全局 tooltip 指令、style/style.css。
---
## 阶段 A — 基础设施搭建 ✅
- [x] 安装 Tailwind v4 (`tailwindcss@^4.2.2`、`@tailwindcss/vite@^4.2.2`),在 `vite.config.js` 注入 `tailwindcss()` 插件
- [x] 安装 shadcn-vue 运行时依赖 (`reka-ui`、`class-variance-authority`、`clsx`、`tailwind-merge`、`lucide-vue-next`),建立 `components.json`,约定组件落在 `frontend/components/ui/`,工具函数在 `frontend/lib/utils.js`
- [x] 设计 design tokens(shadcn 标准 CSS 变量集 + neutral baseColor + oklch 色彩空间)写入 `style/style.css` 的 `@theme inline` 块
- [x] 暗色模式:`store.setDarkMode()` 同步切换 `<html>` 的 `.dark` class,`@custom-variant dark` 绑定该 class,Tailwind `dark:` 变体生效
- [x] Tailwind 与 Bootstrap 共存验证:`npm run build` 通过、`npm test` 23 全绿、Vite HMR 正常、CSS bundle 含 `--background` 等 tokens 与 `.dark { }` 规则
- [x] `tailwindcss` / `@tailwindcss/vite` 归入 devDependencies(保持 dev 分支 "chore: move dev tools out of production deps" 的约定)
- [x] 落地一个 shadcn `Button` 组件(`frontend/components/ui/button/`)作为后续替换的样板
## 阶段 B — 命令式 Bootstrap JS 替换(高 ROI 优先)
> 共 13 处 `import ... from 'bootstrap'`,每替一处都直接简化代码。
- [x] **Toast** → shadcn `sonner`(vue-sonner):`components/widgets/Toast.vue`(保持 `store.setAlert()` 外部 API 不变)
- [ ] **Tooltip** → shadcn `Tooltip`:删除 `main.js` 里的 `app.directive('tooltip', ...)`,组件内改用 `<Tooltip>` 包裹
- [ ] **Modal** → shadcn `Dialog`:
- [ ] `components/widgets/Help.vue`
- [ ] `components/widgets/QueryIP.vue`
- [ ] `components/User.vue`
- [ ] `components/Additional.vue`
- [ ] **Offcanvas** → shadcn `Sheet`:
- [ ] `components/Nav.vue`
- [ ] `components/Achievements.vue`
- [ ] `components/Footer.vue`
- [ ] `components/Advanced.vue`
- [ ] `components/widgets/Patch.vue`
- [ ] **删除 `router/index.js` 里对 `document.getElementById('offcanvasTools')` 的命令式操作**,改为 store 状态驱动 Sheet 开合
- [ ] 删除 `main.js` 里的 `import 'bootstrap'`
## 阶段 C — 视觉层批量重写
按以下顺序,每个组件做完后单独 commit:
- [ ] `components/Nav.vue`(含暗色模式样式收敛)
- [ ] `components/IpInfos.vue` + `components/ip-infos/` 子组件(IPCard / ASNInfo / DataPairBar)
- [ ] `components/ConnectivityTest.vue`
- [ ] `components/WebRtcTest.vue`
- [ ] `components/DnsLeaksTest.vue`
- [ ] `components/SpeedTest.vue`
- [ ] `components/Advanced.vue`
- [ ] `components/advanced-tools/` 全部 11 个组件
- [ ] `components/Achievements.vue`
- [ ] `components/Additional.vue`
- [ ] `components/Footer.vue`
- [ ] `components/widgets/Preferences.vue`
- [ ] `components/widgets/InfoMask.vue`
- [ ] `components/widgets/PWA.vue`
- [ ] `App.vue` 模板部分(注意:App.vue 大改要协调 02 任务)
## 阶段 D — 图标迁移
- [ ] 安装 `lucide-vue-next`
- [ ] 全仓库搜索 `bi bi-` 类名,逐一替换为对应的 lucide 组件
- [ ] 删除 `style/style.css` 里的 `bootstrap-icons` import
## 阶段 E — 卸载与清理
- [ ] 删除 `style/style.css` 里的 `@import 'bootstrap/dist/css/bootstrap.min.css'`
- [ ] 检查 `style/style.css` 自定义类是否还需要保留,能用 Tailwind 表达的删除
- [ ] `npm uninstall bootstrap bootstrap-icons`
- [ ] `npm run build` 通过,体积对比记录一下
---
## 完成前回顾清单
将本任务标记为 ✅ 之前必须确认:
- [ ] 全仓库 `grep -r "from 'bootstrap'"` 结果为 0
- [ ] 全仓库 `grep -rE "data-bs-|class=.*\b(btn-|navbar-|offcanvas|modal-|toast|tooltip|dropdown-|card-|row|col-|d-flex|text-(white|dark|muted|success|danger|warning|primary)|bg-(white|dark|body))"` 中 Bootstrap 残留为 0(注意区分自定义类)
- [ ] 全仓库 `grep -r "bi bi-"` 结果为 0
- [ ] `package.json` 不再含 `bootstrap` / `bootstrap-icons`
- [ ] `npm run build` 通过,`npm run dev` 启动后所有 Modal / Sheet / Tooltip / Toast 行为正常
- [ ] 暗色模式在所有页面表现一致
- [ ] 移动端布局未破坏(`store.isMobile` 路径手动验证一次)
- [ ] router 切换 advanced-tools 时 Sheet 正常开合
+52
View File
@@ -0,0 +1,52 @@
# 02 — App.vue 上帝组件拆分
**目标**:将 `frontend/App.vue`(590 行,14 个 ref,混合了模板、加载编排、刷新调度、信息遮罩、快捷键注册、滚动控制)拆分为职责清晰的多个 composables 和子组件。最终 `App.vue` 只剩模板 + 少量编排代码(目标 < 150 行)。
**状态**:🟡 未开始
**前置依赖**:阶段 A 不强制,但**强烈建议在 01 阶段 B 完成后**再做,以避免拆分后又因 UI 框架替换而再次重构。
---
## 子任务
### 拆分 composables(新建 `frontend/composables/` 目录)
- [ ] `useShortcuts.js` — 抽出 `ShortcutKeys()` 函数(约 270 行)和 `registerShortcutKeys` / `loadShortcuts`
- 入参:各组件 ref 集合 + configs + i18n t 函数
- 出参:`registerAll()` / `keyMap`
- [ ] `useInfoMask.js` — 抽出 `infoMask` / `infoUnmask` / `toggleInfoMask` + `infoMaskLevel` 状态
- 入参:各组件 ref(IPCheck / webRTC / dnsLeaks)
- 出参:`infoMaskLevel`、`toggleInfoMask`、`showMaskButton`
- [ ] `useRefreshOrchestrator.js` — 抽出 `refreshEverything` / `scheduleTimedTasks` / `loadingControl`
- 与 store 的 `mountingStatus` / `loadingStatus` / `shouldRefreshEveryThing` 协作
- [ ] `useScrollTo.js` — 抽出 `scrollToElement`(小工具)
### Store 改造
- [ ] 将刷新编排(refreshTasks)从 App.vue 移入 store action `refreshEverything()`
- [ ] App.vue 只 watch `shouldRefreshEveryThing` 并触发 store action
### 模板与 ref 整理
- [ ] App.vue 顶层 14 个 ref 收敛:能用 store 状态替代的尽量替代(特别是 alert 相关的 4 个 ref,已经在 store 里有 `setAlert`)
- [ ] 删除 App.vue 中已废弃的 `app.config.globalProperties.$trackEvent`(如果确认 components 都已直接从 utils 导入 `trackEvent`)
- [ ] App.vue 顶部 `loadingControl` 的递归 setTimeout 改为基于 store getter 的 watch
### 验收
- [ ] App.vue 总行数 < 150
- [ ] 所有快捷键功能验证通过
- [ ] 信息遮罩 3 级切换正常
- [ ] 自动刷新 / 手动刷新行为不变
- [ ] composables 文件每个都有头部注释说明输入输出
---
## 完成前回顾清单
- [ ] 全仓库 `grep` 确认 App.vue 中拆出的所有函数没有被其它组件直接引用过
- [ ] `frontend/composables/` 下文件均为单一职责,没有"杂物 composable"
- [ ] App.vue 不再持有任何与 alert / refresh / mask / shortcut 相关的本地状态
- [ ] 手动跑一遍:自动刷新模式 / 手动刷新模式 / 切换到任一 advanced-tool / 触发遮罩 1 / 2 / 解除 / 触发每个快捷键
- [ ] 03 任务里有没有可以为这些新 composables 添加单测的位置(应该有,去补 03 的清单)
+82
View File
@@ -0,0 +1,82 @@
# 03 — 增加单元测试
**目标**:扩展现有 Node 内置 test runner 测试覆盖,给后续重构(特别是 01、02、04)提供安全网。
**状态**:🟢 进行中(基础设施 + 部分 utils + 部分 API smoke 已完成)
**测试栈**:**Node 内置 `node --test`**(不引入 Vitest / Jest,避免增加依赖)。
**位置**:根目录 `tests/`,文件命名 `xxx.test.js`,用 `node:test` + `node:assert/strict`。
---
## 已经完成(dev 分支已合入)
- [x] `package.json` 加 scripts:`"test": "node --test tests/*.test.js"`、`"check": "npm run test && npm run build"`
- [x] 根目录建立 `tests/` 目录
- [x] `common/valid-ip.js` + `frontend/utils/valid-ip.js` 测试(`tests/valid-ip.test.js`)
- [x] API smoke 测试(`tests/api-smoke.test.js`):覆盖 referer 检查 + configs / map / maxmind / dns 早期校验路径,无网络调用
> 现状参考:跑 `npm test` 应全绿。新增测试请遵循同样的 ESM + `node:test` 风格,避免引入新依赖。
---
## 待办子任务
### 阶段 A — utils 纯函数补齐
- [ ] `frontend/utils/transform-ip-data.js` — 各类 raw 输入到统一卡片数据的映射 / 边界
- [ ] `frontend/utils/masked-info.js` — 遮罩输出格式(mock i18n t 函数)
- [ ] `frontend/utils/country-name.js` — 国家代码 → 名称映射 + 兜底
- [ ] `frontend/utils/timestamp-to-date.js` — 时区与格式
- [ ] `frontend/utils/system-detect.js` — UA 模拟测试 OS / Mobile 判定
- [ ] `frontend/utils/speedtest-colos.js` — 数据查询函数
- [ ] `frontend/utils/shortcut.js` — `mappingKeys` / `navigateCards` 行为(注意:依赖 DOM,可能需要轻量 mock 或拆纯函数后再测)
### 阶段 B — API smoke 扩展
> 已覆盖 4 个 handler(configs / map / maxmind / dns),剩余 handler 用同样风格补齐:
- [ ] `api/get-user-info.js`
- [ ] `api/get-whois.js`
- [ ] `api/cf-radar.js`
- [ ] `api/invisibility-test.js`
- [ ] `api/ip-sb.js` / `api/ipapi-com.js` / `api/ipapi-is.js` / `api/ipcheck-ing.js` / `api/ipinfo-io.js` / `api/ipapi-com.js` / `api/ip2location-io.js`(外部 IP 源,重点测 referer 校验和参数早期 reject 路径,**不要发真实请求**)
- [ ] `api/mac-checker.js`
- [ ] `api/update-user-achievement.js`
- [ ] `api/dns-resolver.js` 的更多分支(已有基础覆盖,可补错误路径)
### 阶段 C — composables 测试(依赖 02 任务)
> 02 拆出 composables 后回来补充:
- [ ] `useShortcuts.js`
- [ ] `useInfoMask.js`
- [ ] `useRefreshOrchestrator.js`
### 阶段 D — store 测试
- [ ] `store.js` 关键 action:`setAlert` / `setLoadingStatus` / `setRefreshEveryThing` / `setCurrentPath`
- [ ] 04 任务完成后,针对成就数据加载 / 解锁逻辑添加测试(`createInitialAchievementsState()` 等)
### 阶段 E — 共享与后端
- [ ] `common/referer-check.js` — 已被 api-smoke 间接覆盖,可补独立测试覆盖更多组合
- [ ] `common/maxmind-service.js` — 接口契约测试(mock fs / db)
---
## Vue 组件测试(不在本阶段范围)
Node test runner 测 Vue SFC 需要额外编译/jsdom 配置,会破坏"零依赖"原则。**本阶段不做组件测试**,需要时单独评估方案。
---
## 完成前回顾清单
- [ ] `npm run check` 通过
- [ ] 所有新测试文件遵循 `tests/*.test.js` 命名
- [ ] 测试不依赖网络(外部 API 测试只覆盖 referer / 参数早 reject 路径)
- [ ] 测试不引入新 npm 依赖
- [ ] 02 任务完成后是否回头补了 composables 测试
- [ ] 04 任务完成后是否回头补了成就解锁逻辑测试
- [ ] 是否有"看上去是纯函数但其实有副作用"的 utils 被遗漏(`use-analytics.js` / `register-service-worker.js` 这类带副作用的需要明确决定测不测,并在本文档登记决定)
+49
View File
@@ -0,0 +1,49 @@
# 04 — store.js 硬编码数据治理
**目标**:将 `frontend/store.js` 中硬编码的数据(主要是 21 条 `userAchievements`、可能还有其它常量)抽离到独立数据文件,store 只持有可变状态和 actions,便于维护、便于测试、便于未来本地化与扩展。
**状态**:🟡 未开始
---
## 子任务
### 阶段 A — 现状盘点
- [ ] 通读 `frontend/store.js`,列出所有"硬编码常量数据"候选清单(不仅仅是 achievements,可能还有默认偏好、curl 域名映射等)
- [ ] 在本文件下方"盘点结果"小节登记发现项
### 阶段 B — achievements 抽离
- [ ] 新建 `frontend/data/achievements.js`,导出形如 `export const ACHIEVEMENTS_DEFINITIONS = [...]` 的数组(定义:`name` / `img`),不含状态字段(`achieved` / `achievedTime` / `showDetails`)
- [ ] 新建工厂函数 `createInitialAchievementsState()`,根据定义生成初始状态对象
- [ ] store 的 `state.userAchievements` 改为调用工厂函数初始化
- [ ] 校验所有读取 `userAchievements` 的地方(`Achievements.vue` / `App.vue` / 触发解锁的 utils)行为不变
- [ ] 成就元数据(图片路径前缀不一致问题:`'achievements/iamhuman.webp'` vs `/achievements/...`)顺手统一
### 阶段 C — 其它硬编码
- [ ] 处理盘点中发现的其它项(具体子任务在阶段 A 完成后补齐到这里)
### 阶段 D — 测试
- [ ] 在 03 的覆盖范围里补:`createInitialAchievementsState()` 单测、关键解锁逻辑回归测试
---
## 盘点结果
> 阶段 A 完成后填写。每项注明:所在行号、内容性质、建议归宿(data/ 目录、constants 文件、保留在 store)。
- (待填写)
---
## 完成前回顾清单
- [ ] `frontend/store.js` 中已无大段静态对象字面量
- [ ] `frontend/data/` 下文件均为纯数据 + 纯函数,无副作用、无 store 依赖
- [ ] 全仓库 grep 确认所有 `userAchievements` 的读写仍走 store,没有组件直接 import 数据文件硬编码
- [ ] 图片路径前缀问题已统一并验证渲染
- [ ] 任何新增导出都在文件顶部注释说明用途
- [ ] 已与 03 任务联动,补充必要的单测
+51
View File
@@ -0,0 +1,51 @@
# 重构计划索引
> 本目录管理 MyIP 当前阶段的重构任务。每个文件对应一个独立子项目。
启动日期:2026-04-15
---
## 子任务总览
| # | 文件 | 主题 | 状态 |
|---|---|---|---|
| 01 | [01-bootstrap-to-tailwind.md](./01-bootstrap-to-tailwind.md) | Bootstrap → Tailwind + shadcn-vue(彻底替换) | 🟢 进行中(阶段 A 完成) |
| 02 | [02-app-vue-split.md](./02-app-vue-split.md) | App.vue 上帝组件拆分 | 🟡 未开始 |
| 03 | [03-add-tests.md](./03-add-tests.md) | 增加单元测试(Node `node --test`) | 🟢 进行中(基建+部分用例已完成于 dev) |
| 04 | [04-store-hardcoded.md](./04-store-hardcoded.md) | store.js 硬编码数据治理 | 🟡 未开始 |
状态图例:🟡 未开始 / 🟢 进行中 / ✅ 已完成 / ⚪ 已取消
---
## 推荐顺序
虽然顺序不强制,但建议这样推进,理由是后一项依赖前一项的成果:
1. **先 01(Bootstrap → Tailwind)的基础设施部分**(装 Tailwind / shadcn-vue / Sheet 替换 Offcanvas),因为 02(App.vue 拆分)会动到大量模板和命令式 Bootstrap 调用,先有新 UI 基建可以避免做两次。
2. **同时启动 03(测试基建)**——给 utils/ 加 Vitest,这是后续重构的安全网。
3. **04(store 硬编码)** 可以独立穿插进行,影响面小。
4. **02(App.vue 拆分)** 放到 01 推进过半再做,拆出的 composables 会更干净。
---
## 完成纪律(重要)
**每次将子任务从 `[ ]` 改成 `[x]` 之前,必须执行以下检查:**
1. **全局搜索回扫**:用 grep / Glob 搜索该子任务相关的关键词(如某个被替换的 Bootstrap class、被移除的 import、被抽离的函数名),确认全仓库没有遗漏的引用。
2. **同类场景扫描**:思考"这个改动是否还有类似场景没处理"。比如替换了一个 Modal,确认其它 Modal 是否同样需要替换;抽出一个 composable,确认是否还有重复逻辑可以合并。
3. **运行时验证**:能跑就跑(`npm run dev` / `npm run build` / `npm test`),看构建/测试是否仍通过。
4. **更新本 README 的总览表**:状态从 🟡 / 🟢 改为 ✅。
5. **如果发现遗漏**:不要勾选,回到计划文件补充新子项后再继续。
每个计划文件末尾都有一个"完成前回顾清单"小节,列出该任务特定的检查点。
---
## 范围约束
当前阶段**只做**这 4 件事。其它建议(TypeScript 化、Biome 接入、依赖瘦身、circle-progress 替换、firebase 动态加载、vue-router 版本核对等)暂不进入计划,避免范围蔓延。
完成 4 项后再评估下一阶段做什么。
+2
View File
@@ -1,6 +1,7 @@
import dotenv, { parse } from 'dotenv';
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { serwist } from '@serwist/vite'
import { CodeInspectorPlugin } from 'code-inspector-plugin';
@@ -74,6 +75,7 @@ export default defineConfig({
}
}
}),
tailwindcss(),
serwist({
swSrc: 'frontend/sw.js',
swDest: 'sw.js',