mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-11 05:07:28 +08:00
优化目录结构
This commit is contained in:
95
frontend/src/utils/auth.js
Normal file
95
frontend/src/utils/auth.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { API_BASE_URL } from '../main'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const TOKEN_KEY = 'token'
|
||||
const USER_ID_KEY = 'userId'
|
||||
const USERNAME_KEY = 'username'
|
||||
const ROLE_KEY = 'role'
|
||||
|
||||
export const authState = reactive({
|
||||
loggedIn: false,
|
||||
userId: null,
|
||||
username: null,
|
||||
role: null
|
||||
})
|
||||
|
||||
authState.loggedIn = localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
|
||||
authState.userId = localStorage.getItem(USER_ID_KEY)
|
||||
authState.username = localStorage.getItem(USERNAME_KEY)
|
||||
authState.role = localStorage.getItem(ROLE_KEY)
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
authState.loggedIn = true
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
clearUserInfo()
|
||||
authState.loggedIn = false
|
||||
}
|
||||
|
||||
export function setUserInfo({ id, username }) {
|
||||
authState.userId = id
|
||||
authState.username = username
|
||||
if (arguments[0] && arguments[0].role) {
|
||||
authState.role = arguments[0].role
|
||||
localStorage.setItem(ROLE_KEY, arguments[0].role)
|
||||
}
|
||||
if (id !== undefined && id !== null) localStorage.setItem(USER_ID_KEY, id)
|
||||
if (username) localStorage.setItem(USERNAME_KEY, username)
|
||||
}
|
||||
|
||||
export function clearUserInfo() {
|
||||
localStorage.removeItem(USER_ID_KEY)
|
||||
localStorage.removeItem(USERNAME_KEY)
|
||||
localStorage.removeItem(ROLE_KEY)
|
||||
authState.userId = null
|
||||
authState.username = null
|
||||
authState.role = null
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser() {
|
||||
const token = getToken()
|
||||
if (!token) return null
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return await res.json()
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCurrentUser() {
|
||||
const user = await fetchCurrentUser()
|
||||
if (user) {
|
||||
setUserInfo({ id: user.id, username: user.username, role: user.role })
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export function isLogin() {
|
||||
return authState.loggedIn
|
||||
}
|
||||
|
||||
export async function checkToken() {
|
||||
const token = getToken()
|
||||
if (!token) return false
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
authState.loggedIn = res.ok
|
||||
return res.ok
|
||||
} catch (e) {
|
||||
authState.loggedIn = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
62
frontend/src/utils/discord.js
Normal file
62
frontend/src/utils/discord.js
Normal 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: '登录失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
62
frontend/src/utils/github.js
Normal file
62
frontend/src/utils/github.js
Normal 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: '登录失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
67
frontend/src/utils/google.js
Normal file
67
frontend/src/utils/google.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { API_BASE_URL, GOOGLE_CLIENT_ID, toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { registerPush } from './push'
|
||||
|
||||
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 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
7
frontend/src/utils/level.js
Normal file
7
frontend/src/utils/level.js
Normal 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]
|
||||
}
|
||||
77
frontend/src/utils/markdown.js
Normal file
77
frontend/src/utils/markdown.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import { toast } from '../main'
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
highlight: (str, lang) => {
|
||||
let code = ''
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
code = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
|
||||
} else {
|
||||
code = hljs.highlightAuto(str).value
|
||||
}
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><code class="hljs language-${lang || ''}">${code}</code></pre>`
|
||||
}
|
||||
})
|
||||
|
||||
md.use(mentionPlugin)
|
||||
|
||||
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 || '')
|
||||
const el = document.createElement('div')
|
||||
el.innerHTML = html
|
||||
return el.textContent || el.innerText || ''
|
||||
}
|
||||
|
||||
export function stripMarkdownLength(text, length) {
|
||||
const plain = stripMarkdown(text)
|
||||
if (!length || plain.length <= length) {
|
||||
return plain
|
||||
}
|
||||
// 截断并加省略号
|
||||
return plain.slice(0, length) + '...'
|
||||
}
|
||||
48
frontend/src/utils/notification.js
Normal file
48
frontend/src/utils/notification.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { API_BASE_URL } from '../main'
|
||||
import { getToken } from './auth'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const notificationState = reactive({
|
||||
unreadCount: 0
|
||||
})
|
||||
|
||||
export async function fetchUnreadCount() {
|
||||
try {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
notificationState.unreadCount = 0
|
||||
return 0
|
||||
}
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications/unread-count`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (!res.ok) {
|
||||
notificationState.unreadCount = 0
|
||||
return 0
|
||||
}
|
||||
const data = await res.json()
|
||||
notificationState.unreadCount = data.count
|
||||
return data.count
|
||||
} catch (e) {
|
||||
notificationState.unreadCount = 0
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function markNotificationsRead(ids) {
|
||||
try {
|
||||
const token = getToken()
|
||||
if (!token || !ids || ids.length === 0) return false
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications/read`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ids })
|
||||
})
|
||||
return res.ok
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
48
frontend/src/utils/push.js
Normal file
48
frontend/src/utils/push.js
Normal 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
|
||||
}
|
||||
}
|
||||
12
frontend/src/utils/screen.js
Normal file
12
frontend/src/utils/screen.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// reactive width value to watch window resize events
|
||||
const width = ref(window.innerWidth)
|
||||
|
||||
// update width on resize
|
||||
window.addEventListener('resize', () => {
|
||||
width.value = window.innerWidth
|
||||
})
|
||||
|
||||
// global computed property
|
||||
export const isMobile = computed(() => width.value <= 768)
|
||||
60
frontend/src/utils/theme.js
Normal file
60
frontend/src/utils/theme.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { reactive } from 'vue'
|
||||
import { toast } from '../main'
|
||||
|
||||
export const ThemeMode = {
|
||||
SYSTEM: 'system',
|
||||
LIGHT: 'light',
|
||||
DARK: 'dark'
|
||||
}
|
||||
|
||||
const THEME_KEY = 'theme-mode'
|
||||
|
||||
export const themeState = reactive({
|
||||
mode: ThemeMode.SYSTEM
|
||||
})
|
||||
|
||||
function apply(mode) {
|
||||
const root = document.documentElement
|
||||
if (mode === ThemeMode.SYSTEM) {
|
||||
root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
} else {
|
||||
root.dataset.theme = mode
|
||||
}
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
const saved = localStorage.getItem(THEME_KEY)
|
||||
if (saved && Object.values(ThemeMode).includes(saved)) {
|
||||
themeState.mode = saved
|
||||
}
|
||||
apply(themeState.mode)
|
||||
}
|
||||
|
||||
export function setTheme(mode) {
|
||||
if (!Object.values(ThemeMode).includes(mode)) return
|
||||
themeState.mode = mode
|
||||
localStorage.setItem(THEME_KEY, mode)
|
||||
apply(mode)
|
||||
}
|
||||
|
||||
export function cycleTheme() {
|
||||
const modes = [ThemeMode.SYSTEM, ThemeMode.LIGHT, ThemeMode.DARK]
|
||||
const index = modes.indexOf(themeState.mode)
|
||||
const next = modes[(index + 1) % modes.length]
|
||||
if (next === ThemeMode.SYSTEM) {
|
||||
toast.success('💻 已经切换到系统主题')
|
||||
} else if (next === ThemeMode.LIGHT) {
|
||||
toast.success('🌞 已经切换到明亮主题')
|
||||
} else {
|
||||
toast.success('🌙 已经切换到暗色主题')
|
||||
}
|
||||
setTheme(next)
|
||||
}
|
||||
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (themeState.mode === ThemeMode.SYSTEM) {
|
||||
apply(ThemeMode.SYSTEM)
|
||||
}
|
||||
})
|
||||
}
|
||||
32
frontend/src/utils/time.js
Normal file
32
frontend/src/utils/time.js
Normal file
@@ -0,0 +1,32 @@
|
||||
export default class TimeManager {
|
||||
static format(input) {
|
||||
const date = new Date(input)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const diffDays = Math.floor((startOfToday - startOfDate) / 86400000)
|
||||
|
||||
const hh = date.getHours().toString().padStart(2, '0')
|
||||
const mm = date.getMinutes().toString().padStart(2, '0')
|
||||
const timePart = `${hh}:${mm}`
|
||||
|
||||
if (diffDays === 0) return `今天 ${timePart}`
|
||||
if (diffDays === 1) return `昨天 ${timePart}`
|
||||
if (diffDays === 2) return `前天 ${timePart}`
|
||||
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${month}.${day} ${timePart}`
|
||||
}
|
||||
|
||||
if (date.getFullYear() === now.getFullYear() - 1) {
|
||||
return `去年 ${month}.${day} ${timePart}`
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}.${month}.${day} ${timePart}`
|
||||
}
|
||||
}
|
||||
79
frontend/src/utils/twitter.js
Normal file
79
frontend/src/utils/twitter.js
Normal 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: '登录失败' }
|
||||
}
|
||||
}
|
||||
30
frontend/src/utils/user.js
Normal file
30
frontend/src/utils/user.js
Normal 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 []
|
||||
}
|
||||
}
|
||||
167
frontend/src/utils/vditor.js
Normal file
167
frontend/src/utils/vditor.js
Normal file
@@ -0,0 +1,167 @@
|
||||
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'
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
let vditor
|
||||
vditor = new Vditor(editorId, {
|
||||
placeholder,
|
||||
height: 'auto',
|
||||
theme: getEditorTheme(),
|
||||
preview: Object.assign({ theme: { current: getPreviewTheme() } }, preview),
|
||||
hint: {
|
||||
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: [
|
||||
'emoji',
|
||||
'bold',
|
||||
'italic',
|
||||
'strike',
|
||||
'|',
|
||||
'list',
|
||||
'line',
|
||||
'quote',
|
||||
'code',
|
||||
'inline-code',
|
||||
'|',
|
||||
'undo',
|
||||
'redo',
|
||||
'|',
|
||||
'link',
|
||||
'upload'
|
||||
],
|
||||
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 = `<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
|
||||
}
|
||||
Reference in New Issue
Block a user