From 565678f79a4ac1c37f49686be21170cfb1e0607b Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Thu, 7 Aug 2025 20:21:22 +0800 Subject: [PATCH] chore: migrate legacy pages and utilities to nuxt --- frontend_nuxt/components/AvatarCropper.vue | 142 +++ frontend_nuxt/components/BaseInput.vue | 82 ++ frontend_nuxt/components/BasePlaceholder.vue | 34 + frontend_nuxt/components/BaseTimeline.vue | 103 ++ frontend_nuxt/components/CallbackPage.vue | 34 + frontend_nuxt/components/CommentEditor.vue | 178 +++ frontend_nuxt/components/CommentItem.vue | 302 +++++ frontend_nuxt/components/LevelProgress.vue | 59 + frontend_nuxt/components/LoginOverlay.vue | 85 ++ .../components/MilkTeaActivityComponent.vue | 247 ++++ .../components/NotificationContainer.vue | 63 + frontend_nuxt/components/PostEditor.vue | 150 +++ frontend_nuxt/components/ProgressBar.vue | 37 + frontend_nuxt/components/ReactionsGroup.vue | 308 +++++ frontend_nuxt/components/UserList.vue | 65 ++ frontend_nuxt/constants.js | 1 + frontend_nuxt/package-lock.json | 119 +- frontend_nuxt/package.json | 7 +- frontend_nuxt/pages/404.vue | 33 + frontend_nuxt/pages/about/index.vue | 122 ++ frontend_nuxt/pages/about/stats.vue | 53 + frontend_nuxt/pages/activities.vue | 169 +++ frontend_nuxt/pages/discord-callback.vue | 26 + frontend_nuxt/pages/forgot-password.vue | 175 +++ frontend_nuxt/pages/github-callback.vue | 26 + frontend_nuxt/pages/google-callback.vue | 27 + frontend_nuxt/pages/login.vue | 302 +++++ frontend_nuxt/pages/message.vue | 763 ++++++++++++ frontend_nuxt/pages/new-post.vue | 387 +++++++ frontend_nuxt/pages/posts/[id].vue | 1018 +++++++++++++++++ frontend_nuxt/pages/posts/[id]/edit.vue | 339 ++++++ frontend_nuxt/pages/settings.vue | 376 ++++++ frontend_nuxt/pages/signup-reason.vue | 142 +++ frontend_nuxt/pages/signup.vue | 412 +++++++ frontend_nuxt/pages/twitter-callback.vue | 26 + frontend_nuxt/pages/users/[id].vue | 819 +++++++++++++ frontend_nuxt/router/index.js | 7 + frontend_nuxt/utils/clearVditorStorage.js | 7 + frontend_nuxt/utils/discord.js | 62 + frontend_nuxt/utils/github.js | 62 + frontend_nuxt/utils/google.js | 79 ++ frontend_nuxt/utils/level.js | 7 + frontend_nuxt/utils/markdown.js | 75 +- frontend_nuxt/utils/push.js | 48 + frontend_nuxt/utils/reactions.js | 25 + frontend_nuxt/utils/tiebaEmoji.js | 11 + frontend_nuxt/utils/twitter.js | 79 ++ frontend_nuxt/utils/user.js | 30 + frontend_nuxt/utils/vditor.js | 176 +++ 49 files changed, 7894 insertions(+), 5 deletions(-) create mode 100644 frontend_nuxt/components/AvatarCropper.vue create mode 100644 frontend_nuxt/components/BaseInput.vue create mode 100644 frontend_nuxt/components/BasePlaceholder.vue create mode 100644 frontend_nuxt/components/BaseTimeline.vue create mode 100644 frontend_nuxt/components/CallbackPage.vue create mode 100644 frontend_nuxt/components/CommentEditor.vue create mode 100644 frontend_nuxt/components/CommentItem.vue create mode 100644 frontend_nuxt/components/LevelProgress.vue create mode 100644 frontend_nuxt/components/LoginOverlay.vue create mode 100644 frontend_nuxt/components/MilkTeaActivityComponent.vue create mode 100644 frontend_nuxt/components/NotificationContainer.vue create mode 100644 frontend_nuxt/components/PostEditor.vue create mode 100644 frontend_nuxt/components/ProgressBar.vue create mode 100644 frontend_nuxt/components/ReactionsGroup.vue create mode 100644 frontend_nuxt/components/UserList.vue create mode 100644 frontend_nuxt/constants.js create mode 100644 frontend_nuxt/pages/404.vue create mode 100644 frontend_nuxt/pages/about/index.vue create mode 100644 frontend_nuxt/pages/about/stats.vue create mode 100644 frontend_nuxt/pages/activities.vue create mode 100644 frontend_nuxt/pages/discord-callback.vue create mode 100644 frontend_nuxt/pages/forgot-password.vue create mode 100644 frontend_nuxt/pages/github-callback.vue create mode 100644 frontend_nuxt/pages/google-callback.vue create mode 100644 frontend_nuxt/pages/login.vue create mode 100644 frontend_nuxt/pages/message.vue create mode 100644 frontend_nuxt/pages/new-post.vue create mode 100644 frontend_nuxt/pages/posts/[id].vue create mode 100644 frontend_nuxt/pages/posts/[id]/edit.vue create mode 100644 frontend_nuxt/pages/settings.vue create mode 100644 frontend_nuxt/pages/signup-reason.vue create mode 100644 frontend_nuxt/pages/signup.vue create mode 100644 frontend_nuxt/pages/twitter-callback.vue create mode 100644 frontend_nuxt/pages/users/[id].vue create mode 100644 frontend_nuxt/router/index.js create mode 100644 frontend_nuxt/utils/clearVditorStorage.js create mode 100644 frontend_nuxt/utils/discord.js create mode 100644 frontend_nuxt/utils/github.js create mode 100644 frontend_nuxt/utils/google.js create mode 100644 frontend_nuxt/utils/level.js create mode 100644 frontend_nuxt/utils/push.js create mode 100644 frontend_nuxt/utils/reactions.js create mode 100644 frontend_nuxt/utils/tiebaEmoji.js create mode 100644 frontend_nuxt/utils/twitter.js create mode 100644 frontend_nuxt/utils/user.js create mode 100644 frontend_nuxt/utils/vditor.js diff --git a/frontend_nuxt/components/AvatarCropper.vue b/frontend_nuxt/components/AvatarCropper.vue new file mode 100644 index 000000000..2cb0a1d62 --- /dev/null +++ b/frontend_nuxt/components/AvatarCropper.vue @@ -0,0 +1,142 @@ + + + + + + + + 取消 + 确定 + + + + + + + + + diff --git a/frontend_nuxt/components/BaseInput.vue b/frontend_nuxt/components/BaseInput.vue new file mode 100644 index 000000000..c596c86b6 --- /dev/null +++ b/frontend_nuxt/components/BaseInput.vue @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + diff --git a/frontend_nuxt/components/BasePlaceholder.vue b/frontend_nuxt/components/BasePlaceholder.vue new file mode 100644 index 000000000..fed94821d --- /dev/null +++ b/frontend_nuxt/components/BasePlaceholder.vue @@ -0,0 +1,34 @@ + + + + + {{ text }} + + + + + + + diff --git a/frontend_nuxt/components/BaseTimeline.vue b/frontend_nuxt/components/BaseTimeline.vue new file mode 100644 index 000000000..af39b4c6b --- /dev/null +++ b/frontend_nuxt/components/BaseTimeline.vue @@ -0,0 +1,103 @@ + + + + + + + {{ item.emoji }} + + + {{ item.content }} + + + + + + + + diff --git a/frontend_nuxt/components/CallbackPage.vue b/frontend_nuxt/components/CallbackPage.vue new file mode 100644 index 000000000..8fe8461c4 --- /dev/null +++ b/frontend_nuxt/components/CallbackPage.vue @@ -0,0 +1,34 @@ + + + + Magic is happening... + + + + + + diff --git a/frontend_nuxt/components/CommentEditor.vue b/frontend_nuxt/components/CommentEditor.vue new file mode 100644 index 000000000..fe3bc7dd8 --- /dev/null +++ b/frontend_nuxt/components/CommentEditor.vue @@ -0,0 +1,178 @@ + + + + + + + + + + 发布评论 + + + 发布中... + + + + + + + + + diff --git a/frontend_nuxt/components/CommentItem.vue b/frontend_nuxt/components/CommentItem.vue new file mode 100644 index 000000000..3eab5be1b --- /dev/null +++ b/frontend_nuxt/components/CommentItem.vue @@ -0,0 +1,302 @@ + + + + + + + {{ comment.userName }} + + + {{ comment.parentUserName }} + + {{ comment.time }} + + + + + + + + + + + + + + + + + + {{ replyCount }}条回复 + + + + + + + + + + + + + + + + diff --git a/frontend_nuxt/components/LevelProgress.vue b/frontend_nuxt/components/LevelProgress.vue new file mode 100644 index 000000000..d361f79c1 --- /dev/null +++ b/frontend_nuxt/components/LevelProgress.vue @@ -0,0 +1,59 @@ + + + 当前Lv.{{ currentLevel }} + + + {{ exp }} / {{ nextExp }} + 🎉目标 Lv.{{ currentLevel + 1 }} + + + + + + + diff --git a/frontend_nuxt/components/LoginOverlay.vue b/frontend_nuxt/components/LoginOverlay.vue new file mode 100644 index 000000000..55d754f4a --- /dev/null +++ b/frontend_nuxt/components/LoginOverlay.vue @@ -0,0 +1,85 @@ + + + + + + + 请先登录,点击跳转到登录页面 + + + 登录 + + + + + + + + diff --git a/frontend_nuxt/components/MilkTeaActivityComponent.vue b/frontend_nuxt/components/MilkTeaActivityComponent.vue new file mode 100644 index 000000000..b9231fcc4 --- /dev/null +++ b/frontend_nuxt/components/MilkTeaActivityComponent.vue @@ -0,0 +1,247 @@ + + + + + + 升级规则说明 + + + 回复帖子每次10exp,最多3次每天 + 发布帖子每次30exp,最多1次每天 + 发表情每次5exp,最多3次每天 + + + + + 🔥 已兑换奶茶人数 + + 当前 {{ info.redeemCount }} / 50 + + + + 加载当前等级中... + + + + + + 请登录查看自身等级 + + + 兑换 + 兑换 + + + + + 提交 + 取消 + + + + + + + + + + diff --git a/frontend_nuxt/components/NotificationContainer.vue b/frontend_nuxt/components/NotificationContainer.vue new file mode 100644 index 000000000..2bb645986 --- /dev/null +++ b/frontend_nuxt/components/NotificationContainer.vue @@ -0,0 +1,63 @@ + + + + + + + + {{ isMobile ? 'OK' : '标记为已读' }} + + 已读 + + + + + + + diff --git a/frontend_nuxt/components/PostEditor.vue b/frontend_nuxt/components/PostEditor.vue new file mode 100644 index 000000000..63644222d --- /dev/null +++ b/frontend_nuxt/components/PostEditor.vue @@ -0,0 +1,150 @@ + + + + + + + + + + + + diff --git a/frontend_nuxt/components/ProgressBar.vue b/frontend_nuxt/components/ProgressBar.vue new file mode 100644 index 000000000..bc9cdd2a3 --- /dev/null +++ b/frontend_nuxt/components/ProgressBar.vue @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/frontend_nuxt/components/ReactionsGroup.vue b/frontend_nuxt/components/ReactionsGroup.vue new file mode 100644 index 000000000..2398c22be --- /dev/null +++ b/frontend_nuxt/components/ReactionsGroup.vue @@ -0,0 +1,308 @@ + + + + + + {{ reactionEmojiMap[r.type] }} + {{ totalCount }} + + + + 点击以表态 + + + + + + + + {{ likeCount }} + + + + + + {{ reactionEmojiMap[t] }}{{ counts[t] }} + + + + + + + + diff --git a/frontend_nuxt/components/UserList.vue b/frontend_nuxt/components/UserList.vue new file mode 100644 index 000000000..38a0dfbbf --- /dev/null +++ b/frontend_nuxt/components/UserList.vue @@ -0,0 +1,65 @@ + + + + + + + {{ u.username }} + {{ u.introduction }} + + + + + + + + diff --git a/frontend_nuxt/constants.js b/frontend_nuxt/constants.js new file mode 100644 index 000000000..33ff557e3 --- /dev/null +++ b/frontend_nuxt/constants.js @@ -0,0 +1 @@ +export const WEBSITE_BASE_URL = 'https://www.open-isle.com' diff --git a/frontend_nuxt/package-lock.json b/frontend_nuxt/package-lock.json index 4ec1d54d7..6e645df4b 100644 --- a/frontend_nuxt/package-lock.json +++ b/frontend_nuxt/package-lock.json @@ -6,10 +6,15 @@ "": { "name": "frontend_nuxt", "dependencies": { + "cropperjs": "^1.6.2", + "echarts": "^5.6.0", "highlight.js": "^11.11.1", "ldrs": "^1.0.0", "markdown-it": "^14.1.0", - "nuxt": "latest" + "nuxt": "latest", + "vditor": "^3.11.1", + "vue-easy-lightbox": "^1.19.0", + "vue-echarts": "^7.0.3" } }, "node_modules/@ampproject/remapping": { @@ -3455,6 +3460,12 @@ "node": ">=18.0" } }, + "node_modules/cropperjs": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz", + "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "license": "MIT", @@ -3907,6 +3918,12 @@ "node": ">=0.3.1" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/dom-serializer": { "version": "2.0.0", "license": "MIT", @@ -3997,6 +4014,22 @@ "version": "0.2.0", "license": "MIT" }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/ee-first": { "version": "1.1.1", "license": "MIT" @@ -9787,6 +9820,18 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vditor": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/vditor/-/vditor-3.11.1.tgz", + "integrity": "sha512-7rjNSXYVyZG0mVZpUG2tfxwnoNtkcRCnwdSju+Zvpjf/r72iQa6kLpeThFMIKPuQ5CRnQQv6gnR3eNU6UGbC2Q==", + "license": "MIT", + "dependencies": { + "diff-match-patch": "^1.0.5" + }, + "funding": { + "url": "https://ld246.com/sponsor" + } + }, "node_modules/vite": { "version": "7.1.0", "license": "MIT", @@ -10113,10 +10158,67 @@ "ufo": "^1.6.1" } }, + "node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/vue-devtools-stub": { "version": "0.1.0", "license": "MIT" }, + "node_modules/vue-easy-lightbox": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/vue-easy-lightbox/-/vue-easy-lightbox-1.19.0.tgz", + "integrity": "sha512-YxLXgjEn91UF3DuK1y8u3Pyx2sJ7a/MnBpkyrBSQkvU1glzEJASyAZ7N+5yDpmxBQDVMwCsL2VmxWGIiFrWCgA==", + "license": "MIT", + "engines": { + "node": ">=14.18.3" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-echarts": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-7.0.3.tgz", + "integrity": "sha512-/jSxNwOsw5+dYAUcwSfkLwKPuzTQ0Cepz1LxCOpj2QcHrrmUa/Ql0eQqMmc1rTPQVrh2JQ29n2dhq75ZcHvRDw==", + "license": "MIT", + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/runtime-core": "^3.0.0", + "echarts": "^5.5.1", + "vue": "^2.7.0 || ^3.1.1" + }, + "peerDependenciesMeta": { + "@vue/runtime-core": { + "optional": true + } + } + }, "node_modules/vue-router": { "version": "4.5.1", "license": "MIT", @@ -10505,6 +10607,21 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/frontend_nuxt/package.json b/frontend_nuxt/package.json index 04c737dff..1229fbb44 100644 --- a/frontend_nuxt/package.json +++ b/frontend_nuxt/package.json @@ -11,6 +11,11 @@ "highlight.js": "^11.11.1", "ldrs": "^1.0.0", "markdown-it": "^14.1.0", - "nuxt": "latest" + "nuxt": "latest", + "cropperjs": "^1.6.2", + "echarts": "^5.6.0", + "vue-echarts": "^7.0.3", + "vue-easy-lightbox": "^1.19.0", + "vditor": "^3.11.1" } } diff --git a/frontend_nuxt/pages/404.vue b/frontend_nuxt/pages/404.vue new file mode 100644 index 000000000..5b623cafe --- /dev/null +++ b/frontend_nuxt/pages/404.vue @@ -0,0 +1,33 @@ + + + 404 - 页面不存在 + 你访问的页面不存在或已被删除 + 返回首页 + + + + + + diff --git a/frontend_nuxt/pages/about/index.vue b/frontend_nuxt/pages/about/index.vue new file mode 100644 index 000000000..669218787 --- /dev/null +++ b/frontend_nuxt/pages/about/index.vue @@ -0,0 +1,122 @@ + + + + + {{ tab.label }} + + + + + + + + + + + + diff --git a/frontend_nuxt/pages/about/stats.vue b/frontend_nuxt/pages/about/stats.vue new file mode 100644 index 000000000..f0c1904b1 --- /dev/null +++ b/frontend_nuxt/pages/about/stats.vue @@ -0,0 +1,53 @@ + + + + + + + + + diff --git a/frontend_nuxt/pages/activities.vue b/frontend_nuxt/pages/activities.vue new file mode 100644 index 000000000..c5054c293 --- /dev/null +++ b/frontend_nuxt/pages/activities.vue @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + {{ a.title }} + 已结束 + 进行中 + + {{ a.content }} + + + + + + + + + + + diff --git a/frontend_nuxt/pages/discord-callback.vue b/frontend_nuxt/pages/discord-callback.vue new file mode 100644 index 000000000..27316b431 --- /dev/null +++ b/frontend_nuxt/pages/discord-callback.vue @@ -0,0 +1,26 @@ + + + + + + diff --git a/frontend_nuxt/pages/forgot-password.vue b/frontend_nuxt/pages/forgot-password.vue new file mode 100644 index 000000000..3afb2e8c5 --- /dev/null +++ b/frontend_nuxt/pages/forgot-password.vue @@ -0,0 +1,175 @@ + + + + 找回密码 + + + {{ emailError }} + 发送验证码 + 发送中... + + + + 验证 + 验证中... + + + + {{ passwordError }} + 重置密码 + 提交中... + + + + + + + + diff --git a/frontend_nuxt/pages/github-callback.vue b/frontend_nuxt/pages/github-callback.vue new file mode 100644 index 000000000..873bb6ba0 --- /dev/null +++ b/frontend_nuxt/pages/github-callback.vue @@ -0,0 +1,26 @@ + + + + + + diff --git a/frontend_nuxt/pages/google-callback.vue b/frontend_nuxt/pages/google-callback.vue new file mode 100644 index 000000000..2a5efef19 --- /dev/null +++ b/frontend_nuxt/pages/google-callback.vue @@ -0,0 +1,27 @@ + + + + + + diff --git a/frontend_nuxt/pages/login.vue b/frontend_nuxt/pages/login.vue new file mode 100644 index 000000000..26d926a90 --- /dev/null +++ b/frontend_nuxt/pages/login.vue @@ -0,0 +1,302 @@ + + + + + + Welcome :) + + + + + + + + + + + 登录 + + + + + + 登录中... + + + + 没有账号? 注册 | + 找回密码 + + + + + + + + Google 登录 + + + + GitHub 登录 + + + + Discord 登录 + + + + Twitter 登录 + + + + + + + + \ No newline at end of file diff --git a/frontend_nuxt/pages/message.vue b/frontend_nuxt/pages/message.vue new file mode 100644 index 000000000..af15a4c2d --- /dev/null +++ b/frontend_nuxt/pages/message.vue @@ -0,0 +1,763 @@ + + + + + 消息 + 未读 + + + + + + + + 已读所有消息 + + + + + + + + + + + + + + + + + + + + {{ item.comment.author.username }} 对我的评论 + + + {{ stripMarkdownLength(item.parentComment.content, 100) }} + + 回复了 + + {{ stripMarkdownLength(item.comment.content, 100) }} + + + + + + + {{ item.comment.author.username }} 对我的文章 + + + {{ stripMarkdownLength(item.post.title, 100) }} + + 回复了 + + {{ stripMarkdownLength(item.comment.content, 100) }} + + + + + + + {{ item.fromUser.username }} 申请进行奶茶兑换,联系方式是:{{ item.content }} + + + + + {{ item.fromUser.username }} 对我的文章 + + + {{ stripMarkdownLength(item.post.title, 100) }} + + + 进行了表态 + + + + + {{ item.fromUser.username }} 对我的评论 + + + {{ stripMarkdownLength(item.comment.content, 100) }} + + + 进行了表态 + + + + + + {{ item.fromUser.username }} + + 查看了您的帖子 + + {{ stripMarkdownLength(item.post.title, 100) }} + + + + + + 您关注的帖子 + + {{ stripMarkdownLength(item.post.title, 100) }} + + 下面有新评论 + + {{ stripMarkdownLength(item.comment.content, 100) }} + + + + + + 你关注的 + + {{ item.comment.author.username }} + + 在 对评论 + + {{ stripMarkdownLength(item.parentComment.content, 100) }} + + 回复了 + + {{ stripMarkdownLength(item.comment.content, 100) }} + + + + + + 你关注的 + + {{ item.comment.author.username }} + + 在文章 + + {{ stripMarkdownLength(item.post.title, 100) }} + + 下面评论了 + + {{ stripMarkdownLength(item.comment.content, 100) }} + + + + + + + {{ item.fromUser.username }} + + 在评论中提到了你: + + {{ stripMarkdownLength(item.comment.content, 100) }} + + + + + + + {{ item.fromUser.username }} + + 在帖子 + + {{ stripMarkdownLength(item.post.title, 100) }} + + 中提到了你 + + + + + + {{ item.fromUser.username }} + + 开始关注你了 + + + + + + {{ item.fromUser.username }} + + 取消关注你了 + + + + + 你关注的 + + {{ item.fromUser.username }} + + 发布了文章 + + {{ stripMarkdownLength(item.post.title, 100) }} + + + + + + + {{ item.fromUser.username }} + + 订阅了你的文章 + + {{ stripMarkdownLength(item.post.title, 100) }} + + + + + + + {{ item.fromUser.username }} + + 取消订阅了你的文章 + + {{ stripMarkdownLength(item.post.title, 100) }} + + + + + + + {{ item.fromUser.username }} + + 发布了帖子 + + {{ stripMarkdownLength(item.post.title, 100) }} + + ,请审核 + + + + + 您发布的帖子 + + {{ stripMarkdownLength(item.post.title, 100) }} + + 已提交审核 + + + + + {{ item.fromUser.username }} 希望注册为会员,理由是:{{ item.content }} + + + 同意 + 拒绝 + + 已读 + + + + + + 您发布的帖子 + + {{ stripMarkdownLength(item.post.title, 100) }} + + 已审核通过 + + + + + 您发布的帖子 + + {{ stripMarkdownLength(item.post.title, 100) }} + + 已被管理员拒绝 + + + + + {{ formatType(item.type) }} + + + + {{ TimeManager.format(item.createdAt) }} + + + + + + + + + + diff --git a/frontend_nuxt/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue new file mode 100644 index 000000000..93f9b4211 --- /dev/null +++ b/frontend_nuxt/pages/new-post.vue @@ -0,0 +1,387 @@ + + + + + + + + + + + + + + + + 清空 + + + + md格式优化 + + + + 存草稿 + + 发布 + 发布中... + + + + + + + + + diff --git a/frontend_nuxt/pages/posts/[id].vue b/frontend_nuxt/pages/posts/[id].vue new file mode 100644 index 000000000..48ad47bae --- /dev/null +++ b/frontend_nuxt/pages/posts/[id].vue @@ -0,0 +1,1018 @@ + + + + + + + + + {{ title }} + + + + + + + + 审核中 + + + 已拒绝 + + + + 订阅文章 + + + + 取消订阅 + + + + + + + + + + + + + + + + {{ author.username }} + {{ postTime }} + + + + + + {{ author.username }} + {{ postTime }} + + + + + + + + + + + + Sort by: + + + + + + + + + + + + + + + + + + + loading... + {{ scrollerTopTime }} + + + {{ currentIndex }}/{{ totalPosts }} + + loading... + {{ lastReplyTime }} + + + + + + + + diff --git a/frontend_nuxt/pages/posts/[id]/edit.vue b/frontend_nuxt/pages/posts/[id]/edit.vue new file mode 100644 index 000000000..a8f89a431 --- /dev/null +++ b/frontend_nuxt/pages/posts/[id]/edit.vue @@ -0,0 +1,339 @@ + + + + + + + + + + + + + + + + 清空 + + + + md格式优化 + + + 取消 + + 更新 + 更新中... + + + + + + + + + diff --git a/frontend_nuxt/pages/settings.vue b/frontend_nuxt/pages/settings.vue new file mode 100644 index 000000000..089ef7a77 --- /dev/null +++ b/frontend_nuxt/pages/settings.vue @@ -0,0 +1,376 @@ + + + + + + + + 个人资料设置 + + + + + + + + 更换头像 + + + + + + 用户名是你在社区的唯一标识 + {{ usernameError }} + + + 自我介绍 + + 自我介绍会出现在你的个人主页,可以简要介绍自己 + + + + 管理员设置 + + 发布规则 + + + + 密码强度 + + + + AI 优化次数 + + + + 注册模式 + + + + + 保存中... + 保存 + + + + + + + + diff --git a/frontend_nuxt/pages/signup-reason.vue b/frontend_nuxt/pages/signup-reason.vue new file mode 100644 index 000000000..1bd4183e4 --- /dev/null +++ b/frontend_nuxt/pages/signup-reason.vue @@ -0,0 +1,142 @@ + + + + 请填写注册理由 + + 为了我们社区的良性发展,请填写注册理由,我们将根据你的理由审核你的注册, 谢谢! + + + + {{ reason.length }}/20 + + {{ error }} + 提交 + 提交中... + + + + + + + diff --git a/frontend_nuxt/pages/signup.vue b/frontend_nuxt/pages/signup.vue new file mode 100644 index 000000000..0868b5923 --- /dev/null +++ b/frontend_nuxt/pages/signup.vue @@ -0,0 +1,412 @@ + + + + + + Welcome :) + + + + + + {{ emailError }} + + + {{ usernameError }} + + + {{ passwordError }} + + + + 验证邮箱 + + + + + 发送中... + + + + 已经有账号? 登录 + + + + + + 注册 + + + + + 验证中... + + + + + + + + + Google 注册 + + + + GitHub 注册 + + + + Discord 注册 + + + + Twitter 注册 + + + + + + + + \ No newline at end of file diff --git a/frontend_nuxt/pages/twitter-callback.vue b/frontend_nuxt/pages/twitter-callback.vue new file mode 100644 index 000000000..940d49d53 --- /dev/null +++ b/frontend_nuxt/pages/twitter-callback.vue @@ -0,0 +1,26 @@ + + + + + + diff --git a/frontend_nuxt/pages/users/[id].vue b/frontend_nuxt/pages/users/[id].vue new file mode 100644 index 000000000..672e8d463 --- /dev/null +++ b/frontend_nuxt/pages/users/[id].vue @@ -0,0 +1,819 @@ + + + + + + + + + + + + + {{ user.username }} + {{ user.introduction }} + + + 关注 + + + + 取消关注 + + + + 目标 Lv.{{ levelInfo.currentLevel + 1 }} + + + + + + + + 加入时间: + {{ formatDate(user.createdAt) }} + + + 最后发帖时间: + {{ formatDate(user.lastPostTime) }} + + + 最后评论时间: + {{ user.lastCommentTime!=null?formatDate(user.lastCommentTime):"暂无评论" }} + + + 浏览量: + {{ user.totalViews }} + + + + + + + 总结 + + + + 时间线 + + + + 关注 + + + + + + + + + + 统计信息 + + + 访问天数 + {{ user.visitedDays }} + + + 已读帖子 + {{ user.readPosts }} + + + 已送出的💗 + {{ user.likesSent }} + + + 已收到的💗 + {{ user.likesReceived }} + + + + + + 热门回复 + + + + 在 + + {{ item.comment.post.title }} + + + 下对 + + {{ stripMarkdownLength(item.comment.parentComment.content, 200) }} + + 回复了 + + + 下评论了 + + + {{ stripMarkdownLength(item.comment.content, 200) }} + + + {{ formatDate(item.comment.createdAt) }} + + + + + + 暂无热门回复 + + + + 热门话题 + + + + + {{ item.post.title }} + + + {{ stripMarkdown(item.post.snippet) }} + + + {{ formatDate(item.post.createdAt) }} + + + + + + 暂无热门话题 + + + + TA创建的tag + + + + + {{ item.tag.name }} x{{ item.tag.count }} + + + {{ item.tag.description }} + + + {{ formatDate(item.tag.createdAt) }} + + + + + + 暂无标签 + + + + + + + + + + + 发布了文章 + + {{ item.post.title }} + + {{ formatDate(item.createdAt) }} + + + 在 + + {{ item.comment.post.title }} + + 下评论了 + + {{ stripMarkdownLength(item.comment.content, 200) }} + + {{ formatDate(item.createdAt) }} + + + 在 + + {{ item.comment.post.title }} + + 下对 + + {{ stripMarkdownLength(item.comment.parentComment.content, 200) }} + + 回复了 + + {{ stripMarkdownLength(item.comment.content, 200) }} + + {{ formatDate(item.createdAt) }} + + + 创建了标签 + + {{ item.tag.name }} x{{ item.tag.count }} + + + {{ item.tag.description }} + + {{ formatDate(item.createdAt) }} + + + + + + + + 关注者 + + 正在关注 + + + + + + + + + + + + + + + + diff --git a/frontend_nuxt/router/index.js b/frontend_nuxt/router/index.js new file mode 100644 index 000000000..91b18fb46 --- /dev/null +++ b/frontend_nuxt/router/index.js @@ -0,0 +1,7 @@ +export default { + push(path) { + if (process.client) { + window.location.href = path + } + } +} diff --git a/frontend_nuxt/utils/clearVditorStorage.js b/frontend_nuxt/utils/clearVditorStorage.js new file mode 100644 index 000000000..41c42f9e3 --- /dev/null +++ b/frontend_nuxt/utils/clearVditorStorage.js @@ -0,0 +1,7 @@ +export function clearVditorStorage() { + Object.keys(localStorage).forEach(key => { + if (key.startsWith('vditoreditor-') || key === 'vditor') { + localStorage.removeItem(key) + } + }) +} diff --git a/frontend_nuxt/utils/discord.js b/frontend_nuxt/utils/discord.js new file mode 100644 index 000000000..521ecc8fa --- /dev/null +++ b/frontend_nuxt/utils/discord.js @@ -0,0 +1,62 @@ +import { API_BASE_URL, DISCORD_CLIENT_ID, toast } from '../main' +import { WEBSITE_BASE_URL } from '../constants' +import { setToken, loadCurrentUser } from './auth' +import { registerPush } from './push' + +export function discordAuthorize(state = '') { + if (!DISCORD_CLIENT_ID) { + toast.error('Discord 登录不可用') + return + } + const redirectUri = `${WEBSITE_BASE_URL}/discord-callback` + const url = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify%20email&state=${state}` + window.location.href = url +} + +export async function discordExchange(code, state, reason) { + try { + const res = await fetch(`${API_BASE_URL}/api/auth/discord`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, redirectUri: `${window.location.origin}/discord-callback`, reason, state }) + }) + const data = await res.json() + if (res.ok && data.token) { + setToken(data.token) + await loadCurrentUser() + toast.success('登录成功') + registerPush() + return { + success: true, + needReason: false + } + } else if (data.reason_code === 'NOT_APPROVED') { + toast.info('当前为注册审核模式,请填写注册理由') + return { + success: false, + needReason: true, + token: data.token + } + } else if (data.reason_code === 'IS_APPROVING') { + toast.info('您的注册理由正在审批中') + return { + success: true, + needReason: false + } + } else { + toast.error(data.error || '登录失败') + return { + success: false, + needReason: false, + error: data.error || '登录失败' + } + } + } catch (e) { + toast.error('登录失败') + return { + success: false, + needReason: false, + error: '登录失败' + } + } +} diff --git a/frontend_nuxt/utils/github.js b/frontend_nuxt/utils/github.js new file mode 100644 index 000000000..cca908654 --- /dev/null +++ b/frontend_nuxt/utils/github.js @@ -0,0 +1,62 @@ +import { API_BASE_URL, GITHUB_CLIENT_ID, toast } from '../main' +import { setToken, loadCurrentUser } from './auth' +import { WEBSITE_BASE_URL } from '../constants' +import { registerPush } from './push' + +export function githubAuthorize(state = '') { + if (!GITHUB_CLIENT_ID) { + toast.error('GitHub 登录不可用') + return + } + const redirectUri = `${WEBSITE_BASE_URL}/github-callback` + const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${state}` + window.location.href = url +} + +export async function githubExchange(code, state, reason) { + try { + const res = await fetch(`${API_BASE_URL}/api/auth/github`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, redirectUri: `${window.location.origin}/github-callback`, reason, state }) + }) + const data = await res.json() + if (res.ok && data.token) { + setToken(data.token) + await loadCurrentUser() + toast.success('登录成功') + registerPush() + return { + success: true, + needReason: false + } + } else if (data.reason_code === 'NOT_APPROVED') { + toast.info('当前为注册审核模式,请填写注册理由') + return { + success: false, + needReason: true, + token: data.token + } + } else if (data.reason_code === 'IS_APPROVING') { + toast.info('您的注册理由正在审批中') + return { + success: true, + needReason: false + } + } else { + toast.error(data.error || '登录失败') + return { + success: false, + needReason: false, + error: data.error || '登录失败' + } + } + } catch (e) { + toast.error('登录失败') + return { + success: false, + needReason: false, + error: '登录失败' + } + } +} diff --git a/frontend_nuxt/utils/google.js b/frontend_nuxt/utils/google.js new file mode 100644 index 000000000..61ae67ccb --- /dev/null +++ b/frontend_nuxt/utils/google.js @@ -0,0 +1,79 @@ +import { API_BASE_URL, GOOGLE_CLIENT_ID, toast } from '../main' +import { setToken, loadCurrentUser } from './auth' +import { registerPush } from './push' +import { WEBSITE_BASE_URL } from '../constants' + +export async function googleGetIdToken() { + return new Promise((resolve, reject) => { + if (!window.google || !GOOGLE_CLIENT_ID) { + toast.error('Google 登录不可用, 请检查网络设置与VPN') + reject() + return + } + window.google.accounts.id.initialize({ + client_id: GOOGLE_CLIENT_ID, + callback: ({ credential }) => resolve(credential), + use_fedcm: true + }) + window.google.accounts.id.prompt() + }) +} + +export function googleAuthorize() { + if (!GOOGLE_CLIENT_ID) { + toast.error('Google 登录不可用, 请检查网络设置与VPN') + return + } + const redirectUri = `${WEBSITE_BASE_URL}/google-callback` + const nonce = Math.random().toString(36).substring(2) + const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=id_token&scope=openid%20email%20profile&nonce=${nonce}` + window.location.href = url +} + +export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) { + try { + const res = await fetch(`${API_BASE_URL}/api/auth/google`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ idToken }) + }) + const data = await res.json() + if (res.ok && data.token) { + setToken(data.token) + await loadCurrentUser() + toast.success('登录成功') + registerPush() + if (redirect_success) redirect_success() + } else if (data.reason_code === 'NOT_APPROVED') { + toast.info('当前为注册审核模式,请填写注册理由') + if (redirect_not_approved) redirect_not_approved(data.token) + } else if (data.reason_code === 'IS_APPROVING') { + toast.info('您的注册理由正在审批中') + if (redirect_success) redirect_success() + } + } catch (e) { + toast.error('登录失败') + } +} + +export async function googleSignIn(redirect_success, redirect_not_approved) { + try { + const token = await googleGetIdToken() + await googleAuthWithToken(token, redirect_success, redirect_not_approved) + } catch { + /* ignore */ + } +} + +import router from '../router' + +export function loginWithGoogle() { + googleSignIn( + () => { + router.push('/') + }, + token => { + router.push('/signup-reason?token=' + token) + } + ) +} \ No newline at end of file diff --git a/frontend_nuxt/utils/level.js b/frontend_nuxt/utils/level.js new file mode 100644 index 000000000..6f1b88daa --- /dev/null +++ b/frontend_nuxt/utils/level.js @@ -0,0 +1,7 @@ +export const LEVEL_EXP = [100, 200, 300, 600, 1200, 10000] + +export const prevLevelExp = level => { + if (level <= 0) return 0 + if (level - 1 < LEVEL_EXP.length) return LEVEL_EXP[level - 1] + return LEVEL_EXP[LEVEL_EXP.length - 1] +} diff --git a/frontend_nuxt/utils/markdown.js b/frontend_nuxt/utils/markdown.js index 1f45c7761..dbcd352b7 100644 --- a/frontend_nuxt/utils/markdown.js +++ b/frontend_nuxt/utils/markdown.js @@ -1,6 +1,54 @@ import MarkdownIt from 'markdown-it' import hljs from 'highlight.js' import 'highlight.js/styles/github.css' +import { toast } from '../main' +import { tiebaEmoji } from './tiebaEmoji' + +function mentionPlugin(md) { + const mentionReg = /^@\[([^\]]+)\]/ + function mention(state, silent) { + const pos = state.pos + if (state.src.charCodeAt(pos) !== 0x40) return false + const match = mentionReg.exec(state.src.slice(pos)) + if (!match) return false + if (!silent) { + const tokenOpen = state.push('link_open', 'a', 1) + tokenOpen.attrs = [ + ['href', `/users/${match[1]}`], + ['target', '_blank'], + ['class', 'mention-link'] + ] + const text = state.push('text', '', 0) + text.content = `@${match[1]}` + state.push('link_close', 'a', -1) + } + state.pos += match[0].length + return true + } + md.inline.ruler.before('emphasis', 'mention', mention) +} + +function tiebaEmojiPlugin(md) { + md.renderer.rules['tieba-emoji'] = (tokens, idx) => { + const name = tokens[idx].content + const file = tiebaEmoji[name] + return `` + } + md.inline.ruler.before('emphasis', 'tieba-emoji', (state, silent) => { + const pos = state.pos + if (state.src.charCodeAt(pos) !== 0x3a) return false + const match = state.src.slice(pos).match(/^:tieba(\d+):/) + if (!match) return false + const key = `tieba${match[1]}` + if (!tiebaEmoji[key]) return false + if (!silent) { + const token = state.push('tieba-emoji', '', 0) + token.content = key + } + state.pos += match[0].length + return true + }) +} const md = new MarkdownIt({ html: false, @@ -17,10 +65,30 @@ const md = new MarkdownIt({ } }) -// todo: 简单用正则操作一下,后续体验不佳可以采用 striptags +md.use(mentionPlugin) +md.use(tiebaEmojiPlugin) + +export function renderMarkdown(text) { + return md.render(text || '') +} + +export function handleMarkdownClick(e) { + if (e.target.classList.contains('copy-code-btn')) { + const pre = e.target.closest('pre') + const codeEl = pre && pre.querySelector('code') + if (codeEl) { + navigator.clipboard.writeText(codeEl.innerText).then(() => { + toast.success('已复制') + }) + } + } +} + export function stripMarkdown(text) { - const html = md.render(text) - return html.replace(/<\/?[^>]+>/g, '') + const html = md.render(text || '') + const el = document.createElement('div') + el.innerHTML = html + return el.textContent || el.innerText || '' } export function stripMarkdownLength(text, length) { @@ -28,5 +96,6 @@ export function stripMarkdownLength(text, length) { if (!length || plain.length <= length) { return plain } + // 截断并加省略号 return plain.slice(0, length) + '...' } diff --git a/frontend_nuxt/utils/push.js b/frontend_nuxt/utils/push.js new file mode 100644 index 000000000..201b8cc60 --- /dev/null +++ b/frontend_nuxt/utils/push.js @@ -0,0 +1,48 @@ +import { API_BASE_URL } from '../main' +import { getToken } from './auth' + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4) + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') + const rawData = atob(base64) + const outputArray = new Uint8Array(rawData.length) + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i) + } + return outputArray +} + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer) + let binary = '' + for (const b of bytes) binary += String.fromCharCode(b) + return btoa(binary) +} + +export async function registerPush() { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) return + try { + const reg = await navigator.serviceWorker.register('/notifications-sw.js') + const res = await fetch(`${API_BASE_URL}/api/push/public-key`) + if (!res.ok) return + const { key } = await res.json() + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(key) + }) + await fetch(`${API_BASE_URL}/api/push/subscribe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${getToken()}` + }, + body: JSON.stringify({ + endpoint: sub.endpoint, + p256dh: arrayBufferToBase64(sub.getKey('p256dh')), + auth: arrayBufferToBase64(sub.getKey('auth')) + }) + }) + } catch (e) { + // ignore + } +} diff --git a/frontend_nuxt/utils/reactions.js b/frontend_nuxt/utils/reactions.js new file mode 100644 index 000000000..7fa967de7 --- /dev/null +++ b/frontend_nuxt/utils/reactions.js @@ -0,0 +1,25 @@ +export const reactionEmojiMap = { + LIKE: '❤️', + DISLIKE: '👎', + RECOMMEND: '👏', + ANGRY: '😡', + FLUSHED: '😳', + STAR_STRUCK: '🤩', + ROFL: '🤣', + HOLDING_BACK_TEARS: '🥹', + MIND_BLOWN: '🤯', + POOP: '💩', + CLOWN: '🤡', + SKULL: '☠️', + FIRE: '🔥', + EYES: '👀', + FROWN: '☹️', + HOT: '🥵', + EAGLE: '🦅', + SPIDER: '🕷️', + BAT: '🦇', + CHINA: '🇨🇳', + USA: '🇺🇸', + JAPAN: '🇯🇵', + KOREA: '🇰🇷' +} diff --git a/frontend_nuxt/utils/tiebaEmoji.js b/frontend_nuxt/utils/tiebaEmoji.js new file mode 100644 index 000000000..ce91ea0ff --- /dev/null +++ b/frontend_nuxt/utils/tiebaEmoji.js @@ -0,0 +1,11 @@ +export const TIEBA_EMOJI_CDN = 'https://cdn.jsdelivr.net/gh/microlong666/tieba_mobile_emotions@master/' +// export const TIEBA_EMOJI_CDN = 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor/dist/images/emoji/' + +export const tiebaEmoji = (() => { + const map = { tieba1: TIEBA_EMOJI_CDN + 'image_emoticon.png' } + for (let i = 2; i <= 124; i++) { + if (i > 50 && i < 62) continue + map[`tieba${i}`] = TIEBA_EMOJI_CDN + `image_emoticon${i}.png` + } + return map +})() diff --git a/frontend_nuxt/utils/twitter.js b/frontend_nuxt/utils/twitter.js new file mode 100644 index 000000000..8362d17af --- /dev/null +++ b/frontend_nuxt/utils/twitter.js @@ -0,0 +1,79 @@ +import { API_BASE_URL, TWITTER_CLIENT_ID, toast } from '../main' +import { WEBSITE_BASE_URL } from '../constants' +import { setToken, loadCurrentUser } from './auth' +import { registerPush } from './push' + +function generateCodeVerifier() { + const array = new Uint8Array(32) + window.crypto.getRandomValues(array) + return Array.from(array) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +async function generateCodeChallenge(codeVerifier) { + const encoder = new TextEncoder() + const data = encoder.encode(codeVerifier) + const digest = await window.crypto.subtle.digest('SHA-256', data) + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +export async function twitterAuthorize(state = '') { + if (!TWITTER_CLIENT_ID) { + toast.error('Twitter 登录不可用') + return + } + if (state === '') { + state = Math.random().toString(36).substring(2, 15) + } + const redirectUri = `${WEBSITE_BASE_URL}/twitter-callback` + const codeVerifier = generateCodeVerifier() + sessionStorage.setItem('twitter_code_verifier', codeVerifier) + const codeChallenge = await generateCodeChallenge(codeVerifier) + const url = + `https://x.com/i/oauth2/authorize?response_type=code&client_id=${TWITTER_CLIENT_ID}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=tweet.read%20users.read` + + `&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256` + window.location.href = url +} + +export async function twitterExchange(code, state, reason) { + try { + const codeVerifier = sessionStorage.getItem('twitter_code_verifier') + sessionStorage.removeItem('twitter_code_verifier') + const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code, + redirectUri: `${window.location.origin}/twitter-callback`, + reason, + state, + codeVerifier + }) + }) + const data = await res.json() + if (res.ok && data.token) { + setToken(data.token) + await loadCurrentUser() + toast.success('登录成功') + registerPush() + return { success: true, needReason: false } + } else if (data.reason_code === 'NOT_APPROVED') { + toast.info('当前为注册审核模式,请填写注册理由') + return { success: false, needReason: true, token: data.token } + } else if (data.reason_code === 'IS_APPROVING') { + toast.info('您的注册理由正在审批中') + return { success: true, needReason: false } + } else { + toast.error(data.error || '登录失败') + return { success: false, needReason: false, error: data.error || '登录失败' } + } + } catch (e) { + toast.error('登录失败') + return { success: false, needReason: false, error: '登录失败' } + } +} diff --git a/frontend_nuxt/utils/user.js b/frontend_nuxt/utils/user.js new file mode 100644 index 000000000..a7469067e --- /dev/null +++ b/frontend_nuxt/utils/user.js @@ -0,0 +1,30 @@ +import { API_BASE_URL } from '../main' + +export async function fetchFollowings(username) { + if (!username) return [] + try { + const res = await fetch(`${API_BASE_URL}/api/users/${username}/following`) + return res.ok ? await res.json() : [] + } catch (e) { + return [] + } +} + +export async function fetchAdmins() { + try { + const res = await fetch(`${API_BASE_URL}/api/users/admins`) + return res.ok ? await res.json() : [] + } catch (e) { + return [] + } +} + +export async function searchUsers(keyword) { + if (!keyword) return [] + try { + const res = await fetch(`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`) + return res.ok ? await res.json() : [] + } catch (e) { + return [] + } +} diff --git a/frontend_nuxt/utils/vditor.js b/frontend_nuxt/utils/vditor.js new file mode 100644 index 000000000..93564b7d0 --- /dev/null +++ b/frontend_nuxt/utils/vditor.js @@ -0,0 +1,176 @@ +import Vditor from 'vditor' +import 'vditor/dist/index.css' +import { API_BASE_URL } from '../main' +import { getToken, authState } from './auth' +import { searchUsers, fetchFollowings, fetchAdmins } from './user' +import { tiebaEmoji } from './tiebaEmoji' + +export function getEditorTheme() { + return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic' +} + +export function getPreviewTheme() { + return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light' +} + +export function createVditor(editorId, options = {}) { + const { + placeholder = '', + preview = {}, + input, + after + } = options + + const fetchMentions = async (value) => { + if (!value) { + const [followings, admins] = await Promise.all([ + fetchFollowings(authState.username), + fetchAdmins() + ]) + const combined = [...followings, ...admins] + const seen = new Set() + return combined.filter(u => { + if (seen.has(u.id)) return false + seen.add(u.id) + return true + }) + } + return searchUsers(value) + } + + const isMobile = window.innerWidth <= 768 + const toolbar = isMobile + ? ['emoji', 'upload'] + : [ + 'emoji', + 'bold', + 'italic', + 'strike', + '|', + 'list', + 'line', + 'quote', + 'code', + 'inline-code', + '|', + 'undo', + 'redo', + '|', + 'link', + 'upload' + ] + + let vditor + vditor = new Vditor(editorId, { + placeholder, + height: 'auto', + theme: getEditorTheme(), + preview: Object.assign({ + theme: { current: getPreviewTheme() }, + }, preview), + hint: { + emoji: tiebaEmoji, + extend: [ + { + key: '@', + hint: async (key) => { + const list = await fetchMentions(key) + return list.map(u => ({ + value: `@[${u.username}]`, + html: ` @${u.username}` + })) + }, + }, + ], + }, + cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor', + toolbar, + upload: { + accept: 'image/*,video/*', + multiple: false, + handler: async (files) => { + const file = files[0] + vditor.tip('图片上传中', 0) + vditor.disabled() + const res = await fetch( + `${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`, + { headers: { Authorization: `Bearer ${getToken()}` } } + ) + if (!res.ok) { + vditor.enable() + vditor.tip('获取上传地址失败') + return '获取上传地址失败' + } + const info = await res.json() + const put = await fetch(info.uploadUrl, { method: 'PUT', body: file }) + if (!put.ok) { + vditor.enable() + vditor.tip('上传失败') + return '上传失败' + } + + const ext = file.name.split('.').pop().toLowerCase() + const imageExts = [ + 'apng', + 'bmp', + 'gif', + 'ico', + 'cur', + 'jpg', + 'jpeg', + 'jfif', + 'pjp', + 'pjpeg', + 'png', + 'svg', + 'webp' + ] + const audioExts = ['wav', 'mp3', 'ogg'] + let md + if (imageExts.includes(ext)) { + md = `` + } else if (audioExts.includes(ext)) { + md = `` + } else { + md = `[${file.name}](${info.fileUrl})` + } + vditor.insertValue(md + '\n') + vditor.enable() + vditor.tip('上传成功') + return null + } + }, + // upload: { + // fieldName: 'file', + // url: `${API_BASE_URL}/api/upload`, + // accept: 'image/*,video/*', + // multiple: false, + // headers: { Authorization: `Bearer ${getToken()}` }, + // format(files, responseText) { + // const res = JSON.parse(responseText) + // if (res.code === 0) { + // return JSON.stringify({ + // code: 0, + // msg: '', + // data: { + // errFiles: [], + // succMap: { [files[0].name]: res.data.url } + // } + // }) + // } else { + // return JSON.stringify({ + // code: 1, + // msg: '上传失败', + // data: { errFiles: files.map(f => f.name), succMap: {} } + // }) + // } + // } + // }, + toolbarConfig: { pin: true }, + cache: { enable: false }, + input, + after + }) + + return vditor +}
回复帖子每次10exp,最多3次每天
发布帖子每次30exp,最多1次每天
发表情每次5exp,最多3次每天
你访问的页面不存在或已被删除