@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.
Files changed (70) hide show
  1. package/app.vue +172 -0
  2. package/assets/css/base.scss +96 -0
  3. package/composables/api/article.ts +25 -0
  4. package/composables/api/category.ts +19 -0
  5. package/composables/api/comment.ts +25 -0
  6. package/composables/api/createApi.ts +242 -0
  7. package/composables/api/feedback.ts +14 -0
  8. package/composables/api/friend.ts +14 -0
  9. package/composables/api/moment.ts +10 -0
  10. package/composables/api/music.ts +39 -0
  11. package/composables/api/notification.ts +19 -0
  12. package/composables/api/stats.ts +14 -0
  13. package/composables/api/subscribe.ts +13 -0
  14. package/composables/api/sysconfig.ts +9 -0
  15. package/composables/api/tag.ts +19 -0
  16. package/composables/api/theme.ts +9 -0
  17. package/composables/api/upload.ts +13 -0
  18. package/composables/api/user.ts +77 -0
  19. package/composables/useArticle.ts +227 -0
  20. package/composables/useAuth.ts +180 -0
  21. package/composables/useComment.ts +143 -0
  22. package/composables/useDarkMode.ts +29 -0
  23. package/composables/useEmoji.ts +39 -0
  24. package/composables/useFeedback.ts +38 -0
  25. package/composables/useFriendList.ts +100 -0
  26. package/composables/useMermaid.ts +30 -0
  27. package/composables/useMoment.ts +86 -0
  28. package/composables/useMusic.ts +37 -0
  29. package/composables/useSearchState.ts +90 -0
  30. package/composables/useStores.ts +557 -0
  31. package/composables/useSubscribe.ts +13 -0
  32. package/composables/useTheme.ts +61 -0
  33. package/composables/useUser.ts +202 -0
  34. package/layouts/default.vue +1 -0
  35. package/nuxt.config.ts +170 -0
  36. package/package.json +58 -0
  37. package/pages/index.vue +1 -0
  38. package/plugins/console-banner.client.ts +11 -0
  39. package/plugins/custom-code.ts +121 -0
  40. package/plugins/syncThemeMeta.ts +28 -0
  41. package/plugins/tracker.client.ts +107 -0
  42. package/server/plugins/sitemap.ts +170 -0
  43. package/server/routes/atom.xml.ts +21 -0
  44. package/server/routes/manifest.json.ts +45 -0
  45. package/server/routes/rss.xml.ts +21 -0
  46. package/types/article.ts +48 -0
  47. package/types/auth.ts +8 -0
  48. package/types/category.ts +12 -0
  49. package/types/comment.ts +72 -0
  50. package/types/emoji.ts +10 -0
  51. package/types/feedback.ts +31 -0
  52. package/types/friend.ts +63 -0
  53. package/types/markdown.ts +7 -0
  54. package/types/moment.ts +85 -0
  55. package/types/notification.ts +56 -0
  56. package/types/request.ts +30 -0
  57. package/types/stats.ts +38 -0
  58. package/types/sysconfig.ts +2 -0
  59. package/types/tag.ts +11 -0
  60. package/types/theme.ts +26 -0
  61. package/types/upload.ts +5 -0
  62. package/types/user.ts +106 -0
  63. package/utils/avatar.ts +21 -0
  64. package/utils/date.ts +136 -0
  65. package/utils/download.ts +11 -0
  66. package/utils/emoji.ts +90 -0
  67. package/utils/format.ts +42 -0
  68. package/utils/markdown.ts +1458 -0
  69. package/utils/scroll.ts +31 -0
  70. package/utils/upload.ts +57 -0
package/app.vue ADDED
@@ -0,0 +1,172 @@
1
+ <script setup lang="ts">
2
+ import { getCategories } from '@/composables/api/category';
3
+ import { getTags } from '@/composables/api/tag';
4
+ import { getSiteStats } from '@/composables/api/stats';
5
+ import { getSettingGroup } from '@/composables/api/sysconfig';
6
+ import { getActiveThemeSchema } from '@/composables/api/theme';
7
+
8
+ // 全局数据
9
+ const { basicConfig, oauthConfig, uploadConfig } = useSysConfig();
10
+ const { menus } = useMenus();
11
+ const { categories, total: categoriesTotal } = useCategories();
12
+ const { tags, total: tagsTotal } = useTags();
13
+ const { siteStats } = useStats();
14
+ const { theme } = useTheme();
15
+
16
+ const { data: globalData } = await useAsyncData('global-data', async () => {
17
+ const [basicData, oauthData, uploadData, categoriesData, tagsData, statsData, themeData] =
18
+ await Promise.all([
19
+ getSettingGroup('basic'),
20
+ getSettingGroup('oauth'),
21
+ getSettingGroup('upload'),
22
+ getCategories(),
23
+ getTags(),
24
+ getSiteStats(),
25
+ getActiveThemeSchema(),
26
+ ]);
27
+
28
+ return {
29
+ basicConfig: basicData,
30
+ oauthConfig: oauthData,
31
+ uploadConfig: uploadData,
32
+ menus: themeData.menus || {},
33
+ categories: categoriesData.list,
34
+ categoriesTotal: categoriesData.total,
35
+ tags: tagsData.list,
36
+ tagsTotal: tagsData.total,
37
+ stats: statsData,
38
+ theme: themeData,
39
+ };
40
+ });
41
+
42
+ // 初始化全局数据
43
+ watchEffect(() => {
44
+ if (globalData.value) {
45
+ basicConfig.value = globalData.value.basicConfig;
46
+ oauthConfig.value = globalData.value.oauthConfig;
47
+ uploadConfig.value = globalData.value.uploadConfig;
48
+ menus.value = globalData.value.menus;
49
+ categories.value = globalData.value.categories;
50
+ tags.value = globalData.value.tags;
51
+ siteStats.value = globalData.value.stats;
52
+ if (globalData.value.categoriesTotal !== undefined) {
53
+ categoriesTotal.value = globalData.value.categoriesTotal;
54
+ }
55
+ if (globalData.value.tagsTotal !== undefined) {
56
+ tagsTotal.value = globalData.value.tagsTotal;
57
+ }
58
+ if (globalData.value.theme) {
59
+ theme.value = {
60
+ slug: globalData.value.theme.slug,
61
+ name: globalData.value.theme.name || globalData.value.theme.slug,
62
+ schema: globalData.value.theme.schema,
63
+ config: globalData.value.theme.config ?? {},
64
+ loaded: true,
65
+ };
66
+ }
67
+ initMarkdownRenderer();
68
+ }
69
+ });
70
+
71
+ // 刷新时恢复滚动位置
72
+ onMounted(() => {
73
+ const key = 'scroll-y';
74
+ const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
75
+ if (nav?.type === 'reload') {
76
+ const y = +(sessionStorage.getItem(key) || 0);
77
+ if (y > 0) setTimeout(() => window.scrollTo(0, y), 100);
78
+ }
79
+ let t: ReturnType<typeof setTimeout>;
80
+ const save = () => sessionStorage.setItem(key, '' + window.scrollY);
81
+ window.addEventListener(
82
+ 'scroll',
83
+ () => {
84
+ clearTimeout(t);
85
+ t = setTimeout(save, 200);
86
+ },
87
+ { passive: true }
88
+ );
89
+ window.addEventListener('pagehide', save);
90
+ });
91
+
92
+ // SEO Meta
93
+ useSeoMeta({
94
+ description: () => basicConfig.value.description,
95
+ keywords: () => basicConfig.value.keywords,
96
+ author: () => basicConfig.value.author,
97
+ // Open Graph
98
+ ogTitle: () => basicConfig.value.title,
99
+ ogDescription: () => basicConfig.value.description,
100
+ ogImage: () => basicConfig.value.favicon,
101
+ ogType: 'website',
102
+ ogSiteName: () => basicConfig.value.title,
103
+ // Twitter Card
104
+ twitterCard: 'summary_large_image',
105
+ twitterTitle: () => basicConfig.value.title,
106
+ twitterDescription: () => basicConfig.value.description,
107
+ twitterImage: () => basicConfig.value.favicon,
108
+ });
109
+
110
+ // 页面标题模板和 favicon
111
+ const route = useRoute();
112
+ const siteTitle = computed(() => basicConfig.value.title);
113
+
114
+ useHead({
115
+ titleTemplate: (title): string | null => {
116
+ // 首页特殊处理:显示"网站标题 - 网站副标题"
117
+ if (route.path === '/') {
118
+ const subtitle = basicConfig.value.subtitle;
119
+ return subtitle ? `${siteTitle.value} - ${subtitle}` : siteTitle.value || null;
120
+ }
121
+
122
+ // 其他页面:显示"页面标题 | 网站标题"
123
+ const pageTitle = title || (route.meta.title as string);
124
+ if (pageTitle) return `${pageTitle} | ${siteTitle.value}`;
125
+ return siteTitle.value || null;
126
+ },
127
+ link: [
128
+ { rel: 'icon', href: basicConfig.value.favicon || '/favicon.ico' },
129
+ // PWA Manifest
130
+ { rel: 'manifest', href: '/manifest.json' },
131
+ // RSS/Atom 订阅
132
+ {
133
+ rel: 'alternate',
134
+ type: 'application/rss+xml',
135
+ title: `${basicConfig.value.title} - RSS 2.0 Feed`,
136
+ href: '/rss.xml',
137
+ },
138
+ {
139
+ rel: 'alternate',
140
+ type: 'application/atom+xml',
141
+ title: `${basicConfig.value.title} - Atom Feed`,
142
+ href: '/atom.xml',
143
+ },
144
+ ],
145
+ meta: computed(() => [
146
+ { name: 'description', content: basicConfig.value.description },
147
+ { name: 'keywords', content: basicConfig.value.keywords },
148
+ { name: 'author', content: basicConfig.value.author },
149
+ // PWA 主题色
150
+ { name: 'theme-color', content: '#f7f7f7' },
151
+ { name: 'mobile-web-app-capable', content: 'yes' },
152
+ { name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
153
+ ]),
154
+ script: [
155
+ {
156
+ type: 'application/ld+json',
157
+ innerHTML: JSON.stringify({
158
+ '@context': 'https://schema.org',
159
+ '@type': 'WebSite',
160
+ name: basicConfig.value.title,
161
+ description: basicConfig.value.description,
162
+ }),
163
+ },
164
+ ],
165
+ });
166
+ </script>
167
+
168
+ <template>
169
+ <NuxtLayout>
170
+ <NuxtPage />
171
+ </NuxtLayout>
172
+ </template>
@@ -0,0 +1,96 @@
1
+ *,
2
+ *::before,
3
+ *::after {
4
+ box-sizing: border-box;
5
+ -webkit-tap-highlight-color: transparent;
6
+ }
7
+
8
+ html {
9
+ scroll-behavior: smooth;
10
+ color-scheme: light dark;
11
+ max-width: 100%;
12
+ overflow-x: clip;
13
+ }
14
+
15
+ body {
16
+ margin: 0;
17
+ padding: 0;
18
+ line-height: 2;
19
+ max-width: 100%;
20
+ overflow-x: clip;
21
+ }
22
+
23
+ h1,
24
+ h2,
25
+ h3,
26
+ h4,
27
+ h5,
28
+ h6 {
29
+ margin: 0;
30
+ line-height: 1.3;
31
+ font-weight: inherit;
32
+ }
33
+
34
+ p {
35
+ margin: 0;
36
+ }
37
+
38
+ a {
39
+ color: inherit;
40
+ text-decoration: none;
41
+ transition: all 0.2s;
42
+ }
43
+
44
+ img {
45
+ max-width: 100%;
46
+ height: auto;
47
+ display: block;
48
+ }
49
+
50
+ ul,
51
+ ol {
52
+ list-style: none;
53
+ }
54
+
55
+ button {
56
+ background: none;
57
+ border: none;
58
+ cursor: pointer;
59
+ padding: 0;
60
+ font-family: inherit;
61
+ }
62
+
63
+ input,
64
+ textarea,
65
+ select {
66
+ font-family: inherit;
67
+ font-size: inherit;
68
+ color: inherit;
69
+ }
70
+
71
+ ::selection {
72
+ background: var(--theme-color);
73
+ color: #fff;
74
+ }
75
+
76
+ :focus-visible {
77
+ outline: 2px solid var(--theme-color);
78
+ outline-offset: 2px;
79
+ }
80
+
81
+ ::-webkit-scrollbar {
82
+ width: 5px;
83
+ height: 5px;
84
+ }
85
+
86
+ ::-webkit-scrollbar-thumb {
87
+ background: var(--theme-color);
88
+ border-radius: 5px;
89
+ cursor: pointer;
90
+ }
91
+
92
+ :where([class^='ri-']),
93
+ :where([class*=' ri-']) {
94
+ font-size: 1.15em;
95
+ display: inline-block;
96
+ }
@@ -0,0 +1,25 @@
1
+ import type { Article, ArticleQuery } from '@@/types/article';
2
+ import type { PaginationData } from '@@/types/request';
3
+ import { createApi } from './createApi';
4
+
5
+ const articleApi = createApi<Article>('/articles');
6
+
7
+ /** 获取文章列表 */
8
+ export const getArticlesForWeb = async (params: ArticleQuery = {}) => {
9
+ return articleApi.getList(params);
10
+ };
11
+
12
+ /** 获取文章详情 */
13
+ export const getArticleBySlug = async (slug: string) => {
14
+ return articleApi.getOne(slug);
15
+ };
16
+
17
+ /** 搜索文章 */
18
+ export const searchArticles = async (keyword: string, params: Partial<ArticleQuery> = {}) => {
19
+ return articleApi.get<PaginationData<Article>>('/search', { keyword, ...params });
20
+ };
21
+
22
+ /** 随机文章 slug */
23
+ export const getRandomArticleSlug = async () => {
24
+ return articleApi.get<string>('/random');
25
+ };
@@ -0,0 +1,19 @@
1
+ import type { Category } from '@@/types/category';
2
+ import { createApi } from './createApi';
3
+
4
+ const categoryApi = createApi<Category>('/categories');
5
+
6
+ /** 获取分类列表 */
7
+ export const getCategories = async () => {
8
+ return categoryApi.getList();
9
+ };
10
+
11
+ /** 获取分类详情(By ID) */
12
+ export const getCategoryById = async (id: number) => {
13
+ return categoryApi.getOne(id);
14
+ };
15
+
16
+ /** 获取分类详情(By Slug) */
17
+ export const getCategoryBySlug = async (slug: string) => {
18
+ return categoryApi.getOne(slug);
19
+ };
@@ -0,0 +1,25 @@
1
+ import type { Comment, CommentTargetType, CreateCommentParams } from '@@/types/comment';
2
+ import type { PaginationQuery } from '@@/types/request';
3
+ import { createApi } from './createApi';
4
+
5
+ interface GetCommentsParams extends PaginationQuery {
6
+ target_type: CommentTargetType;
7
+ target_key: string | number;
8
+ }
9
+
10
+ const commentApi = createApi<Comment>('/comments', { stringifyTargetKey: true });
11
+
12
+ /** 获取评论列表 */
13
+ export const getComments = async (params: GetCommentsParams) => {
14
+ return commentApi.getList(params);
15
+ };
16
+
17
+ /** 创建评论 */
18
+ export const createComment = async (params: CreateCommentParams) => {
19
+ return commentApi.create(params);
20
+ };
21
+
22
+ /** 删除评论(仅可删除自己的评论) */
23
+ export const deleteComment = async (id: number) => {
24
+ return commentApi.delete(id);
25
+ };
@@ -0,0 +1,242 @@
1
+ import { $fetch } from 'ofetch';
2
+ import type { FetchOptions } from 'ofetch';
3
+ import { accessToken, setAccessToken, logout } from '../useAuth';
4
+ import type { ApiResponse, PaginationData, PaginationQuery } from '@@/types/request';
5
+
6
+ // ========== HTTP 请求基础设施 ==========
7
+
8
+ type HttpMethod =
9
+ | 'GET'
10
+ | 'HEAD'
11
+ | 'PATCH'
12
+ | 'POST'
13
+ | 'PUT'
14
+ | 'DELETE'
15
+ | 'CONNECT'
16
+ | 'OPTIONS'
17
+ | 'TRACE';
18
+
19
+ // Token 刷新状态
20
+ let isRefreshing = false;
21
+ let refreshPromise: Promise<boolean> | null = null;
22
+
23
+ // 刷新 Token
24
+ const doRefreshToken = async (): Promise<boolean> => {
25
+ try {
26
+ const res = await $fetch<ApiResponse<{ access_token: string }>>('/auth/refresh', {
27
+ method: 'POST',
28
+ baseURL: useRuntimeConfig().public.apiUrl as string,
29
+ credentials: 'include',
30
+ });
31
+ if (res.code === 0 && res.data) {
32
+ setAccessToken(res.data.access_token);
33
+ return true;
34
+ }
35
+ return false;
36
+ } catch {
37
+ return false;
38
+ }
39
+ };
40
+
41
+ // 封装请求,支持自动 Token 注入和 401 刷新
42
+ /* eslint-disable @typescript-eslint/no-explicit-any -- 通用请求封装,返回类型由调用方指定 */
43
+ async function apiRequest<T = any>(
44
+ url: string,
45
+ options: Omit<FetchOptions, 'method'> & { method?: HttpMethod; _retry?: boolean } = {}
46
+ ): Promise<T> {
47
+ const config = useRuntimeConfig();
48
+ const headers: Record<string, string> = {
49
+ ...((options.headers as Record<string, string>) || {}),
50
+ };
51
+
52
+ if (accessToken.value && url !== '/auth/refresh') {
53
+ headers['Authorization'] = `Bearer ${accessToken.value}`;
54
+ }
55
+
56
+ try {
57
+ const response = await $fetch<T>(url, {
58
+ ...options,
59
+ baseURL: config.public.apiUrl,
60
+ headers,
61
+ credentials: 'include',
62
+ } as any);
63
+
64
+ // 后端返回 HTTP 200 但业务 code 非 0 时,视为错误
65
+ if (
66
+ response &&
67
+ typeof response === 'object' &&
68
+ 'code' in response &&
69
+ (response as any).code !== 0
70
+ ) {
71
+ const err = new Error((response as any).message || '请求失败') as any;
72
+ err.response = { data: response };
73
+ throw err;
74
+ }
75
+
76
+ return response;
77
+ } catch (error: any) {
78
+ // 401 自动刷新 token
79
+ if (error?.response?.status === 401 && !options._retry) {
80
+ if (!isRefreshing) {
81
+ isRefreshing = true;
82
+ refreshPromise = doRefreshToken().finally(() => {
83
+ isRefreshing = false;
84
+ });
85
+ }
86
+
87
+ const success = await refreshPromise;
88
+ if (success) {
89
+ return apiRequest<T>(url, { ...options, _retry: true });
90
+ }
91
+ logout();
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+ /* eslint-enable @typescript-eslint/no-explicit-any */
97
+
98
+ /* eslint-disable @typescript-eslint/no-explicit-any -- 通用请求封装,返回类型/请求体由调用方指定 */
99
+ async function get<T = any>(url: string, options: Omit<FetchOptions, 'method'> = {}): Promise<T> {
100
+ return await apiRequest<T>(url, { ...options, method: 'GET' });
101
+ }
102
+
103
+ async function post<T = any>(
104
+ url: string,
105
+ body?: any,
106
+ options: Omit<FetchOptions, 'method'> = {}
107
+ ): Promise<T> {
108
+ return await apiRequest<T>(url, { ...options, method: 'POST', body });
109
+ }
110
+
111
+ async function put<T = any>(
112
+ url: string,
113
+ body?: any,
114
+ options: Omit<FetchOptions, 'method'> = {}
115
+ ): Promise<T> {
116
+ return await apiRequest<T>(url, { ...options, method: 'PUT', body });
117
+ }
118
+
119
+ async function patch<T = any>(
120
+ url: string,
121
+ body?: any,
122
+ options: Omit<FetchOptions, 'method'> = {}
123
+ ): Promise<T> {
124
+ return await apiRequest<T>(url, { ...options, method: 'PATCH', body });
125
+ }
126
+
127
+ async function del<T = any>(url: string, options: Omit<FetchOptions, 'method'> = {}): Promise<T> {
128
+ return await apiRequest<T>(url, { ...options, method: 'DELETE' });
129
+ }
130
+ /* eslint-enable @typescript-eslint/no-explicit-any */
131
+
132
+ // ========== API 工厂 ==========
133
+
134
+ interface ApiFactoryOptions {
135
+ stringifyTargetKey?: boolean;
136
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 转换函数,输入输出类型由调用方决定
137
+ transformParams?: (params: any) => any;
138
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 转换函数,输入输出类型由调用方决定
139
+ transformBody?: (body: any) => any;
140
+ }
141
+
142
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 处理任意数据结构
143
+ function processData(data: any, options: ApiFactoryOptions, isBody: boolean) {
144
+ let processed = { ...data };
145
+
146
+ if (options.stringifyTargetKey && processed.target_key !== undefined) {
147
+ processed.target_key = String(processed.target_key);
148
+ }
149
+
150
+ const transformFn = isBody ? options.transformBody : options.transformParams;
151
+ if (transformFn) {
152
+ processed = transformFn(processed);
153
+ }
154
+
155
+ return processed;
156
+ }
157
+
158
+ export function createApi<T>(endpoint: string, options: ApiFactoryOptions = {}) {
159
+ return {
160
+ getList: async (params?: Partial<PaginationQuery>): Promise<PaginationData<T>> => {
161
+ const response = await get<ApiResponse<PaginationData<T>>>(endpoint, {
162
+ params: processData(params, options, false),
163
+ });
164
+ return response.data;
165
+ },
166
+
167
+ getOne: async (id: number | string): Promise<T> => {
168
+ const response = await get<ApiResponse<T>>(`${endpoint}/${id}`);
169
+ return response.data;
170
+ },
171
+
172
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 数据类型由调用方决定
173
+ create: async (data: any): Promise<T> => {
174
+ const response = await post<ApiResponse<T>>(endpoint, processData(data, options, true));
175
+ return response.data;
176
+ },
177
+
178
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 数据类型由调用方决定
179
+ update: async (id: number | string, data: any): Promise<T> => {
180
+ const response = await put<ApiResponse<T>>(
181
+ `${endpoint}/${id}`,
182
+ processData(data, options, true)
183
+ );
184
+ return response.data;
185
+ },
186
+
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 数据类型由调用方决定
188
+ patch: async (id: number | string, data: any): Promise<T> => {
189
+ const response = await patch<ApiResponse<T>>(
190
+ `${endpoint}/${id}`,
191
+ processData(data, options, true)
192
+ );
193
+ return response.data;
194
+ },
195
+
196
+ delete: async (id: number | string): Promise<void> => {
197
+ await del(`${endpoint}/${id}`);
198
+ },
199
+
200
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 参数类型由调用方决定
201
+ get: async <R = T>(url: string, params?: any): Promise<R> => {
202
+ const response = await get<ApiResponse<R>>(`${endpoint}${url}`, {
203
+ params: processData(params, options, false),
204
+ });
205
+ return response.data;
206
+ },
207
+
208
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 数据类型由调用方决定
209
+ post: async <R = T>(url: string, data?: any): Promise<R> => {
210
+ const response = await post<ApiResponse<R>>(
211
+ `${endpoint}${url}`,
212
+ processData(data, options, true)
213
+ );
214
+ return response.data;
215
+ },
216
+
217
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 数据类型由调用方决定
218
+ put: async <R = T>(url: string, data?: any): Promise<R> => {
219
+ const response = await put<ApiResponse<R>>(
220
+ `${endpoint}${url}`,
221
+ processData(data, options, true)
222
+ );
223
+ return response.data;
224
+ },
225
+
226
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 数据类型由调用方决定
227
+ patchRequest: async <R = T>(url: string, data?: any): Promise<R> => {
228
+ const response = await patch<ApiResponse<R>>(
229
+ `${endpoint}${url}`,
230
+ processData(data, options, true)
231
+ );
232
+ return response.data;
233
+ },
234
+
235
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 数据类型由调用方决定
236
+ deleteRequest: async (url: string, data?: any): Promise<void> => {
237
+ await del(`${endpoint}${url}`, {
238
+ body: processData(data, options, true),
239
+ });
240
+ },
241
+ };
242
+ }
@@ -0,0 +1,14 @@
1
+ import type { Feedback, SubmitFeedbackParams } from '@@/types/feedback';
2
+ import { createApi } from './createApi';
3
+
4
+ const feedbackApi = createApi<Feedback>('');
5
+
6
+ /** 提交反馈 */
7
+ export const submitFeedback = async (data: SubmitFeedbackParams) => {
8
+ return feedbackApi.post('/feedback', data);
9
+ };
10
+
11
+ /** 查询反馈状态 */
12
+ export const getFeedbackByTicketNo = async (ticketNo: string) => {
13
+ return feedbackApi.get(`/feedback/ticket/${ticketNo}`);
14
+ };
@@ -0,0 +1,14 @@
1
+ import type { FriendGroupedResponse, FriendQueryParams, FriendApplyRequest } from '@@/types/friend';
2
+ import { createApi } from './createApi';
3
+
4
+ const friendApi = createApi<FriendGroupedResponse>('');
5
+
6
+ /** 获取友链列表 */
7
+ export const getFriends = async (params?: FriendQueryParams) => {
8
+ return friendApi.get('/friends', params);
9
+ };
10
+
11
+ /** 申请友链 */
12
+ export const applyFriend = async (data: FriendApplyRequest) => {
13
+ return friendApi.post('/friends/apply', data);
14
+ };
@@ -0,0 +1,10 @@
1
+ import type { Moment } from '@@/types/moment';
2
+ import type { PaginationQuery } from '@@/types/request';
3
+ import { createApi } from './createApi';
4
+
5
+ const momentApi = createApi<Moment>('/moments');
6
+
7
+ /** 获取动态列表 */
8
+ export const getMoments = async (params: PaginationQuery = {}) => {
9
+ return momentApi.getList(params);
10
+ };
@@ -0,0 +1,39 @@
1
+ import type { MusicTrack } from '@@/types/moment';
2
+
3
+ interface MusicApiResponse {
4
+ name?: string;
5
+ title?: string;
6
+ artist?: string;
7
+ author?: string;
8
+ url: string;
9
+ pic?: string;
10
+ cover?: string;
11
+ lrc?: string;
12
+ }
13
+
14
+ /** 从 Meting API 获取音乐数据 */
15
+ export async function fetchMetingMusic(
16
+ apiUrl: string,
17
+ params: { server: string; type: string; id: string }
18
+ ): Promise<MusicTrack[]> {
19
+ const response = await $fetch<MusicApiResponse[] | MusicApiResponse>(
20
+ `${apiUrl}?server=${params.server}&type=${params.type}&id=${params.id}`
21
+ );
22
+ const list = Array.isArray(response) ? response : [response];
23
+ return list.map(item => ({
24
+ name: item.name || item.title || '未知歌曲',
25
+ artist: item.artist || item.author || '未知艺术家',
26
+ url: item.url,
27
+ cover: item.pic || item.cover || '',
28
+ lrc: item.lrc || '',
29
+ }));
30
+ }
31
+
32
+ /** 获取歌词文本 */
33
+ export async function fetchLyricsText(lrcUrl: string): Promise<string> {
34
+ try {
35
+ return await $fetch<string>(lrcUrl, { responseType: 'text' });
36
+ } catch {
37
+ return '';
38
+ }
39
+ }
@@ -0,0 +1,19 @@
1
+ import type { NotificationListResponse, GetNotificationsParams } from '@@/types/notification';
2
+ import { createApi } from './createApi';
3
+
4
+ const notificationApi = createApi<NotificationListResponse>('');
5
+
6
+ /** 获取通知列表 */
7
+ export const getNotifications = async (params: GetNotificationsParams) => {
8
+ return notificationApi.get<NotificationListResponse>('/notifications', params);
9
+ };
10
+
11
+ /** 标记单条通知已读 */
12
+ export const markAsRead = async (id: number) => {
13
+ await notificationApi.put(`/notifications/${id}/read`);
14
+ };
15
+
16
+ /** 标记全部通知已读 */
17
+ export const markAllAsRead = async () => {
18
+ await notificationApi.put('/notifications/read-all');
19
+ };
@@ -0,0 +1,14 @@
1
+ import type { SiteStats, ArchiveStats } from '@@/types/stats';
2
+ import { createApi } from './createApi';
3
+
4
+ const statsApi = createApi<SiteStats>('');
5
+
6
+ /** 获取网站统计信息 */
7
+ export const getSiteStats = async () => {
8
+ return statsApi.get<SiteStats>('/stats/site');
9
+ };
10
+
11
+ /** 获取归档统计信息 */
12
+ export const getArchiveStats = async () => {
13
+ return statsApi.get<ArchiveStats>('/stats/archives');
14
+ };