chore: migrate legacy pages and utilities to nuxt

This commit is contained in:
Tim
2025-08-07 20:21:22 +08:00
parent 73b9dcf0cd
commit 565678f79a
49 changed files with 7894 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
export function clearVditorStorage() {
Object.keys(localStorage).forEach(key => {
if (key.startsWith('vditoreditor-') || key === 'vditor') {
localStorage.removeItem(key)
}
})
}

View File

@@ -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: '登录失败'
}
}
}

View File

@@ -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: '登录失败'
}
}
}

View File

@@ -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)
}
)
}

View File

@@ -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]
}

View File

@@ -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 `<img class="emoji" src="${file}" alt="${name}">`
}
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) + '...'
}

View File

@@ -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
}
}

View File

@@ -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: '🇰🇷'
}

View File

@@ -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
})()

View File

@@ -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: '登录失败' }
}
}

View File

@@ -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 []
}
}

View File

@@ -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: `<img src="${u.avatar}" /> @${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 = `![${file.name}](${info.fileUrl})`
} else if (audioExts.includes(ext)) {
md = `<audio controls="controls" src="${info.fileUrl}"></audio>`
} 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
}