@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
@@ -0,0 +1,13 @@
1
+ import { createApi } from './createApi';
2
+
3
+ const subscribeApi = createApi<{ message: string }>('/subscribe');
4
+
5
+ /** 邮件订阅 */
6
+ export const subscribe = async (email: string) => {
7
+ return subscribeApi.post<{ message: string }>('', { email });
8
+ };
9
+
10
+ /** 退订 */
11
+ export const unsubscribe = async (token: string) => {
12
+ return subscribeApi.get<{ message: string }>('/unsubscribe', { token });
13
+ };
@@ -0,0 +1,9 @@
1
+ import type { SettingGroupType } from '@@/types/sysconfig';
2
+ import { createApi } from './createApi';
3
+
4
+ const settingApi = createApi<Record<string, string>>('');
5
+
6
+ /** 获取指定分组的配置 */
7
+ export const getSettingGroup = async (group: SettingGroupType) => {
8
+ return settingApi.get<Record<string, string>>(`/settings/${group}`);
9
+ };
@@ -0,0 +1,19 @@
1
+ import type { Tag } from '@@/types/tag';
2
+ import { createApi } from './createApi';
3
+
4
+ const tagApi = createApi<Tag>('/tags');
5
+
6
+ /** 获取标签列表 */
7
+ export const getTags = async () => {
8
+ return tagApi.getList();
9
+ };
10
+
11
+ /** 根据ID获取标签详情 */
12
+ export const getTagById = async (id: number) => {
13
+ return tagApi.getOne(id);
14
+ };
15
+
16
+ /** 根据Slug获取标签详情 */
17
+ export const getTagBySlug = async (slug: string) => {
18
+ return tagApi.getOne(slug);
19
+ };
@@ -0,0 +1,9 @@
1
+ import type { ThemeSchemaResponse } from '@@/types/theme';
2
+ import { createApi } from './createApi';
3
+
4
+ const themeApi = createApi<ThemeSchemaResponse>('/themes');
5
+
6
+ /** 获取当前激活主题的 Schema 和 Config(公开接口) */
7
+ export const getActiveThemeSchema = async (): Promise<ThemeSchemaResponse> => {
8
+ return themeApi.get<ThemeSchemaResponse>('');
9
+ };
@@ -0,0 +1,13 @@
1
+ import { createApi } from './createApi';
2
+
3
+ interface UploadApiResponse {
4
+ original_name: string;
5
+ file_url: string;
6
+ }
7
+
8
+ const uploadApi = createApi<UploadApiResponse>('/upload');
9
+
10
+ /** 上传文件 */
11
+ export async function uploadFileApi(formData: FormData): Promise<UploadApiResponse> {
12
+ return uploadApi.post<UploadApiResponse>('', formData);
13
+ }
@@ -0,0 +1,77 @@
1
+ import type {
2
+ LoginParams,
3
+ LoginResponse,
4
+ RegisterParams,
5
+ RegisterResponse,
6
+ UserInfo,
7
+ UpdateProfileParams,
8
+ ForgotPasswordParams,
9
+ ResetPasswordParams,
10
+ ChangePasswordParams,
11
+ DeactivateAccountParams,
12
+ RefreshTokenResponse,
13
+ } from '@@/types/user';
14
+ import { createApi } from './createApi';
15
+
16
+ const authApi = createApi<LoginResponse>('/auth');
17
+ const userApi = createApi<UserInfo>('');
18
+
19
+ /** 用户登录 */
20
+ export const login = async (data: LoginParams) => {
21
+ return authApi.post<LoginResponse>('/login', data);
22
+ };
23
+
24
+ /** 用户注册 */
25
+ export const register = async (data: RegisterParams) => {
26
+ return authApi.post<RegisterResponse>('/register', data);
27
+ };
28
+
29
+ /** 刷新Token */
30
+ export const refreshToken = async () => {
31
+ return authApi.post<RefreshTokenResponse>('/refresh');
32
+ };
33
+
34
+ /** 获取当前用户信息 */
35
+ export const getUserProfile = async () => {
36
+ return userApi.get<UserInfo>('/user/profile');
37
+ };
38
+
39
+ /** 更新用户资料 */
40
+ export const updateUserProfile = async (data: UpdateProfileParams) => {
41
+ return userApi.patchRequest<UserInfo>('/user/profile', data);
42
+ };
43
+
44
+ /** 忘记密码 */
45
+ export const forgotPassword = async (data: ForgotPasswordParams) => {
46
+ await authApi.post('/forgot-password', data);
47
+ };
48
+
49
+ /** 重置密码 */
50
+ export const resetPassword = async (data: ResetPasswordParams) => {
51
+ await authApi.post('/reset-password', data);
52
+ };
53
+
54
+ /** 修改密码 */
55
+ export const changePassword = async (data: ChangePasswordParams) => {
56
+ await userApi.put('/user/password', data);
57
+ };
58
+
59
+ /** 设置密码(OAuth 用户首次设置密码) */
60
+ export const setPassword = async (data: { password: string; confirm_password: string }) => {
61
+ await userApi.post('/user/password', data);
62
+ };
63
+
64
+ /** 注销账户 */
65
+ export const deactivateAccount = async (data: DeactivateAccountParams) => {
66
+ await userApi.deleteRequest('/user/deactivate', data);
67
+ };
68
+
69
+ /** 解绑第三方账号 */
70
+ export const unbindOAuth = async (provider: string) => {
71
+ await userApi.deleteRequest(`/user/oauth/${provider}`);
72
+ };
73
+
74
+ /** 轮询微信登录状态 */
75
+ export const pollWechatLoginStatus = async (scene: string) => {
76
+ return authApi.get<{ status: string; access_token?: string }>(`/wechat/scene/${scene}`);
77
+ };
@@ -0,0 +1,227 @@
1
+ import {
2
+ getArticlesForWeb,
3
+ getArticleBySlug,
4
+ getRandomArticleSlug as getRandomSlugApi,
5
+ } from '@/composables/api/article';
6
+ import { getCategoryBySlug } from '@/composables/api/category';
7
+ import { getTagBySlug } from '@/composables/api/tag';
8
+ import { countWords, estimateReadingTime } from '@/utils/markdown';
9
+ import type { Article, ArticleQuery } from '@@/types/article';
10
+ import type { Category } from '@@/types/category';
11
+ import type { Tag } from '@@/types/tag';
12
+
13
+ export function useArticles() {
14
+ const articles = useState<Article[]>('articles', () => []);
15
+ const total = useState<number>('articles-total', () => 0);
16
+ const currentPage = useState<number>('articles-currentPage', () => 1);
17
+ const pageSize = useState<number>('articles-pageSize', () => 10);
18
+ const loading = ref(false);
19
+ const error = ref<Error | null>(null);
20
+
21
+ const fetchArticles = async (query: ArticleQuery = {}, forceRefresh = false) => {
22
+ if (query.page) currentPage.value = query.page;
23
+ if (!forceRefresh && articles.value.length && !Object.keys(query).length) return;
24
+
25
+ loading.value = true;
26
+ error.value = null;
27
+ try {
28
+ const { list, total: resTotal } = await getArticlesForWeb({
29
+ page: currentPage.value,
30
+ page_size: pageSize.value,
31
+ ...query,
32
+ });
33
+ articles.value = list || [];
34
+ total.value = resTotal || 0;
35
+ } catch (e) {
36
+ console.error('获取文章列表失败:', e);
37
+ error.value = e instanceof Error ? e : new Error(String(e));
38
+ articles.value = [];
39
+ total.value = 0;
40
+ } finally {
41
+ loading.value = false;
42
+ }
43
+ };
44
+
45
+ async function useSSR(key: string, query?: ArticleQuery) {
46
+ const { data } = await useAsyncData(key, () =>
47
+ getArticlesForWeb({ page: 1, page_size: pageSize.value, ...query })
48
+ );
49
+ if (data.value) {
50
+ articles.value = data.value.list || [];
51
+ total.value = data.value.total || 0;
52
+ currentPage.value = 1;
53
+ }
54
+ return { data };
55
+ }
56
+
57
+ async function getRandomArticleSlug(): Promise<string> {
58
+ return getRandomSlugApi();
59
+ }
60
+
61
+ return {
62
+ articles,
63
+ total,
64
+ currentPage,
65
+ pageSize,
66
+ loading,
67
+ error,
68
+ fetchArticles,
69
+ useSSR,
70
+ getRandomArticleSlug,
71
+ setPageSize: (size: number) => {
72
+ pageSize.value = size;
73
+ currentPage.value = 1;
74
+ },
75
+ resetPage: () => (currentPage.value = 1),
76
+ };
77
+ }
78
+
79
+ export async function useArticleDetail(slug: string) {
80
+ const router = useRouter();
81
+ const article = ref<Article | null>(null);
82
+ const { setCurrentArticle } = useCurrentArticle();
83
+ const loading = ref(false);
84
+ const error = ref<Error | null>(null);
85
+
86
+ const { data: initialData, pending } = await useAsyncData(`post-${slug}`, async () => {
87
+ try {
88
+ const articleData = await getArticleBySlug(slug);
89
+ setCurrentArticle(articleData);
90
+ return articleData;
91
+ } catch (err: unknown) {
92
+ const e = err as Error & { response?: { status?: number } };
93
+ if (e.response?.status === 404) {
94
+ router.replace('/404');
95
+ }
96
+ return null;
97
+ }
98
+ });
99
+ article.value = initialData.value ?? null;
100
+
101
+ const wordCount = computed(() => {
102
+ if (!article.value?.content) return 0;
103
+ return countWords(article.value.content);
104
+ });
105
+
106
+ const readingTime = computed(() => {
107
+ if (!article.value?.content) return 0;
108
+ return estimateReadingTime(article.value.content, 300);
109
+ });
110
+
111
+ const commentCount = computed(() => {
112
+ return article.value?.comment_count || 0;
113
+ });
114
+
115
+ async function refetch(newSlug: string) {
116
+ loading.value = true;
117
+ error.value = null;
118
+ try {
119
+ const articleData = await getArticleBySlug(newSlug);
120
+ setCurrentArticle(articleData);
121
+ article.value = articleData;
122
+ } catch (err: unknown) {
123
+ const e = err as Error & { response?: { status?: number } };
124
+ error.value = e;
125
+ if (e.response?.status === 404) {
126
+ router.replace('/404');
127
+ }
128
+ } finally {
129
+ loading.value = false;
130
+ }
131
+ }
132
+
133
+ return {
134
+ article,
135
+ wordCount,
136
+ readingTime,
137
+ commentCount,
138
+ loading: computed(() => pending.value || loading.value),
139
+ error,
140
+ refetch,
141
+ };
142
+ }
143
+
144
+ type EntityType = 'category' | 'tag';
145
+
146
+ export async function useEntityArticles(type: EntityType, slug: string) {
147
+ const router = useRouter();
148
+ const route = useRoute();
149
+ const entity = ref<Category | Tag | null>(null);
150
+ const articles = ref<Article[]>([]);
151
+ const total = ref(0);
152
+ const currentPage = ref(1);
153
+ const pageSize = ref(10);
154
+ const loading = ref(false);
155
+ const error = ref<Error | null>(null);
156
+
157
+ const fetchEntity = type === 'category' ? getCategoryBySlug : getTagBySlug;
158
+
159
+ const { data: initialData, pending } = await useAsyncData(`${type}-${slug}`, async () => {
160
+ try {
161
+ const [entityData, articlesData] = await Promise.all([
162
+ fetchEntity(slug),
163
+ getArticlesForWeb({ [type]: slug, page: 1, page_size: pageSize.value }),
164
+ ]);
165
+ return { entity: entityData, articles: articlesData.list, total: articlesData.total };
166
+ } catch (err: unknown) {
167
+ const e = err as Error & { response?: { status?: number } };
168
+ if (e.response?.status === 404) {
169
+ router.replace('/404');
170
+ }
171
+ return null;
172
+ }
173
+ });
174
+
175
+ if (initialData.value) {
176
+ entity.value = initialData.value.entity;
177
+ articles.value = initialData.value.articles;
178
+ total.value = initialData.value.total;
179
+ currentPage.value = 1;
180
+ }
181
+
182
+ function currentSlug() {
183
+ return route.params.slug as string;
184
+ }
185
+
186
+ async function fetchArticlesForPage(page: number) {
187
+ currentPage.value = page;
188
+ loading.value = true;
189
+ error.value = null;
190
+ try {
191
+ const articlesData = await getArticlesForWeb({
192
+ [type]: currentSlug(),
193
+ page,
194
+ page_size: pageSize.value,
195
+ });
196
+ articles.value = articlesData.list;
197
+ total.value = articlesData.total;
198
+ } catch (err: unknown) {
199
+ const e = err as Error & { response?: { status?: number } };
200
+ error.value = e;
201
+ if (e.response?.status === 404) {
202
+ router.replace('/404');
203
+ }
204
+ } finally {
205
+ loading.value = false;
206
+ }
207
+ }
208
+
209
+ function handlePageChange(page: number) {
210
+ fetchArticlesForPage(page);
211
+ if (import.meta.client) {
212
+ window.scrollTo({ top: 0, behavior: 'smooth' });
213
+ }
214
+ }
215
+
216
+ return {
217
+ entity,
218
+ articles,
219
+ total,
220
+ currentPage,
221
+ pageSize,
222
+ loading: computed(() => pending.value || loading.value),
223
+ error,
224
+ fetchArticlesForPage,
225
+ handlePageChange,
226
+ };
227
+ }
@@ -0,0 +1,180 @@
1
+ import type { ApiResponse } from '@@/types/request';
2
+ import type { WechatQRState } from '@@/types/auth';
3
+
4
+ const ACCESS_TOKEN_KEY = 'access_token';
5
+
6
+ /**
7
+ * 获取存储的 access token
8
+ * @returns {string | null} access token字符串或null
9
+ */
10
+ const getStoredToken = (): string | null => {
11
+ if (import.meta.server) return null;
12
+ return localStorage.getItem(ACCESS_TOKEN_KEY);
13
+ };
14
+
15
+ export const accessToken = ref<string | null>(getStoredToken());
16
+
17
+ // 响应式登录状态
18
+ export const isLoggedIn = computed(() => !!accessToken.value && accessToken.value !== '');
19
+
20
+ /**
21
+ * 设置 access token
22
+ */
23
+ export const setAccessToken = (access: string): void => {
24
+ if (import.meta.client) {
25
+ localStorage.setItem(ACCESS_TOKEN_KEY, access);
26
+ }
27
+ accessToken.value = access;
28
+ };
29
+
30
+ /**
31
+ * 获取 access token
32
+ */
33
+ export const getAccessToken = (): string | null => {
34
+ if (import.meta.client) {
35
+ return localStorage.getItem(ACCESS_TOKEN_KEY);
36
+ }
37
+ return accessToken.value;
38
+ };
39
+
40
+ /**
41
+ * 清除 access token
42
+ */
43
+ export const clearAccessToken = (): void => {
44
+ if (import.meta.client) {
45
+ localStorage.removeItem(ACCESS_TOKEN_KEY);
46
+ }
47
+ accessToken.value = null;
48
+ };
49
+
50
+ /**
51
+ * 登出操作
52
+ */
53
+ export const logout = (): void => {
54
+ const token = accessToken.value;
55
+ clearAccessToken();
56
+
57
+ if (token) {
58
+ const config = useRuntimeConfig();
59
+ $fetch<ApiResponse<void>>('/auth/logout', {
60
+ method: 'POST',
61
+ baseURL: config.public.apiUrl as string,
62
+ headers: {
63
+ Authorization: `Bearer ${token}`,
64
+ },
65
+ credentials: 'include',
66
+ }).catch(() => {});
67
+ }
68
+ };
69
+
70
+ /**
71
+ * 获取响应式的登录状态
72
+ */
73
+ export const useAuth = () => isLoggedIn;
74
+
75
+ /**
76
+ * 构建 OAuth 认证 URL
77
+ */
78
+ export function buildOAuthUrl(
79
+ provider: string,
80
+ options: { redirect?: string; action?: 'bind' } = {}
81
+ ): string {
82
+ const config = useRuntimeConfig();
83
+ const base = `${config.public.apiUrl}/auth/${provider}`;
84
+ const params = new URLSearchParams();
85
+ if (options.action) params.set('action', options.action);
86
+ if (options.action === 'bind' && accessToken.value) {
87
+ params.set('token', accessToken.value);
88
+ }
89
+ if (options.redirect) params.set('redirect', options.redirect);
90
+ const qs = params.toString();
91
+ return qs ? `${base}?${qs}` : base;
92
+ }
93
+
94
+ // ============================================================
95
+ // 微信扫码登录
96
+ // ============================================================
97
+
98
+ export function useWechatLogin(options: { onSuccess: () => void }) {
99
+ const { fetchUserInfo, setToken, pollWechatLoginStatus } = useUser();
100
+
101
+ const qrState = ref<WechatQRState>({
102
+ visible: false,
103
+ imageUrl: '',
104
+ scene: '',
105
+ status: 'idle',
106
+ error: '',
107
+ });
108
+
109
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
110
+
111
+ const clearPoll = () => {
112
+ if (pollTimer) {
113
+ clearInterval(pollTimer);
114
+ pollTimer = null;
115
+ }
116
+ };
117
+
118
+ const startPoll = (scene: string) => {
119
+ clearPoll();
120
+ let elapsed = 0;
121
+ pollTimer = setInterval(async () => {
122
+ elapsed += 2;
123
+ if (elapsed > 300) {
124
+ qrState.value = { ...qrState.value, status: 'expired', error: '二维码已过期' };
125
+ clearPoll();
126
+ return;
127
+ }
128
+ try {
129
+ const res = await pollWechatLoginStatus(scene);
130
+ if (res.status === 'confirmed' && res.access_token) {
131
+ clearPoll();
132
+ setToken(res.access_token);
133
+ await fetchUserInfo();
134
+ options.onSuccess();
135
+ } else if (res.status === 'expired') {
136
+ qrState.value = { ...qrState.value, status: 'expired', error: '二维码已过期' };
137
+ clearPoll();
138
+ }
139
+ } catch {
140
+ // 轮询失败静默忽略
141
+ }
142
+ }, 2000);
143
+ };
144
+
145
+ const showQR = async () => {
146
+ qrState.value = { visible: true, imageUrl: '', scene: '', status: 'loading', error: '' };
147
+ try {
148
+ const config = useRuntimeConfig();
149
+ const resp = await $fetch.raw(`${config.public.apiUrl}/auth/wechat/qrcode`, {
150
+ responseType: 'blob',
151
+ });
152
+ const blob = resp._data as Blob;
153
+ const scene = resp.headers.get('X-Scene') || '';
154
+ qrState.value = {
155
+ visible: true,
156
+ imageUrl: URL.createObjectURL(blob),
157
+ scene,
158
+ status: 'scanning',
159
+ error: '',
160
+ };
161
+ startPoll(scene);
162
+ } catch {
163
+ qrState.value = { ...qrState.value, status: 'error', error: '获取二维码失败' };
164
+ }
165
+ };
166
+
167
+ const refresh = () => showQR();
168
+
169
+ const dismiss = () => {
170
+ clearPoll();
171
+ if (qrState.value.imageUrl) {
172
+ URL.revokeObjectURL(qrState.value.imageUrl);
173
+ }
174
+ qrState.value = { visible: false, imageUrl: '', scene: '', status: 'idle', error: '' };
175
+ };
176
+
177
+ onUnmounted(clearPoll);
178
+
179
+ return { qrState, showQR, refresh, dismiss };
180
+ }
@@ -0,0 +1,143 @@
1
+ import type { Comment, CommentTargetType, FlatComment, GuestInfo } from '@@/types/comment';
2
+
3
+ export function flattenComments(commentList: Comment[], depth = 0): FlatComment[] {
4
+ const result: FlatComment[] = [];
5
+
6
+ commentList.forEach(comment => {
7
+ result.push({ comment, depth });
8
+ if (comment.replies && comment.replies.length > 0) {
9
+ result.push(...flattenComments(comment.replies, depth + 1));
10
+ }
11
+ });
12
+
13
+ return result;
14
+ }
15
+
16
+ /** 将扁平评论列表按顶级评论分组 */
17
+ export function groupFlatComments(
18
+ flatComments: FlatComment[]
19
+ ): Array<{ parent: FlatComment; replies: FlatComment[] }> {
20
+ const groups: Array<{ parent: FlatComment; replies: FlatComment[] }> = [];
21
+ let currentGroup: { parent: FlatComment; replies: FlatComment[] } | null = null;
22
+
23
+ flatComments.forEach(item => {
24
+ if (item.depth === 0) {
25
+ currentGroup = { parent: item, replies: [] };
26
+ groups.push(currentGroup);
27
+ } else if (currentGroup) {
28
+ currentGroup.replies.push(item);
29
+ }
30
+ });
31
+
32
+ return groups;
33
+ }
34
+
35
+ /** 递归计算嵌套评论总数(含回复) */
36
+ export function countComments(list: Comment[]): number {
37
+ return list.reduce(
38
+ (total, c) => total + 1 + (c.replies?.length ? countComments(c.replies) : 0),
39
+ 0
40
+ );
41
+ }
42
+
43
+ export interface CommentContext {
44
+ targetType: Ref<CommentTargetType>;
45
+ targetKey: Ref<string | number>;
46
+ addComment: (content: string, guestInfo?: GuestInfo) => Promise<void>;
47
+ addReply: (commentId: number, content: string, guestInfo?: GuestInfo) => Promise<void>;
48
+ deleteComment: (commentId: number) => Promise<void>;
49
+ showLogin: () => void;
50
+ replyState: {
51
+ replyingToId: Ref<number | null>;
52
+ replyingToNickname: Ref<string>;
53
+ startReply: (commentId: number, nickname: string) => void;
54
+ cancelReply: () => void;
55
+ };
56
+ }
57
+
58
+ const CommentContextKey: InjectionKey<CommentContext> = Symbol('CommentContext');
59
+
60
+ export function provideCommentContext(context: CommentContext) {
61
+ provide(CommentContextKey, context);
62
+ }
63
+
64
+ export function useCommentContext() {
65
+ const context = inject(CommentContextKey);
66
+ if (!context) {
67
+ throw new Error('useCommentContext must be used within a comment provider');
68
+ }
69
+ return context;
70
+ }
71
+
72
+ export async function fillComment(content: string) {
73
+ const wrapper = document.querySelector('.comment-input');
74
+ const textarea = wrapper?.querySelector('textarea') as HTMLTextAreaElement | null;
75
+
76
+ if (!wrapper || !textarea) return;
77
+
78
+ textarea.value = content;
79
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
80
+
81
+ await new Promise(resolve => {
82
+ requestAnimationFrame(() => requestAnimationFrame(resolve));
83
+ });
84
+
85
+ scrollToElement('.comment-input');
86
+ textarea.focus();
87
+ }
88
+
89
+ // ============================================================
90
+ // 评论本地存储
91
+ // ============================================================
92
+
93
+ const GUEST_INFO_KEY = 'guest_info';
94
+ const COMMENT_DRAFT_KEY = 'comment_draft';
95
+
96
+ /** 加载游客信息(客户端) */
97
+ export function loadGuestInfo(): GuestInfo {
98
+ if (import.meta.server) return { nickname: '', email: '', website: '' };
99
+ try {
100
+ const stored = localStorage.getItem(GUEST_INFO_KEY);
101
+ if (stored) {
102
+ const saved = JSON.parse(stored);
103
+ return {
104
+ nickname: saved.nickname || '',
105
+ email: saved.email || '',
106
+ website: saved.website || '',
107
+ };
108
+ }
109
+ } catch {
110
+ // localStorage 数据可能损坏,回退到默认值
111
+ }
112
+ return { nickname: '', email: '', website: '' };
113
+ }
114
+
115
+ /** 保存游客信息(客户端) */
116
+ export function saveGuestInfo(info: GuestInfo): void {
117
+ if (import.meta.client) {
118
+ localStorage.setItem(GUEST_INFO_KEY, JSON.stringify(info));
119
+ }
120
+ }
121
+
122
+ /** 加载评论草稿(客户端) */
123
+ export function loadCommentDraft(): string {
124
+ if (import.meta.server) return '';
125
+ return localStorage.getItem(COMMENT_DRAFT_KEY) || '';
126
+ }
127
+
128
+ /** 保存评论草稿(客户端) */
129
+ export function saveCommentDraft(content: string): void {
130
+ if (!import.meta.client) return;
131
+ if (content) {
132
+ localStorage.setItem(COMMENT_DRAFT_KEY, content);
133
+ } else {
134
+ localStorage.removeItem(COMMENT_DRAFT_KEY);
135
+ }
136
+ }
137
+
138
+ /** 清除评论草稿(客户端) */
139
+ export function clearCommentDraft(): void {
140
+ if (import.meta.client) {
141
+ localStorage.removeItem(COMMENT_DRAFT_KEY);
142
+ }
143
+ }