mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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:
1 parent
fd7284cbae
commit
0a5b0c1914
18 files changed
+657
-65
No files matched your search
@@ -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` 的语言模式
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Button } from './Button.vue';
|
||||
export { buttonVariants } from './variants.js';
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Sonner } from './Sonner.vue';
|
||||
export { toast } from 'vue-sonner';
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 正常开合
|
||||
@@ -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 的清单)
|
||||
@@ -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` 这类带副作用的需要明确决定测不测,并在本文档登记决定)
|
||||
@@ -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 任务联动,补充必要的单测
|
||||
@@ -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 项后再评估下一阶段做什么。
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user