优化目录结构

This commit is contained in:
WilliamColton
2025-08-03 01:27:28 +08:00
parent d63081955e
commit c08723574d
222 changed files with 2 additions and 25 deletions

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

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

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

@@ -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) + '...'
}

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

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,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)

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

View 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}`
}
}

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,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 = `![${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
}