@flecblog/core-nuxt 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app.vue +172 -0
- package/assets/css/base.scss +96 -0
- package/composables/api/article.ts +25 -0
- package/composables/api/category.ts +19 -0
- package/composables/api/comment.ts +25 -0
- package/composables/api/createApi.ts +242 -0
- package/composables/api/feedback.ts +14 -0
- package/composables/api/friend.ts +14 -0
- package/composables/api/moment.ts +10 -0
- package/composables/api/music.ts +39 -0
- package/composables/api/notification.ts +19 -0
- package/composables/api/stats.ts +14 -0
- package/composables/api/subscribe.ts +13 -0
- package/composables/api/sysconfig.ts +9 -0
- package/composables/api/tag.ts +19 -0
- package/composables/api/theme.ts +9 -0
- package/composables/api/upload.ts +13 -0
- package/composables/api/user.ts +77 -0
- package/composables/useArticle.ts +227 -0
- package/composables/useAuth.ts +180 -0
- package/composables/useComment.ts +143 -0
- package/composables/useDarkMode.ts +29 -0
- package/composables/useEmoji.ts +39 -0
- package/composables/useFeedback.ts +38 -0
- package/composables/useFriendList.ts +100 -0
- package/composables/useMermaid.ts +30 -0
- package/composables/useMoment.ts +86 -0
- package/composables/useMusic.ts +37 -0
- package/composables/useSearchState.ts +90 -0
- package/composables/useStores.ts +557 -0
- package/composables/useSubscribe.ts +13 -0
- package/composables/useTheme.ts +61 -0
- package/composables/useUser.ts +202 -0
- package/layouts/default.vue +1 -0
- package/nuxt.config.ts +170 -0
- package/package.json +58 -0
- package/pages/index.vue +1 -0
- package/plugins/console-banner.client.ts +11 -0
- package/plugins/custom-code.ts +121 -0
- package/plugins/syncThemeMeta.ts +28 -0
- package/plugins/tracker.client.ts +107 -0
- package/server/plugins/sitemap.ts +170 -0
- package/server/routes/atom.xml.ts +21 -0
- package/server/routes/manifest.json.ts +45 -0
- package/server/routes/rss.xml.ts +21 -0
- package/types/article.ts +48 -0
- package/types/auth.ts +8 -0
- package/types/category.ts +12 -0
- package/types/comment.ts +72 -0
- package/types/emoji.ts +10 -0
- package/types/feedback.ts +31 -0
- package/types/friend.ts +63 -0
- package/types/markdown.ts +7 -0
- package/types/moment.ts +85 -0
- package/types/notification.ts +56 -0
- package/types/request.ts +30 -0
- package/types/stats.ts +38 -0
- package/types/sysconfig.ts +2 -0
- package/types/tag.ts +11 -0
- package/types/theme.ts +26 -0
- package/types/upload.ts +5 -0
- package/types/user.ts +106 -0
- package/utils/avatar.ts +21 -0
- package/utils/date.ts +136 -0
- package/utils/download.ts +11 -0
- package/utils/emoji.ts +90 -0
- package/utils/format.ts +42 -0
- package/utils/markdown.ts +1458 -0
- package/utils/scroll.ts +31 -0
- package/utils/upload.ts +57 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
UserInfo,
|
|
3
|
+
LoginParams,
|
|
4
|
+
RegisterParams,
|
|
5
|
+
ForgotPasswordParams,
|
|
6
|
+
ResetPasswordParams,
|
|
7
|
+
ChangePasswordParams,
|
|
8
|
+
DeactivateAccountParams,
|
|
9
|
+
UpdateProfileParams,
|
|
10
|
+
LoginResponse,
|
|
11
|
+
RegisterResponse,
|
|
12
|
+
} from '@@/types/user';
|
|
13
|
+
import {
|
|
14
|
+
getUserProfile,
|
|
15
|
+
updateUserProfile as updateProfileApi,
|
|
16
|
+
login as loginApi,
|
|
17
|
+
register as registerApi,
|
|
18
|
+
forgotPassword as forgotPasswordApi,
|
|
19
|
+
resetPassword as resetPasswordApi,
|
|
20
|
+
changePassword as changePasswordApi,
|
|
21
|
+
setPassword as setPasswordApi,
|
|
22
|
+
deactivateAccount as deactivateAccountApi,
|
|
23
|
+
unbindOAuth as unbindOAuthApi,
|
|
24
|
+
pollWechatLoginStatus as pollWechatApi,
|
|
25
|
+
} from '@/composables/api/user';
|
|
26
|
+
|
|
27
|
+
const userInfo = ref<UserInfo | null>(null);
|
|
28
|
+
|
|
29
|
+
const showLoginModal = ref(false);
|
|
30
|
+
|
|
31
|
+
export function useLoginModal() {
|
|
32
|
+
const open = () => {
|
|
33
|
+
showLoginModal.value = true;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const close = () => {
|
|
37
|
+
showLoginModal.value = false;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
showLoginModal,
|
|
42
|
+
open,
|
|
43
|
+
close,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 邮箱绑定提示相关状态
|
|
48
|
+
const GLOBAL_REMIND_INTERVAL = 12 * 60 * 60 * 1000;
|
|
49
|
+
const COMMENT_REMIND_INTERVAL = 10 * 60 * 1000;
|
|
50
|
+
const SKIP_TIME_KEY = 'bindEmailSkipTime';
|
|
51
|
+
const showBindEmailModal = ref(false);
|
|
52
|
+
type TriggerType = 'global' | 'comment';
|
|
53
|
+
|
|
54
|
+
export function useBindEmail() {
|
|
55
|
+
const shouldShowPrompt = async (
|
|
56
|
+
trigger: TriggerType,
|
|
57
|
+
userInfo?: UserInfo | null
|
|
58
|
+
): Promise<boolean> => {
|
|
59
|
+
if (!isLoggedIn.value) return false;
|
|
60
|
+
let user = userInfo;
|
|
61
|
+
if (!user) {
|
|
62
|
+
try {
|
|
63
|
+
user = await getUserProfile();
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!user?.is_virtual_email) return false;
|
|
69
|
+
const skipTime = localStorage.getItem(SKIP_TIME_KEY);
|
|
70
|
+
if (skipTime) {
|
|
71
|
+
const elapsed = Date.now() - parseInt(skipTime, 10);
|
|
72
|
+
const interval = trigger === 'comment' ? COMMENT_REMIND_INTERVAL : GLOBAL_REMIND_INTERVAL;
|
|
73
|
+
if (elapsed < interval) return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const triggerGlobal = async (userInfo?: UserInfo | null) => {
|
|
79
|
+
if (await shouldShowPrompt('global', userInfo)) {
|
|
80
|
+
showBindEmailModal.value = true;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const triggerOnComment = async () => {
|
|
85
|
+
if (await shouldShowPrompt('comment')) {
|
|
86
|
+
showBindEmailModal.value = true;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const onBindSuccess = () => {
|
|
91
|
+
localStorage.removeItem(SKIP_TIME_KEY);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const onSkip = () => {
|
|
95
|
+
localStorage.setItem(SKIP_TIME_KEY, String(Date.now()));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return { showBindEmailModal, triggerGlobal, triggerOnComment, onBindSuccess, onSkip };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function useUser() {
|
|
102
|
+
const isLoggedIn = useAuth();
|
|
103
|
+
const loading = ref(false);
|
|
104
|
+
const error = ref<Error | null>(null);
|
|
105
|
+
|
|
106
|
+
const fetchUserInfo = async () => {
|
|
107
|
+
if (!isLoggedIn.value) {
|
|
108
|
+
userInfo.value = null;
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
loading.value = true;
|
|
112
|
+
error.value = null;
|
|
113
|
+
try {
|
|
114
|
+
const data = await getUserProfile();
|
|
115
|
+
userInfo.value = data;
|
|
116
|
+
return data;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.error('获取用户信息失败:', e);
|
|
119
|
+
error.value = e instanceof Error ? e : new Error(String(e));
|
|
120
|
+
userInfo.value = null;
|
|
121
|
+
return null;
|
|
122
|
+
} finally {
|
|
123
|
+
loading.value = false;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const clearUserInfo = () => {
|
|
128
|
+
userInfo.value = null;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
async function login(params: LoginParams): Promise<LoginResponse> {
|
|
132
|
+
return loginApi(params);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function register(params: RegisterParams): Promise<RegisterResponse> {
|
|
136
|
+
return registerApi(params);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function forgotPassword(params: ForgotPasswordParams): Promise<void> {
|
|
140
|
+
return forgotPasswordApi(params);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function resetPassword(params: ResetPasswordParams): Promise<void> {
|
|
144
|
+
return resetPasswordApi(params);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function changePassword(params: ChangePasswordParams): Promise<void> {
|
|
148
|
+
return changePasswordApi(params);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function setPassword(params: {
|
|
152
|
+
password: string;
|
|
153
|
+
confirm_password: string;
|
|
154
|
+
}): Promise<void> {
|
|
155
|
+
return setPasswordApi(params);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function updateUserProfile(params: UpdateProfileParams): Promise<UserInfo> {
|
|
159
|
+
const data = await updateProfileApi(params);
|
|
160
|
+
userInfo.value = data;
|
|
161
|
+
return data;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function deactivateAccount(params: DeactivateAccountParams): Promise<void> {
|
|
165
|
+
return deactivateAccountApi(params);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function unbindOAuth(provider: string): Promise<void> {
|
|
169
|
+
return unbindOAuthApi(provider);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function setToken(token: string): void {
|
|
173
|
+
setAccessToken(token);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function pollWechatLoginStatus(scene: string) {
|
|
177
|
+
return pollWechatApi(scene);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
userInfo,
|
|
182
|
+
userAvatar: computed(() => getAvatarUrl(userInfo.value || {})),
|
|
183
|
+
userNickname: computed(() => userInfo.value?.nickname || '用户'),
|
|
184
|
+
userEmail: computed(() => userInfo.value?.email || ''),
|
|
185
|
+
loading,
|
|
186
|
+
error,
|
|
187
|
+
fetchUserInfo,
|
|
188
|
+
clearUserInfo,
|
|
189
|
+
login,
|
|
190
|
+
register,
|
|
191
|
+
forgotPassword,
|
|
192
|
+
resetPassword,
|
|
193
|
+
changePassword,
|
|
194
|
+
setPassword,
|
|
195
|
+
updateUserProfile,
|
|
196
|
+
deactivateAccount,
|
|
197
|
+
unbindOAuth,
|
|
198
|
+
setToken,
|
|
199
|
+
pollWechatLoginStatus,
|
|
200
|
+
getUserProfile: fetchUserInfo,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<template><div><slot /></div></template>
|
package/nuxt.config.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const layerRoot = resolve(__dirname);
|
|
6
|
+
|
|
7
|
+
export default defineNuxtConfig({
|
|
8
|
+
compatibilityDate: '2025-07-15',
|
|
9
|
+
ssr: true,
|
|
10
|
+
features: { inlineStyles: true },
|
|
11
|
+
|
|
12
|
+
alias: {
|
|
13
|
+
'@@': layerRoot,
|
|
14
|
+
'@': layerRoot,
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
typescript: {
|
|
18
|
+
tsConfig: {
|
|
19
|
+
compilerOptions: {
|
|
20
|
+
paths: {
|
|
21
|
+
'@@': [layerRoot],
|
|
22
|
+
'@@/*': [resolve(layerRoot, './*')],
|
|
23
|
+
'@': [layerRoot],
|
|
24
|
+
'@/*': [resolve(layerRoot, './*')],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
app: {
|
|
31
|
+
head: {
|
|
32
|
+
htmlAttrs: { lang: 'zh-CN' },
|
|
33
|
+
script: [
|
|
34
|
+
{
|
|
35
|
+
innerHTML: `
|
|
36
|
+
(function() {
|
|
37
|
+
var theme = localStorage.getItem('theme');
|
|
38
|
+
var isDark = theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
39
|
+
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
|
40
|
+
})();
|
|
41
|
+
`,
|
|
42
|
+
type: 'text/javascript',
|
|
43
|
+
tagPosition: 'head',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
modules: [
|
|
50
|
+
'@vueuse/nuxt',
|
|
51
|
+
'@nuxt/image',
|
|
52
|
+
'@nuxtjs/seo',
|
|
53
|
+
'@vite-pwa/nuxt',
|
|
54
|
+
[
|
|
55
|
+
'@nuxtjs/critters',
|
|
56
|
+
{
|
|
57
|
+
config: {
|
|
58
|
+
preload: 'swap',
|
|
59
|
+
inlineFonts: false,
|
|
60
|
+
pruneSource: false,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
],
|
|
65
|
+
|
|
66
|
+
css: ['@/assets/css/base.scss', 'remixicon/fonts/remixicon.css'],
|
|
67
|
+
|
|
68
|
+
site: {
|
|
69
|
+
url: '',
|
|
70
|
+
defaultLocale: 'zh-CN',
|
|
71
|
+
},
|
|
72
|
+
sitemap: {
|
|
73
|
+
strictNuxtContentPaths: true,
|
|
74
|
+
},
|
|
75
|
+
robots: {
|
|
76
|
+
allow: '/',
|
|
77
|
+
},
|
|
78
|
+
ogImage: {
|
|
79
|
+
enabled: false,
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
runtimeConfig: {
|
|
83
|
+
public: {
|
|
84
|
+
apiUrl: '',
|
|
85
|
+
activeTheme: '',
|
|
86
|
+
appVersion: process.env.FLECBLOG_VERSION || '1.0.0',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
pwa: {
|
|
91
|
+
registerType: 'autoUpdate',
|
|
92
|
+
manifest: false,
|
|
93
|
+
workbox: {
|
|
94
|
+
navigateFallback: '/',
|
|
95
|
+
globPatterns: ['**/*.{js,css,html,png,ico,webp,woff,woff2}'],
|
|
96
|
+
globIgnores: ['**/remixicon*.svg'],
|
|
97
|
+
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
|
|
98
|
+
runtimeCaching: [
|
|
99
|
+
{
|
|
100
|
+
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/i,
|
|
101
|
+
handler: 'CacheFirst',
|
|
102
|
+
options: {
|
|
103
|
+
cacheName: 'images',
|
|
104
|
+
expiration: {
|
|
105
|
+
maxEntries: 100,
|
|
106
|
+
maxAgeSeconds: 60 * 60 * 24 * 30,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
client: {
|
|
113
|
+
installPrompt: true,
|
|
114
|
+
periodicSyncForUpdates: 3600,
|
|
115
|
+
},
|
|
116
|
+
devOptions: {
|
|
117
|
+
enabled: false,
|
|
118
|
+
type: 'module',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
vite: {
|
|
123
|
+
build: {
|
|
124
|
+
rollupOptions: {
|
|
125
|
+
output: {
|
|
126
|
+
manualChunks(id) {
|
|
127
|
+
if (id.includes('node_modules/vue/') || id.includes('node_modules/@vue/')) {
|
|
128
|
+
return 'vue-core';
|
|
129
|
+
}
|
|
130
|
+
if (id.includes('node_modules/vue-router')) {
|
|
131
|
+
return 'vue-router';
|
|
132
|
+
}
|
|
133
|
+
if (id.includes('node_modules/dayjs')) {
|
|
134
|
+
return 'dayjs';
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
id.includes('node_modules/markdown-it') ||
|
|
138
|
+
id.includes('node_modules/dompurify') ||
|
|
139
|
+
id.includes('node_modules/isomorphic-dompurify')
|
|
140
|
+
) {
|
|
141
|
+
return 'markdown-renderer';
|
|
142
|
+
}
|
|
143
|
+
if (id.includes('node_modules/katex')) {
|
|
144
|
+
return 'katex';
|
|
145
|
+
}
|
|
146
|
+
if (id.includes('node_modules/highlight.js')) {
|
|
147
|
+
return 'highlight';
|
|
148
|
+
}
|
|
149
|
+
if (id.includes('node_modules/@vueuse')) {
|
|
150
|
+
return 'vueuse';
|
|
151
|
+
}
|
|
152
|
+
if (id.includes('node_modules/aplayer')) {
|
|
153
|
+
return 'aplayer';
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
chunkSizeWarningLimit: 600,
|
|
159
|
+
cssCodeSplit: true,
|
|
160
|
+
sourcemap: false,
|
|
161
|
+
cssMinify: true,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
router: {
|
|
166
|
+
options: {
|
|
167
|
+
scrollBehaviorType: 'smooth',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flecblog/core-nuxt",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "FlecBlog Nuxt Layer(共享核心)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./nuxt.config.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"nuxt.config.ts",
|
|
9
|
+
"app.vue",
|
|
10
|
+
"composables",
|
|
11
|
+
"plugins",
|
|
12
|
+
"components",
|
|
13
|
+
"layouts",
|
|
14
|
+
"pages",
|
|
15
|
+
"server",
|
|
16
|
+
"assets",
|
|
17
|
+
"utils",
|
|
18
|
+
"types"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@nuxt/image": "^2.0.0",
|
|
22
|
+
"@nuxtjs/critters": "^0.9.0",
|
|
23
|
+
"@nuxtjs/seo": "^5.1.3",
|
|
24
|
+
"@traptitech/markdown-it-katex": "^3.6.0",
|
|
25
|
+
"@vite-pwa/nuxt": "^1.1.1",
|
|
26
|
+
"@vueuse/core": "^14.2.1",
|
|
27
|
+
"@vueuse/nuxt": "^14.2.1",
|
|
28
|
+
"dayjs": "^1.11.19",
|
|
29
|
+
"dompurify": "^3.3.1",
|
|
30
|
+
"highlight.js": "^11.11.1",
|
|
31
|
+
"isomorphic-dompurify": "^3.0.0",
|
|
32
|
+
"katex": "^0.16.45",
|
|
33
|
+
"markdown-it": "^14.1.1",
|
|
34
|
+
"markdown-it-anchor": "^9.2.0",
|
|
35
|
+
"markdown-it-kbd": "^3.0.2",
|
|
36
|
+
"markdown-it-link-attributes": "^4.0.1",
|
|
37
|
+
"markdown-it-mark": "^4.0.0",
|
|
38
|
+
"markdown-it-plugin-underline": "^0.0.1",
|
|
39
|
+
"markdown-it-sub": "^2.0.0",
|
|
40
|
+
"markdown-it-sup": "^2.0.0",
|
|
41
|
+
"markdown-it-task-lists": "^2.1.1",
|
|
42
|
+
"mermaid": "^11.12.3",
|
|
43
|
+
"remixicon": "^4.9.1"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^25.3.2",
|
|
47
|
+
"@types/markdown-it": "^14.1.2",
|
|
48
|
+
"sass": "^1.97.3",
|
|
49
|
+
"typescript": "^5.9.3"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"nuxt": "^4.3.0",
|
|
53
|
+
"vue": "^3.5.0"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/pages/index.vue
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<template><div>FlecBlog:请在主题中覆盖 pages/index.vue</div></template>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export default defineNuxtPlugin(() => {
|
|
2
|
+
const config = useRuntimeConfig();
|
|
3
|
+
const version = config.public.appVersion;
|
|
4
|
+
|
|
5
|
+
console.log(
|
|
6
|
+
`%c FlecBlog %c v${version} %c github.com/talen8/FlecBlog `,
|
|
7
|
+
'background: #49b1f5; color: #fff; padding: 4px 6px; border-radius: 4px 0 0 4px; font-weight: bold;',
|
|
8
|
+
'background: #3a8fd4; color: #fff; padding: 4px 6px;',
|
|
9
|
+
'background: #2d7ab8; color: #fff; padding: 4px 6px; border-radius: 0 4px 4px 0;'
|
|
10
|
+
);
|
|
11
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
interface TagData {
|
|
2
|
+
tag: string;
|
|
3
|
+
innerHTML?: string;
|
|
4
|
+
[key: string]: string | undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface HeadPayload {
|
|
8
|
+
meta: Record<string, string>[];
|
|
9
|
+
link: Record<string, string>[];
|
|
10
|
+
script: (Record<string, string> & { innerHTML?: string })[];
|
|
11
|
+
style: (Record<string, string> & { innerHTML?: string })[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default defineNuxtPlugin({
|
|
15
|
+
name: 'custom-code',
|
|
16
|
+
setup() {
|
|
17
|
+
const { basicConfig } = useSysConfig();
|
|
18
|
+
|
|
19
|
+
const parseHtmlTags = (html: string): TagData[] => {
|
|
20
|
+
if (!html) return [];
|
|
21
|
+
|
|
22
|
+
const tags: TagData[] = [];
|
|
23
|
+
const tagRegex = /<(\w+)([^>]*)>([\s\S]*?)<\/\1>|<(\w+)([^>]*)\s*\/>/gi;
|
|
24
|
+
let match;
|
|
25
|
+
|
|
26
|
+
while ((match = tagRegex.exec(html)) !== null) {
|
|
27
|
+
const tagName = match[1] || match[4];
|
|
28
|
+
if (!tagName) continue;
|
|
29
|
+
|
|
30
|
+
const attrsStr = match[2] || match[5];
|
|
31
|
+
const innerHTML = match[3];
|
|
32
|
+
|
|
33
|
+
const tagData: TagData = { tag: tagName.toLowerCase() };
|
|
34
|
+
|
|
35
|
+
if (attrsStr) {
|
|
36
|
+
const attrRegex = /(\S+)=["']([^"']*)["']/g;
|
|
37
|
+
let attrMatch: RegExpExecArray | null;
|
|
38
|
+
while ((attrMatch = attrRegex.exec(attrsStr)) !== null) {
|
|
39
|
+
tagData[attrMatch[1]!] = attrMatch[2];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (innerHTML) {
|
|
44
|
+
tagData.innerHTML = innerHTML;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
tags.push(tagData);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return tags;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const buildHeadPayload = (headCode: string): HeadPayload | null => {
|
|
54
|
+
if (!headCode) return null;
|
|
55
|
+
|
|
56
|
+
const tags = parseHtmlTags(headCode);
|
|
57
|
+
const headPayload: HeadPayload = {
|
|
58
|
+
meta: [],
|
|
59
|
+
link: [],
|
|
60
|
+
script: [],
|
|
61
|
+
style: [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
tags.forEach(tag => {
|
|
65
|
+
const { tag: tagName, innerHTML, ...attrs } = tag;
|
|
66
|
+
|
|
67
|
+
const filteredAttrs = Object.fromEntries(
|
|
68
|
+
Object.entries(attrs).filter(([, v]) => v !== undefined)
|
|
69
|
+
) as Record<string, string>;
|
|
70
|
+
|
|
71
|
+
switch (tagName) {
|
|
72
|
+
case 'meta':
|
|
73
|
+
headPayload.meta.push(filteredAttrs);
|
|
74
|
+
break;
|
|
75
|
+
case 'link':
|
|
76
|
+
headPayload.link.push(filteredAttrs);
|
|
77
|
+
break;
|
|
78
|
+
case 'script':
|
|
79
|
+
headPayload.script.push(innerHTML ? { ...filteredAttrs, innerHTML } : filteredAttrs);
|
|
80
|
+
break;
|
|
81
|
+
case 'style':
|
|
82
|
+
headPayload.style.push(innerHTML ? { ...filteredAttrs, innerHTML } : filteredAttrs);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return headPayload;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
useHead(
|
|
91
|
+
computed(() => {
|
|
92
|
+
const customHead = buildHeadPayload(String(basicConfig.value.custom_head || ''));
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
meta: customHead?.meta || [],
|
|
96
|
+
link: customHead?.link || [],
|
|
97
|
+
script: customHead?.script || [],
|
|
98
|
+
style: customHead?.style || [],
|
|
99
|
+
};
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const injectBodyCode = () => {
|
|
104
|
+
const bodyCode = String(basicConfig.value.custom_body || '');
|
|
105
|
+
|
|
106
|
+
if (bodyCode && import.meta.client) {
|
|
107
|
+
const oldContainer = document.getElementById('custom-body-inject');
|
|
108
|
+
if (oldContainer) {
|
|
109
|
+
oldContainer.remove();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const container = document.createElement('div');
|
|
113
|
+
container.id = 'custom-body-inject';
|
|
114
|
+
container.innerHTML = bodyCode;
|
|
115
|
+
document.body.prepend(container);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
watch(basicConfig, injectBodyCode, { immediate: true });
|
|
120
|
+
},
|
|
121
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import themeConfig from '~/theme.config.json';
|
|
2
|
+
import configSchema from '~/schemas/config.json';
|
|
3
|
+
import migrations from '~/schemas/migrations.json';
|
|
4
|
+
|
|
5
|
+
export default defineNuxtPlugin(async () => {
|
|
6
|
+
const apiUrl = useRuntimeConfig().public.apiUrl as string;
|
|
7
|
+
if (!apiUrl) return;
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
await $fetch(`${apiUrl}/themes/_sync`, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
body: {
|
|
13
|
+
slug: themeConfig.slug,
|
|
14
|
+
name: themeConfig.name,
|
|
15
|
+
version: themeConfig.version,
|
|
16
|
+
author: themeConfig.author,
|
|
17
|
+
description: themeConfig.description,
|
|
18
|
+
cover: themeConfig.cover,
|
|
19
|
+
license: themeConfig.license,
|
|
20
|
+
repo: themeConfig.repo,
|
|
21
|
+
schema: configSchema,
|
|
22
|
+
migrations,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.warn('[core-nuxt] 主题元数据上报失败:', e);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 访问统计追踪插件
|
|
3
|
+
* 自动追踪 PV/UV、页面停留时长、支持手动事件追踪
|
|
4
|
+
*
|
|
5
|
+
* 使用 .client.ts 后缀确保只在客户端运行
|
|
6
|
+
* 使用 requestIdleCallback 延迟初始化,避免阻塞首屏渲染
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// 类型声明
|
|
10
|
+
export interface TrackerPlugin {
|
|
11
|
+
trackPageView: (path?: string, articleId?: number) => void;
|
|
12
|
+
trackEvent: (name: string, data?: Record<string, unknown>) => void;
|
|
13
|
+
setArticleId: (id?: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare module '#app' {
|
|
17
|
+
interface NuxtApp {
|
|
18
|
+
$tracker: TrackerPlugin;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default defineNuxtPlugin({
|
|
23
|
+
parallel: true,
|
|
24
|
+
setup() {
|
|
25
|
+
const router = useRouter();
|
|
26
|
+
const endpoint = `${useRuntimeConfig().public.apiUrl}/collect`;
|
|
27
|
+
const { userInfo } = useUser();
|
|
28
|
+
|
|
29
|
+
let pageStartTime = Date.now();
|
|
30
|
+
let lastPageUrl = location.pathname + location.search;
|
|
31
|
+
let currentArticleId: number | undefined;
|
|
32
|
+
|
|
33
|
+
// 检查是否为超级管理员
|
|
34
|
+
const isSuperAdmin = () => {
|
|
35
|
+
// 从响应式状态读取
|
|
36
|
+
return userInfo.value?.role === 'super_admin';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getBaseData = (url?: string, articleId?: number) => ({
|
|
40
|
+
url: url || location.pathname + location.search,
|
|
41
|
+
hostname: location.hostname,
|
|
42
|
+
referrer: document.referrer,
|
|
43
|
+
language: navigator.language,
|
|
44
|
+
screen: `${screen.width}x${screen.height}`,
|
|
45
|
+
title: document.title,
|
|
46
|
+
timestamp: Date.now(),
|
|
47
|
+
...(articleId !== undefined && { article_id: articleId }),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const send = (
|
|
51
|
+
type: string,
|
|
52
|
+
extra: Record<string, unknown> = {},
|
|
53
|
+
url?: string,
|
|
54
|
+
articleId?: number
|
|
55
|
+
) => {
|
|
56
|
+
if (isSuperAdmin()) return;
|
|
57
|
+
|
|
58
|
+
const payload = { ...getBaseData(url, articleId), type, ...extra };
|
|
59
|
+
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
|
|
60
|
+
if (navigator.sendBeacon) {
|
|
61
|
+
navigator.sendBeacon(endpoint, blob);
|
|
62
|
+
} else {
|
|
63
|
+
fetch(endpoint, { method: 'POST', body: blob, keepalive: true }).catch(() => {});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const sendDuration = (url?: string, articleId?: number) => {
|
|
68
|
+
const sec = Math.floor((Date.now() - pageStartTime) / 1000);
|
|
69
|
+
if (sec > 0) send('duration', { duration: sec }, url, articleId);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// 页面隐藏/卸载时发送停留时长
|
|
73
|
+
document.addEventListener('visibilitychange', () => {
|
|
74
|
+
if (document.hidden) {
|
|
75
|
+
sendDuration(undefined, currentArticleId);
|
|
76
|
+
} else {
|
|
77
|
+
pageStartTime = Date.now();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
window.addEventListener('beforeunload', () => sendDuration(undefined, currentArticleId));
|
|
81
|
+
|
|
82
|
+
// 路由变化时统计
|
|
83
|
+
router.afterEach(to => {
|
|
84
|
+
setTimeout(() => {
|
|
85
|
+
sendDuration(lastPageUrl, currentArticleId);
|
|
86
|
+
pageStartTime = Date.now();
|
|
87
|
+
lastPageUrl = to.path;
|
|
88
|
+
currentArticleId = undefined;
|
|
89
|
+
send('pageview', {}, to.path);
|
|
90
|
+
}, 100);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
provide: {
|
|
95
|
+
tracker: {
|
|
96
|
+
trackPageView: (path?: string, articleId?: number) =>
|
|
97
|
+
send('pageview', {}, path, articleId),
|
|
98
|
+
trackEvent: (name: string, data?: Record<string, unknown>) =>
|
|
99
|
+
name && send('event', { event_name: name, event_data: data }),
|
|
100
|
+
setArticleId: (id?: number) => {
|
|
101
|
+
currentArticleId = id;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
});
|