Compare commits

...
2 Commits
Author SHA1 Message Date
Mr.Hope ba8a221833 style: fix eslint 2023-12-19 21:05:54 +08:00
Mr.Hope 3f73cd9443 style: introduce prettier 2023-12-19 20:32:04 +08:00
179 changed files with 3451 additions and 1921 deletions

No files matched your search

-14
View File
@@ -1,14 +0,0 @@
# don't ever lint node_modules
node_modules
# don't lint build output (make sure it's set to your correct build folder name)
dist
# don't lint nyc coverage output
coverage
vite*.config*
# ignore all js files
**/*.js
# ignore all config files
*.config.ts
+2 -1
View File
@@ -16,7 +16,8 @@ module.exports = {
'plugin:import/typescript',
'plugin:compat/recommended',
],
plugins: ['@typescript-eslint', 'import'],
plugins: ['prettier', '@typescript-eslint', 'import'],
ignorePatterns: ['node_modules/', 'coverage/', 'dist/', '**/*.js'],
rules: {
'no-alert': 'warn',
'no-unused-vars': 0,
-57
View File
@@ -1,57 +0,0 @@
.DS_Store
node_modules/
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
.history
.editorconfig
# git
.gitignore
.gitattributes
# tests
test
coverage
# build tools
.travis.yml
.jenkins.yml
.codeclimate.yml
# linters
.jscsrc
.jshintrc
.eslintrc*
# vite configs
vite*.config*
# develop tool configs
.prettierrc
.eslintrc.js
.eslintignore
postcss.config.js
yarn.lock
package-lock.json
# local files
deploy
local
CNAME
index.html
#dist
.github
+11
View File
@@ -0,0 +1,11 @@
# dist files
dist/
# node modules
node_modules/
# pnpm lock file
pnpm-lock.yaml
# Minified files
artalk-sidebar/src/lib/md5.js
-6
View File
@@ -1,6 +0,0 @@
{
"semi": false,
"arrowParens": "always",
"singleQuote": true,
"htmlWhitespaceSensitivity": "ignore"
}
+9 -2
View File
@@ -7,7 +7,8 @@
"dev:sidebar": "pnpm -F @artalk/artalk-sidebar dev",
"build": "pnpm -F artalk build",
"build:sidebar": "pnpm -F @artalk/artalk-sidebar build",
"build:all": "pnpm build && pnpm build:sidebar"
"build:all": "pnpm build && pnpm build:sidebar",
"lint:prettier": "prettier --check --write ."
},
"devDependencies": {
"@playwright/test": "^1.40.1",
@@ -36,5 +37,11 @@
},
"browserslist": [
"supports es6 and supports es6-class and supports es6-generators and supports es6-module-dynamic-import and supports es6-module and supports es6-number and supports es6-string-includes"
]
],
"prettier": {
"semi": false,
"arrowParens": "always",
"singleQuote": true,
"htmlWhitespaceSensitivity": "ignore"
}
}
+68 -60
View File
@@ -5,68 +5,76 @@
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineLoader: typeof import('vue-router/auto')['defineLoader']
const definePage: typeof import('unplugin-vue-router/runtime')['_definePage']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router/auto')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router/auto')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useI18n: typeof import('vue-i18n')['useI18n']
const useRoute: typeof import('vue-router/auto')['useRoute']
const useRouter: typeof import('vue-router/auto')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const EffectScope: (typeof import('vue'))['EffectScope']
const computed: (typeof import('vue'))['computed']
const createApp: (typeof import('vue'))['createApp']
const customRef: (typeof import('vue'))['customRef']
const defineAsyncComponent: (typeof import('vue'))['defineAsyncComponent']
const defineComponent: (typeof import('vue'))['defineComponent']
const defineLoader: (typeof import('vue-router/auto'))['defineLoader']
const definePage: (typeof import('unplugin-vue-router/runtime'))['_definePage']
const effectScope: (typeof import('vue'))['effectScope']
const getCurrentInstance: (typeof import('vue'))['getCurrentInstance']
const getCurrentScope: (typeof import('vue'))['getCurrentScope']
const h: (typeof import('vue'))['h']
const inject: (typeof import('vue'))['inject']
const isProxy: (typeof import('vue'))['isProxy']
const isReactive: (typeof import('vue'))['isReactive']
const isReadonly: (typeof import('vue'))['isReadonly']
const isRef: (typeof import('vue'))['isRef']
const markRaw: (typeof import('vue'))['markRaw']
const nextTick: (typeof import('vue'))['nextTick']
const onActivated: (typeof import('vue'))['onActivated']
const onBeforeMount: (typeof import('vue'))['onBeforeMount']
const onBeforeRouteLeave: (typeof import('vue-router/auto'))['onBeforeRouteLeave']
const onBeforeRouteUpdate: (typeof import('vue-router/auto'))['onBeforeRouteUpdate']
const onBeforeUnmount: (typeof import('vue'))['onBeforeUnmount']
const onBeforeUpdate: (typeof import('vue'))['onBeforeUpdate']
const onDeactivated: (typeof import('vue'))['onDeactivated']
const onErrorCaptured: (typeof import('vue'))['onErrorCaptured']
const onMounted: (typeof import('vue'))['onMounted']
const onRenderTracked: (typeof import('vue'))['onRenderTracked']
const onRenderTriggered: (typeof import('vue'))['onRenderTriggered']
const onScopeDispose: (typeof import('vue'))['onScopeDispose']
const onServerPrefetch: (typeof import('vue'))['onServerPrefetch']
const onUnmounted: (typeof import('vue'))['onUnmounted']
const onUpdated: (typeof import('vue'))['onUpdated']
const provide: (typeof import('vue'))['provide']
const reactive: (typeof import('vue'))['reactive']
const readonly: (typeof import('vue'))['readonly']
const ref: (typeof import('vue'))['ref']
const resolveComponent: (typeof import('vue'))['resolveComponent']
const shallowReactive: (typeof import('vue'))['shallowReactive']
const shallowReadonly: (typeof import('vue'))['shallowReadonly']
const shallowRef: (typeof import('vue'))['shallowRef']
const toRaw: (typeof import('vue'))['toRaw']
const toRef: (typeof import('vue'))['toRef']
const toRefs: (typeof import('vue'))['toRefs']
const toValue: (typeof import('vue'))['toValue']
const triggerRef: (typeof import('vue'))['triggerRef']
const unref: (typeof import('vue'))['unref']
const useAttrs: (typeof import('vue'))['useAttrs']
const useCssModule: (typeof import('vue'))['useCssModule']
const useCssVars: (typeof import('vue'))['useCssVars']
const useI18n: (typeof import('vue-i18n'))['useI18n']
const useRoute: (typeof import('vue-router/auto'))['useRoute']
const useRouter: (typeof import('vue-router/auto'))['useRouter']
const useSlots: (typeof import('vue'))['useSlots']
const watch: (typeof import('vue'))['watch']
const watchEffect: (typeof import('vue'))['watchEffect']
const watchPostEffect: (typeof import('vue'))['watchPostEffect']
const watchSyncEffect: (typeof import('vue'))['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, InjectionKey, PropType, Ref, VNode } from 'vue'
export type {
Component,
ComponentPublicInstance,
ComputedRef,
InjectionKey,
PropType,
Ref,
VNode,
} from 'vue'
}
+17 -17
View File
@@ -7,22 +7,22 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
FileUploader: typeof import('./src/components/FileUploader.vue')['default']
Header: typeof import('./src/components/Header.vue')['default']
ItemTextEditor: typeof import('./src/components/ItemTextEditor.vue')['default']
LoadingLayer: typeof import('./src/components/LoadingLayer.vue')['default']
LogTerminal: typeof import('./src/components/LogTerminal.vue')['default']
PageEditor: typeof import('./src/components/PageEditor.vue')['default']
Pagination: typeof import('./src/components/Pagination.vue')['default']
PreferenceArr: typeof import('./src/components/PreferenceArr.vue')['default']
PreferenceGrp: typeof import('./src/components/PreferenceGrp.vue')['default']
PreferenceItem: typeof import('./src/components/PreferenceItem.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SiteCreate: typeof import('./src/components/SiteCreate.vue')['default']
SiteEditor: typeof import('./src/components/SiteEditor.vue')['default']
SiteSwitcher: typeof import('./src/components/SiteSwitcher.vue')['default']
Tab: typeof import('./src/components/Tab.vue')['default']
UserEditor: typeof import('./src/components/UserEditor.vue')['default']
FileUploader: (typeof import('./src/components/FileUploader.vue'))['default']
Header: (typeof import('./src/components/Header.vue'))['default']
ItemTextEditor: (typeof import('./src/components/ItemTextEditor.vue'))['default']
LoadingLayer: (typeof import('./src/components/LoadingLayer.vue'))['default']
LogTerminal: (typeof import('./src/components/LogTerminal.vue'))['default']
PageEditor: (typeof import('./src/components/PageEditor.vue'))['default']
Pagination: (typeof import('./src/components/Pagination.vue'))['default']
PreferenceArr: (typeof import('./src/components/PreferenceArr.vue'))['default']
PreferenceGrp: (typeof import('./src/components/PreferenceGrp.vue'))['default']
PreferenceItem: (typeof import('./src/components/PreferenceItem.vue'))['default']
RouterLink: (typeof import('vue-router'))['RouterLink']
RouterView: (typeof import('vue-router'))['RouterView']
SiteCreate: (typeof import('./src/components/SiteCreate.vue'))['default']
SiteEditor: (typeof import('./src/components/SiteEditor.vue'))['default']
SiteSwitcher: (typeof import('./src/components/SiteSwitcher.vue'))['default']
Tab: (typeof import('./src/components/Tab.vue'))['default']
UserEditor: (typeof import('./src/components/UserEditor.vue'))['default']
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
@@ -6,10 +6,19 @@ import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
fs.copyFile(path.join(__dirname, '../../../../artalk.example.yml'), path.join(__dirname, '../src/assets/artalk.example.yml'), (err) => {
if (!err) console.log("\nArtalk config file 'artalk.example.yml' loaded.\n")
else console.error("Failed to load config file 'artalk.example.yml':\n\n", err, "\n");
})
fs.copyFile(
path.join(__dirname, '../../../../artalk.example.yml'),
path.join(__dirname, '../src/assets/artalk.example.yml'),
(err) => {
if (!err) console.log("\nArtalk config file 'artalk.example.yml' loaded.\n")
else
console.error(
"Failed to load config file 'artalk.example.yml':\n\n",
err,
'\n',
)
},
)
// const file = fs.createWriteStream()
+24 -13
View File
@@ -14,11 +14,15 @@ const i18n = useI18n()
onBeforeMount(() => {
// 获取语言
if (!global.getBootParams().locale) {
global.getArtalk().ctx.getApi().system.conf().then(resp => {
if (resp.frontend_conf?.locale == 'string') {
i18n.locale.value = resp.frontend_conf.locale
}
})
global
.getArtalk()
.ctx.getApi()
.system.conf()
.then((resp) => {
if (resp.frontend_conf?.locale == 'string') {
i18n.locale.value = resp.frontend_conf.locale
}
})
}
if (bootParams.user?.email) {
@@ -34,13 +38,17 @@ onBeforeMount(() => {
}
// 验证登陆身份有效性
global.getArtalk().ctx.getApi().user.loginStatus().then(resp => {
if (resp.is_admin && !resp.is_login) {
global.getArtalk().ctx.get('user').logout()
user.logout()
router.replace('/login')
}
})
global
.getArtalk()
.ctx.getApi()
.user.loginStatus()
.then((resp) => {
if (resp.is_admin && !resp.is_login) {
global.getArtalk().ctx.get('user').logout()
user.logout()
router.replace('/login')
}
})
})
const darkMode = ref(bootParams.darkMode)
@@ -55,7 +63,10 @@ const darkMode = ref(bootParams.darkMode)
</script>
<template>
<div class="app-wrap artalk atk-sidebar" :class="{ 'atk-dark-mode': darkMode }">
<div
class="app-wrap artalk atk-sidebar"
:class="{ 'atk-dark-mode': darkMode }"
>
<Header />
<Tab />
@@ -14,8 +14,8 @@ const emit = defineEmits<{
const { apiUrl } = toRefs(props)
let xhr: XMLHttpRequest|null = null
const fileInputEl = ref<HTMLInputElement|null>(null)
let xhr: XMLHttpRequest | null = null
const fileInputEl = ref<HTMLInputElement | null>(null)
const remoteFilename = ref('')
const isUploading = ref(false)
const progress = ref(0)
@@ -45,7 +45,7 @@ async function startUploadFile(file: File) {
const fileSize = file.size
if (evt.loaded <= fileSize) {
// 正在上传
progress.value = Math.round(evt.loaded / fileSize * 100)
progress.value = Math.round((evt.loaded / fileSize) * 100)
}
})
@@ -56,7 +56,7 @@ async function startUploadFile(file: File) {
// 开始上传
xhr.open('post', apiUrl.value)
xhr.timeout = 5*60*1000 // 5分钟超时
xhr.timeout = 5 * 60 * 1000 // 5分钟超时
xhr.send(formData)
// 上传成功事件
@@ -114,7 +114,13 @@ function abortUpload() {
<template>
<div class="atk-file-upload-group">
<div v-show="!isUploading" class="atk-file-input-wrap atk-fade-in">
<input ref="fileInputEl" type="file" name="AtkDataFile" accept=".artrans" @change="onFileInputChange()" />
<input
ref="fileInputEl"
type="file"
name="AtkDataFile"
accept=".artrans"
@change="onFileInputChange()"
/>
<div class="atk-desc">
<slot v-if="!isDone" name="tip"></slot>
<slot v-if="isDone" name="done-msg"></slot>
@@ -134,6 +140,4 @@ function abortUpload() {
</div>
</template>
<style scoped lang="scss">
</style>
<style scoped lang="scss"></style>
@@ -14,7 +14,9 @@ const { site: curtSite, isAdmin, email } = storeToRefs(user)
const userAvatarImgURL = computed(() => {
const conf = artalk?.ctx.conf?.gravatar
if (!conf) return ``
return `${conf.mirror.replace(/\/$/, '')}/${MD5(email.value)}?${conf.params.replace(/^\?/, '')}`
return `${conf.mirror.replace(/\/$/, '')}/${MD5(
email.value,
)}?${conf.params.replace(/^\?/, '')}`
})
</script>
@@ -23,7 +25,7 @@ const userAvatarImgURL = computed(() => {
<template v-if="isAdmin">
<div
class="avatar clickable"
:class="{ 'active': nav.siteSwitcherShow }"
:class="{ active: nav.siteSwitcherShow }"
@click="nav.showSiteSwitcher()"
>
<div class="site">{{ curtSite.substring(0, 1) }}</div>
@@ -31,7 +33,7 @@ const userAvatarImgURL = computed(() => {
</template>
<template v-else>
<div class="avatar">
<img :src="userAvatarImgURL">
<img :src="userAvatarImgURL" />
</div>
</template>
@@ -75,7 +77,8 @@ const userAvatarImgURL = computed(() => {
&.clickable {
cursor: pointer;
&:hover, &.active {
&:hover,
&.active {
background: var(--at-color-bg-grey);
border-right: 1px solid var(--at-color-border);
}
@@ -100,7 +103,7 @@ const userAvatarImgURL = computed(() => {
text-align: center;
line-height: 30px;
font-size: 13px;
color: #FFF;
color: #fff;
}
}
@@ -116,14 +119,14 @@ const userAvatarImgURL = computed(() => {
font-size: 20px;
&::after {
content: "";
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 6px;
background: #0083ff;
opacity: .4;
opacity: 0.4;
}
}
}
@@ -6,12 +6,12 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
(evt: 'yes', value: string): boolean|void|Promise<boolean|void>
(evt: 'no', value: string): boolean|void|Promise<boolean|void>
(evt: 'yes', value: string): boolean | void | Promise<boolean | void>
(evt: 'no', value: string): boolean | void | Promise<boolean | void>
(evt: 'close'): void
}>()
const inputEl = ref<HTMLInputElement|null>(null)
const inputEl = ref<HTMLInputElement | null>(null)
const inputVal = ref('')
const inputInvalid = ref(false)
@@ -30,13 +30,14 @@ function onInput() {
}
function onKeyUp(evt: KeyboardEvent) {
if (evt.key === 'Enter' || evt.keyCode === 13) { // 按下回车键
if (evt.key === 'Enter' || evt.keyCode === 13) {
// 按下回车键
evt.preventDefault()
submit('yes')
}
}
async function submit(type: 'yes'|'no') {
async function submit(type: 'yes' | 'no') {
if (type == 'yes' && inputInvalid.value) return
let isContinue: any = undefined
@@ -67,7 +68,7 @@ async function submit(type: 'yes'|'no') {
@input="onInput()"
@keyup="onKeyUp"
:class="{ 'atk-invalid': inputInvalid }"
>
/>
</div>
<div class="atk-actions">
<div
@@ -77,10 +78,7 @@ async function submit(type: 'yes'|'no') {
>
<i class="atk-icon atk-icon-yes"></i>
</div>
<div
class="atk-item atk-no-btn"
@click="submit('no')"
>
<div class="atk-item atk-no-btn" @click="submit('no')">
<i class="atk-icon atk-icon-no"></i>
</div>
</div>
@@ -113,7 +111,8 @@ async function submit(type: 'yes'|'no') {
outline: none;
background: transparent;
&.atk-invalid {}
&.atk-invalid {
}
&:focus {
border-bottom-color: var(--at-color-main);
@@ -124,7 +123,8 @@ async function submit(type: 'yes'|'no') {
.atk-actions {
@extend .atk-list-btn-actions;
.atk-yes-btn.atk-disabled {}
.atk-yes-btn.atk-disabled {
}
}
}
</style>
@@ -5,7 +5,7 @@ const props = defineProps<{
}>()
const showSpinner = ref(false)
let timer: number|undefined
let timer: number | undefined
onMounted(() => {
// spinner 延迟显示,若加载等待时间太短,没必要显示(闪一下即可)
@@ -26,7 +26,16 @@ onUnmounted(() => {
:style="{ background: props.transparentBg ? 'transparent' : undefined }"
>
<div v-if="showSpinner" class="atk-loading-spinner">
<svg viewBox="25 25 50 50"><circle cx="50" cy="50" r="20" fill="none" stroke-width="2" stroke-miterlimit="10"></circle></svg>
<svg viewBox="25 25 50 50">
<circle
cx="50"
cy="50"
r="20"
fill="none"
stroke-width="2"
stroke-miterlimit="10"
></circle>
</svg>
</div>
</div>
</template>
@@ -8,7 +8,7 @@ const emit = defineEmits<{
(evt: 'back'): void
}>()
const logWrapEl = ref<HTMLElement|null>(null)
const logWrapEl = ref<HTMLElement | null>(null)
onMounted(() => {
// 创建 iframe
@@ -64,7 +64,8 @@ function back() {
}
}
.atk-log {}
.atk-log {
}
.atk-iframe {
width: 100%;
@@ -13,8 +13,10 @@ const emit = defineEmits<{
}>()
const { page } = toRefs(props)
const editFieldKey = ref<keyof ArtalkType.PageData|null>(null)
const editFieldVal = computed(() => String(editFieldKey ? page.value[editFieldKey.value!] || '' : ''))
const editFieldKey = ref<keyof ArtalkType.PageData | null>(null)
const editFieldVal = computed(() =>
String(editFieldKey ? page.value[editFieldKey.value!] || '' : ''),
)
const isLoading = ref(false)
const { t } = useI18n()
@@ -30,12 +32,16 @@ async function editAdminOnly() {
isLoading.value = true
let p: ArtalkType.PageData
try {
p = await artalk!.ctx.getApi().page.pageEdit({ ...page.value, admin_only: !page.value.admin_only })
p = await artalk!.ctx
.getApi()
.page.pageEdit({ ...page.value, admin_only: !page.value.admin_only })
} catch (err: any) {
alert(`修改失败:${err.msg || '未知错误'}`)
console.log(err)
return
} finally { isLoading.value = false }
} finally {
isLoading.value = false
}
emit('update', p)
}
@@ -48,7 +54,9 @@ async function sync() {
alert(`同步失败:${err.msg || '未知错误'}`)
console.log(err)
return
} finally { isLoading.value = false }
} finally {
isLoading.value = false
}
emit('update', p)
}
@@ -56,17 +64,26 @@ function del() {
const del = async () => {
isLoading.value = true
try {
await artalk!.ctx.getApi().page.pageDel(page.value.key, page.value.site_name)
await artalk!.ctx
.getApi()
.page.pageDel(page.value.key, page.value.site_name)
} catch (err: any) {
console.log(err)
alert(`删除失败 ${String(err)}`)
return
} finally { isLoading.value = false }
} finally {
isLoading.value = false
}
emit('remove', page.value.id)
}
if (window.confirm(
`确认删除页面 "${page.value.title || page.value.key}"?将会删除所有相关数据`
)) del()
if (
window.confirm(
`确认删除页面 "${
page.value.title || page.value.key
}"?将会删除所有相关数据`,
)
)
del()
}
function close() {
@@ -78,12 +95,16 @@ async function onFieldEditorYes(val: string) {
isLoading.value = true
let p: ArtalkType.PageData
try {
p = await artalk!.ctx.getApi().page.pageEdit({ ...page.value, [editFieldKey.value as any]: val })
p = await artalk!.ctx
.getApi()
.page.pageEdit({ ...page.value, [editFieldKey.value as any]: val })
} catch (err: any) {
alert(`修改失败:${err.msg || '未知错误'}`)
console.error(err)
return false
} finally { isLoading.value = false }
} finally {
isLoading.value = false
}
emit('update', p)
}
@@ -100,13 +121,19 @@ function onFiledEditorNo() {
<template>
<div class="atk-page-edit-layer">
<div class="atk-page-main-actions">
<div class="atk-item atk-title-edit-btn" @click="editTitle()">{{ t('editTitle') }}</div>
<div class="atk-item atk-key-edit-btn" @click="editKey()">{{ t('switchKey') }}</div>
<div class="atk-item atk-title-edit-btn" @click="editTitle()">
{{ t('editTitle') }}
</div>
<div class="atk-item atk-key-edit-btn" @click="editKey()">
{{ t('switchKey') }}
</div>
<div
class="atk-item atk-admin-only-btn"
:class="!page.admin_only ? 'atk-green' : 'atk-yellow'"
@click="editAdminOnly()"
>{{ !page.admin_only ? t('commentAllowAll') : t('commentOnlyAdmin') }}</div>
>
{{ !page.admin_only ? t('commentAllowAll') : t('commentOnlyAdmin') }}
</div>
</div>
<div class="atk-page-actions">
<div class="atk-item atk-sync-btn" @click="sync()">
@@ -20,11 +20,11 @@ const emit = defineEmits<{
const curtPage = ref(1)
const offset = computed(() => pageSize.value * (curtPage.value - 1))
const maxPage = computed(() => Math.ceil(total.value / pageSize.value))
const prevDisabled = computed(() => (curtPage.value - 1 < 1))
const nextDisabled = computed(() => (curtPage.value + 1 > maxPage.value))
const prevDisabled = computed(() => curtPage.value - 1 < 1)
const nextDisabled = computed(() => curtPage.value + 1 > maxPage.value)
const inputValue = ref(String(curtPage.value))
let inputTimer: number|undefined
let inputTimer: number | undefined
function changePage(page: number) {
curtPage.value = page
@@ -36,14 +36,18 @@ function changePage(page: number) {
function prev() {
if (disabled?.value) return
const page = curtPage.value - 1
if (page < 1) { return }
if (page < 1) {
return
}
changePage(page)
}
function next() {
if (disabled?.value) return
const page = curtPage.value + 1
if (page > maxPage.value) { return }
if (page > maxPage.value) {
return
}
changePage(page)
}
@@ -67,11 +71,22 @@ function triggerInput(now: boolean = false) {
const value = inputValue.value.trim()
const modify = () => {
if (value === '') { revokeInput();return }
if (value === '') {
revokeInput()
return
}
let page = Number(value)
if (Number.isNaN(page)) { revokeInput();return }
if (page < 1) { revokeInput();return }
if (page > maxPage.value) { page = maxPage.value }
if (Number.isNaN(page)) {
revokeInput()
return
}
if (page < 1) {
revokeInput()
return
}
if (page > maxPage.value) {
page = maxPage.value
}
changePage(page)
}
@@ -86,13 +101,17 @@ function onInputKeydown(evt: KeyboardEvent) {
if (keyCode === 38) {
// 上键
const page = Number(inputValue.value) + 1
if (page > maxPage.value) { return }
fillInput(page);
if (page > maxPage.value) {
return
}
fillInput(page)
triggerInput(false)
} else if (keyCode === 40) {
// 下键
const page = Number(inputValue.value) - 1
if (page < 1) { return }
if (page < 1) {
return
}
fillInput(page)
triggerInput(false)
} else if (keyCode === 13) {
@@ -111,7 +130,9 @@ defineExpose({ prev, next, reset })
class="atk-btn atk-btn-prev"
:class="{ 'atk-disabled': disabled || prevDisabled }"
@click="prev()"
>Prev</div>
>
Prev
</div>
<input
type="text"
class="atk-input"
@@ -125,7 +146,9 @@ defineExpose({ prev, next, reset })
class="atk-btn atk-btn-next"
:class="{ 'atk-disabled': disabled || nextDisabled }"
@click="next()"
>Next</div>
>
Next
</div>
</div>
</div>
</template>
@@ -2,8 +2,8 @@
import settings from '../lib/settings'
const props = defineProps<{
tplData: Array<any>,
path: (string|number)[]
tplData: Array<any>
path: (string | number)[]
}>()
const ci = getCurrentInstance()
@@ -14,7 +14,8 @@ onMounted(() => {
})
function update() {
customValue.value = (settings.get().customs.value?.getIn(props.path) as any)?.items || []
customValue.value =
(settings.get().customs.value?.getIn(props.path) as any)?.items || []
}
function save() {
@@ -4,11 +4,13 @@ import settings from '../lib/settings'
const router = useRouter()
const props = defineProps<{
tplData: Object|Array<any>
path: (string|number)[]
tplData: Object | Array<any>
path: (string | number)[]
}>()
const desc = computed(() => settings.get().extractItemDescFromComment(props.path))
const desc = computed(() =>
settings.get().extractItemDescFromComment(props.path),
)
const level = computed(() => props.path.length)
const expanded = ref(true)
@@ -53,11 +55,7 @@ function onHeadClick(evt: Event) {
:path="[...path, key]"
/>
<!-- Item Input -->
<PreferenceItem
v-else
:tpl-data="value"
:path="[...path, key]"
/>
<PreferenceItem v-else :tpl-data="value" :path="[...path, key]" />
</div>
</template>
</div>
@@ -87,7 +85,7 @@ function onHeadClick(evt: Event) {
position: absolute;
top: 50%;
transform: translateY(-50%);
transition: height ease .2s;
transition: height ease 0.2s;
left: -10px;
content: '';
height: 10px;
@@ -118,7 +116,8 @@ function onHeadClick(evt: Event) {
}
}
.pf-grp.level-1, .pf-grp.level-2 {
.pf-grp.level-1,
.pf-grp.level-2 {
& > .pf-head > .sub-title {
padding: 0 10px 0 10px;
margin-left: 4px;
@@ -127,7 +126,6 @@ function onHeadClick(evt: Event) {
}
}
.pf-grp.level-3 {
margin-left: 10px;
@@ -3,23 +3,27 @@ import settings from '../lib/settings'
const props = defineProps<{
tplData: any
path: (string|number)[]
path: (string | number)[]
}>()
const desc = computed(() => settings.get().extractItemDescFromComment(props.path))
const customValue = computed(() => settings.get().customs.value?.getIn(props.path) as any)
const desc = computed(() =>
settings.get().extractItemDescFromComment(props.path),
)
const customValue = computed(
() => settings.get().customs.value?.getIn(props.path) as any,
)
function onChange(value: boolean|string|number) {
function onChange(value: boolean | string | number) {
// 类型转换
switch (typeof props.tplData) {
case "boolean":
if (value === "true") value = true
else if (value === "false") value = false
case 'boolean':
if (value === 'true') value = true
else if (value === 'false') value = false
break
case "string":
case 'string':
value = String(value)
break
case "number":
case 'number':
if (!isNaN(Number(value))) value = Number(value)
break
}
@@ -32,34 +36,42 @@ function onChange(value: boolean|string|number) {
<div class="pf-item">
<div class="info">
<div class="title">{{ desc.title }}</div>
<div v-if="!!desc.subTitle" class="sub-title">{{ desc.subTitle }}</div>
</div>
<div v-if="!!desc.subTitle" class="sub-title">{{ desc.subTitle }}</div>
</div>
<div class="value">
<!-- 候选框 -->
<template v-if="desc.opts !== null">
<select :value="customValue" @change="onChange(($event.target as any).value)">
<option
v-for="item in desc.opts"
:value="item"
>{{ item }}</option>
</select>
</template>
<div class="value">
<!-- 候选框 -->
<template v-if="desc.opts !== null">
<select
:value="customValue"
@change="onChange(($event.target as any).value)"
>
<option v-for="item in desc.opts" :value="item">{{ item }}</option>
</select>
</template>
<!-- 开关 -->
<template v-else-if="typeof tplData === 'boolean'">
<input type="checkbox" :checked="customValue" @change="onChange(($event.target as any).checked)">
</template>
<!-- 开关 -->
<template v-else-if="typeof tplData === 'boolean'">
<input
type="checkbox"
:checked="customValue"
@change="onChange(($event.target as any).checked)"
/>
</template>
<!-- 文本框 -->
<template v-else>
<input
type="text"
:value="(typeof customValue === 'undefined' || customValue === null) ? '' : String(customValue)"
@change="onChange(($event.target as any).value)"
/>
</template>
</div>
<!-- 文本框 -->
<template v-else>
<input
type="text"
:value="
typeof customValue === 'undefined' || customValue === null
? ''
: String(customValue)
"
@change="onChange(($event.target as any).value)"
/>
</template>
</div>
</div>
</template>
@@ -5,7 +5,7 @@ import type { ArtalkType } from 'artalk'
const { t } = useI18n()
const props = defineProps<{
initVal?: { name: string, urls: string }
initVal?: { name: string; urls: string }
}>()
const emit = defineEmits<{
@@ -14,9 +14,9 @@ const emit = defineEmits<{
}>()
const isLoading = ref(false)
const site = ref<{name: string, urls: string}>({
const site = ref<{ name: string; urls: string }>({
name: '',
urls: ''
urls: '',
})
onMounted(() => {
@@ -28,7 +28,10 @@ async function submit() {
const siteName = site.value.name.trim()
const siteUrls = site.value.urls.trim()
if (siteName === '') { alert('请输入站点名称'); return }
if (siteName === '') {
alert('请输入站点名称')
return
}
isLoading.value = true
let s: ArtalkType.SiteData
@@ -38,7 +41,9 @@ async function submit() {
window.alert(`创建失败:${err.msg || ''}`)
console.error(err)
return
} finally { isLoading.value = false }
} finally {
isLoading.value = false
}
emit('done', s)
}
@@ -57,14 +62,26 @@ function close() {
</div>
</div>
<form class="atk-form" @submit.prevent="submit()">
<input v-model="site.name" type="text" name="AtkSiteName" :placeholder="t('siteName')" autocomplete="off">
<input v-model="site.urls" type="text" name="AtkSiteUrls" :placeholder="`${t('siteUrls')} (${t('multiSepHint')})`" autocomplete="off">
<button type="submit" class="atk-btn" name="AtkSubmit">{{ t('add') }}</button>
<input
v-model="site.name"
type="text"
name="AtkSiteName"
:placeholder="t('siteName')"
autocomplete="off"
/>
<input
v-model="site.urls"
type="text"
name="AtkSiteUrls"
:placeholder="`${t('siteUrls')} (${t('multiSepHint')})`"
autocomplete="off"
/>
<button type="submit" class="atk-btn" name="AtkSubmit">
{{ t('add') }}
</button>
</form>
<LoadingLayer v-if="isLoading" />
</div>
</template>
<style scoped lang="scss">
</style>
<style scoped lang="scss"></style>
@@ -14,7 +14,7 @@ const emit = defineEmits<{
const { site } = toRefs(props)
const isLoading = ref(false)
const editFieldKey = ref<keyof ArtalkType.SiteData|null>(null)
const editFieldKey = ref<keyof ArtalkType.SiteData | null>(null)
const editFieldVal = computed(() => {
if (editFieldKey.value === 'urls') return site.value.urls_raw || ''
return String(editFieldKey ? site.value[editFieldKey.value!] || '' : '')
@@ -47,12 +47,13 @@ function del() {
console.log(err)
alert(`删除失败 ${String(err)}`)
return
} finally { isLoading.value = false }
} finally {
isLoading.value = false
}
emit('remove', site.value.id)
}
if (window.confirm(
`确认删除站点 "${site.value.name}"?将会删除所有相关数据`
)) del()
if (window.confirm(`确认删除站点 "${site.value.name}"?将会删除所有相关数据`))
del()
}
async function onFieldEditorYes(val: string) {
@@ -60,12 +61,16 @@ async function onFieldEditorYes(val: string) {
isLoading.value = true
let s: ArtalkType.SiteData
try {
s = await artalk!.ctx.getApi().site.siteEdit({ ...site.value, [editFieldKey.value as any]: val })
s = await artalk!.ctx
.getApi()
.site.siteEdit({ ...site.value, [editFieldKey.value as any]: val })
} catch (err: any) {
alert(`修改失败:${err.msg || '未知错误'}`)
console.error(err)
return false
} finally { isLoading.value = false }
} finally {
isLoading.value = false
}
emit('update', s)
}
@@ -85,13 +90,17 @@ function onFiledEditorNo() {
<span
class="atk-site-name"
@click="!site.first_url || openURL(site.first_url)"
>{{ site.name }}</span>
>
{{ site.name }}
</span>
<span class="atk-site-urls">
<div
v-for="(url) in site.urls"
v-for="url in site.urls"
class="atk-url-item"
@click="openURL(url)"
>{{ url }}</div>
>
{{ url }}
</div>
</span>
</div>
<div class="atk-close-btn" @click="close()">
@@ -100,8 +109,12 @@ function onFiledEditorNo() {
</div>
<div class="atk-main">
<div class="atk-site-text-actions">
<div class="atk-item atk-rename-btn" @click="rename()">{{ t('rename') }}</div>
<div class="atk-item atk-edit-url-btn" @click="editURL()">{{ t('edit') }} URL</div>
<div class="atk-item atk-rename-btn" @click="rename()">
{{ t('rename') }}
</div>
<div class="atk-item atk-edit-url-btn" @click="editURL()">
{{ t('edit') }} URL
</div>
<!--<div class="atk-item atk-export-btn">导出</div>
<div class="atk-item atk-import-btn">导入</div>-->
</div>
@@ -121,6 +134,4 @@ function onFiledEditorNo() {
</div>
</template>
<style scoped lang="scss">
</style>
<style scoped lang="scss"></style>
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { storeToRefs } from 'pinia'
import { useNavStore } from '../stores/nav'
import { useUserStore } from '../stores/user'
const el = ref<HTMLElement|null>(null)
const el = ref<HTMLElement | null>(null)
const nav = useNavStore()
const { siteSwitcherShow: curtShow, sites } = storeToRefs(nav)
@@ -37,11 +37,16 @@ const displaySites = computed(() => {
displays.push({ label: t('allSites'), name: '__ATK_SITE_ALL', logoText: '_' })
sites.value.forEach((site) => {
displays.push({
label: site.name, name: site.name,
logoText: site.name.substring(0, 1)
label: site.name,
name: site.name,
logoText: site.name.substring(0, 1),
})
})
displays.push({ label: t('siteManage'), name: '__SITE_MANAGEMENT__', logoText: '+' })
displays.push({
label: t('siteManage'),
name: '__SITE_MANAGEMENT__',
logoText: '+',
})
return displays
})
@@ -68,7 +73,7 @@ watch(curtShow, (value) => {
<div ref="el" v-show="curtShow" class="atk-site-list-floater">
<div class="atk-sites">
<div
v-for="(site) in displaySites"
v-for="site in displaySites"
class="atk-site-item"
:class="{ 'atk-active': curtSite === site.name }"
@click="switchSite(site.name)"
@@ -120,7 +125,7 @@ watch(curtShow, (value) => {
margin: 10px;
border-radius: 3px;
text-align: center;
color: #FFF;
color: #fff;
font-size: 12px;
}
@@ -130,14 +135,16 @@ watch(curtShow, (value) => {
margin-left: 7px;
}
&.atk-active, &:hover {
&.atk-active,
&:hover {
background: var(--at-color-bg-grey);
}
}
}
}
.v-enter-from, .v-leave-to {
.v-enter-from,
.v-leave-to {
opacity: 0;
transform: scale3d(1.05, 1.05, 1.05);
}
@@ -3,7 +3,7 @@ import { useNavStore } from '../stores/nav'
import { useUserStore } from '../stores/user'
import { storeToRefs } from 'pinia'
type PageItem = { label: string, link: string, hide?: boolean }
type PageItem = { label: string; link: string; hide?: boolean }
const router = useRouter()
const route = useRoute()
@@ -12,8 +12,8 @@ const { t } = useI18n()
const { curtPage, curtTab, tabs, isSearchEnabled } = storeToRefs(nav)
const { isAdmin } = storeToRefs(useUserStore())
const indicator = ref<'pages'|'tabs'>('tabs')
const tabListEl = ref<HTMLElement|null>(null)
const indicator = ref<'pages' | 'tabs'>('tabs')
const tabListEl = ref<HTMLElement | null>(null)
const pages = computed((): { [name: string]: PageItem } => {
if (isAdmin.value) {
@@ -42,22 +42,22 @@ const pages = computed((): { [name: string]: PageItem } => {
},
settings: {
label: 'settings',
link: '/settings'
}
link: '/settings',
},
}
} else {
return {
comments: {
label: 'comment',
link: '/comments',
}
},
}
}
})
const isSearchShow = ref(false)
const searchValue = ref('')
const searchInputEl = ref<HTMLInputElement|null>(null)
const searchInputEl = ref<HTMLInputElement | null>(null)
onMounted(() => {
tabListEl.value!.addEventListener('wheel', (evt) => {
@@ -67,7 +67,7 @@ onMounted(() => {
})
function toggleIndicator() {
indicator.value = (indicator.value !== 'tabs') ? 'tabs' : 'pages'
indicator.value = indicator.value !== 'tabs' ? 'tabs' : 'pages'
}
function switchPage(pageName: string) {
@@ -119,8 +119,10 @@ function onSearchSubmit(evt: Event) {
<template>
<div class="tab">
<div class="page" @click="toggleIndicator()">
<div class="icon" :class="(indicator === 'tabs') ? 'menu' : 'arrow'"></div>
<div class="text" v-if="pages[curtPage]?.label">{{ t(pages[curtPage].label) }}</div>
<div class="icon" :class="indicator === 'tabs' ? 'menu' : 'arrow'"></div>
<div class="text" v-if="pages[curtPage]?.label">
{{ t(pages[curtPage].label) }}
</div>
</div>
<div ref="tabListEl" class="tab-list">
@@ -131,7 +133,9 @@ function onSearchSubmit(evt: Event) {
class="item"
:class="{ active: curtTab === tabName }"
@click="switchTab(tabName as string)"
>{{ t(tabLabel) }}</div>
>
{{ t(tabLabel) }}
</div>
<div
v-if="isSearchEnabled"
@@ -148,7 +152,9 @@ function onSearchSubmit(evt: Event) {
v-show="!page.hide"
:class="{ active: pageName === curtPage }"
@click="switchPage(pageName as string)"
>{{ t(page.label) }}</div>
>
{{ t(page.label) }}
</div>
</template>
</div>
@@ -160,7 +166,13 @@ function onSearchSubmit(evt: Event) {
<div class="item back-btn" @click="hideSearch()">
<div class="icon arrow"></div>
</div>
<input ref="searchInputEl" type="text" :placeholder="t('searchHint')" v-model="searchValue" required>
<input
ref="searchInputEl"
type="text"
:placeholder="t('searchHint')"
v-model="searchValue"
required
/>
<button type="submit" class="item search-btn"></button>
</form>
</div>
@@ -65,21 +65,29 @@ function submit() {
}
if (isCreateMode.value) {
artalk!.ctx.getApi().user.userAdd(user.value!, user.value!.password)
artalk!.ctx
.getApi()
.user.userAdd(user.value!, user.value!.password)
.then((respUser) => {
emit('update', respUser)
}).catch((e) => {
alert('用户创建错误:'+e.msg)
}).finally(() => {
})
.catch((e) => {
alert('用户创建错误:' + e.msg)
})
.finally(() => {
isLoading.value = false
})
} else {
artalk!.ctx.getApi().user.userEdit(user.value!, user.value!.password)
artalk!.ctx
.getApi()
.user.userEdit(user.value!, user.value!.password)
.then((respUser) => {
emit('update', respUser)
}).catch((e) => {
alert('用户保存错误:'+e.msg)
}).finally(() => {
})
.catch((e) => {
alert('用户保存错误:' + e.msg)
})
.finally(() => {
isLoading.value = false
})
}
@@ -89,30 +97,73 @@ function submit() {
<template>
<div class="user-editor-layer">
<div class="header">
<div class="title">{{ isCreateMode ? t('userCreate') : t('userEdit') }}</div>
<div class="title">
{{ isCreateMode ? t('userCreate') : t('userEdit') }}
</div>
<div v-if="!isCreateMode" class="close-btn" @click="close()">
<i class="atk-icon atk-icon-close"></i>
</div>
</div>
<div v-if="!isCreateMode" class="user-log">
<div><span>{{ t('comments') }}</span>{{(user as any).comment_count}}</div>
<div><span>{{ t('last') }} IP</span>{{(user as any).last_ip || '-'}}</div>
<div><span>{{ t('last') }} UA</span>
<template v-if="showFullDetails || !(user as any).last_ua">{{(user as any).last_ua || '-'}}</template>
<template v-else><span @click="showFullDetails = true" style="cursor: pointer;color: var(--at-color-main)">{{ t('Show') }}</span></template>
<div>
<span>{{ t('comments') }}</span>
{{ (user as any).comment_count }}
</div>
<div>
<span>{{ t('last') }} IP</span>
{{ (user as any).last_ip || '-' }}
</div>
<div>
<span>{{ t('last') }} UA</span>
<template v-if="showFullDetails || !(user as any).last_ua">
{{ (user as any).last_ua || '-' }}
</template>
<template v-else>
<span
@click="showFullDetails = true"
style="cursor: pointer; color: var(--at-color-main)"
>
{{ t('Show') }}
</span>
</template>
</div>
</div>
<form v-if="user" class="atk-form" @submit.prevent="submit()">
<div class="atk-label required">{{ t('username') }}</div>
<input v-model="user.name" type="text" placeholder="" autocomplete="off">
<div class="atk-label required">{{ t('email') }}</div>
<input v-model="user.email" type="text" placeholder="" autocomplete="off">
<input
v-model="user.name"
type="text"
placeholder=""
autocomplete="off"
/>
<div class="atk-label required">{{ t('email') }}</div>
<input
v-model="user.email"
type="text"
placeholder=""
autocomplete="off"
/>
<div class="atk-label">{{ t('link') }}</div>
<input v-model="user.link" type="text" placeholder="" autocomplete="off">
<input
v-model="user.link"
type="text"
placeholder=""
autocomplete="off"
/>
<div class="atk-label">{{ t('badgeText') }}</div>
<input v-model="user.badge_name" type="text" placeholder="" autocomplete="off">
<input
v-model="user.badge_name"
type="text"
placeholder=""
autocomplete="off"
/>
<div class="atk-label">{{ t('badgeColor') }} (Color Hex)</div>
<input v-model="user.badge_color" type="text" placeholder="" autocomplete="off">
<input
v-model="user.badge_color"
type="text"
placeholder=""
autocomplete="off"
/>
<div class="atk-label required">{{ t('role') }}</div>
<select v-model="user.is_admin">
<option :value="false">{{ t('normal') }}</option>
@@ -120,9 +171,19 @@ function submit() {
</select>
<template v-if="user.is_admin">
<div class="atk-label required">{{ t('password') }}</div>
<input v-model="user.password" type="text" :placeholder="isCreateMode ? '' : `(${t('passwordEmptyHint')})`" autocomplete="off">
<input
v-model="user.password"
type="text"
:placeholder="isCreateMode ? '' : `(${t('passwordEmptyHint')})`"
autocomplete="off"
/>
<div class="atk-label">{{ t('siteAttached') }}</div>
<input v-model="user.site_names_raw" type="text" :placeholder="`(${t('siteEmptyHint')})`" autocomplete="off">
<input
v-model="user.site_names_raw"
type="text"
:placeholder="`(${t('siteEmptyHint')})`"
autocomplete="off"
/>
</template>
<div class="atk-label required">{{ t('emailNotify') }}</div>
<select v-model="user.receive_email">
+12 -11
View File
@@ -2,7 +2,7 @@ import Artalk from 'artalk'
import type { ArtalkType } from 'artalk'
import { useUserStore } from './stores/user'
export let artalk: Artalk|null = null
export let artalk: Artalk | null = null
// 启动参数
export const bootParams = getBootParams()
@@ -10,13 +10,13 @@ export function getBootParams() {
const p = new URLSearchParams(document.location.search)
return {
pageKey: p.get('pageKey') || '',
site: p.get('site') || '',
user: <ArtalkType.LocalUser>JSON.parse(p.get('user') || '{}'),
view: p.get('view') || '',
pageKey: p.get('pageKey') || '',
site: p.get('site') || '',
user: <ArtalkType.LocalUser>JSON.parse(p.get('user') || '{}'),
view: p.get('view') || '',
viewParams: <any>null,
darkMode: p.get('darkMode') === '1',
locale: p.get('locale') || '',
darkMode: p.get('darkMode') === '1',
locale: p.get('locale') || '',
}
}
@@ -27,7 +27,7 @@ export function createArtalkInstance() {
return Artalk.init({
el: artalkEl,
server: (import.meta.env.DEV) ? 'http://localhost:23366' : '../',
server: import.meta.env.DEV ? 'http://localhost:23366' : '../',
pageKey: bootParams.pageKey,
site: bootParams.site,
darkMode: bootParams.darkMode,
@@ -41,13 +41,14 @@ export function createArtalkInstance() {
autoLoad: false,
}
conf.listUnreadHighlight = true
}
},
})
}
export function importUserDataFromArtalkInstance() {
if (!artalk) throw Error("the artalk instance is not exist")
if (!artalk.ctx.get('user').getData().email) throw Error("the user data in artalk instance is invalid")
if (!artalk) throw Error('the artalk instance is not exist')
if (!artalk.ctx.get('user').getData().email)
throw Error('the user data in artalk instance is invalid')
const userData = artalk.ctx.get('user').getData()
useUserStore().$patch((state) => {
@@ -22,7 +22,8 @@ const en = {
export: 'Export',
settingSaved: 'Setting saved',
settingSaveFailed: 'Setting save failed',
settingNotice: 'Note: Some config options may require a manual reboot to take effect.',
settingNotice:
'Note: Some config options may require a manual reboot to take effect.',
apply: 'Apply',
updateComplete: 'Update complete',
updateReady: 'Ready to update...',
@@ -76,7 +77,7 @@ const en = {
uploadReadyToImport: 'File uploaded and is ready for import',
loginFailure: 'Login failure',
login: 'Login',
loginSelectHint: 'Please select the account you wish to log into:'
loginSelectHint: 'Please select the account you wish to log into:',
}
const zhCN: typeof en = {
@@ -242,5 +243,7 @@ const zhTW: typeof en = {
}
export default {
en, 'zh-CN': zhCN, 'zh-TW': zhTW
en,
'zh-CN': zhCN,
'zh-TW': zhTW,
}
+26 -16
View File
@@ -1,8 +1,13 @@
import type YAML from 'yaml'
type YAMLPair = {
key?: { value: string, commentBefore: string, comment: string },
value?: { commentBefore: string, comment: string, value?: any, items?: YAMLPair[] }
key?: { value: string; commentBefore: string; comment: string }
value?: {
commentBefore: string
comment: string
value?: any
items?: YAMLPair[]
}
}
export function createSettings(yamlObj: any) {
@@ -18,7 +23,7 @@ export function createSettings(yamlObj: any) {
const key = item?.key?.value
if (!key) return
const itemPath = (!path) ? [key] : [...path, key]
const itemPath = !path ? [key] : [...path, key]
let comment = ''
if (index === 0 && parent) comment = parent?.value?.commentBefore || ''
@@ -35,7 +40,7 @@ export function createSettings(yamlObj: any) {
})
}
function extractItemDescFromComment(nodePath: string|(string|number)[]) {
function extractItemDescFromComment(nodePath: string | (string | number)[]) {
if (Array.isArray(nodePath)) nodePath = nodePath.join('.')
const nodeName = nodePath.split('.').slice(-1)[0]
let comment = (comments[nodePath] || '').trim()
@@ -45,42 +50,47 @@ export function createSettings(yamlObj: any) {
let title = ''
let subTitle = ''
let opts: string[]|null = null
let opts: string[] | null = null
const stReg = /\(.*?\)/gm
title = comment.replace(stReg, '').trim()
const stFind = stReg.exec(comment)
subTitle = stFind ? stFind[0].substring(1, stFind[0].length-1) : ''
subTitle = stFind ? stFind[0].substring(1, stFind[0].length - 1) : ''
if (!title) {
const commonDict: any = { 'enabled': '启用' }
const commonDict: any = { enabled: '启用' }
title = commonDict[nodeName] || snakeToCamel(nodeName)
}
const optReg = /\[.*?\]/gm
const optFind = optReg.exec(title)
if (optFind) {
try { opts = JSON.parse(optFind[0]) } catch (err) { console.error(err) }
try {
opts = JSON.parse(optFind[0])
} catch (err) {
console.error(err)
}
title = title.replace(optReg, '').trim()
}
return {
title, subTitle, opts
title,
subTitle,
opts,
}
}
function snakeToCamel(str: string) {
return str.toLowerCase()
.replace(/([_][a-z]|^[a-z])/g, (group) =>
group.slice(-1).toUpperCase()
)
return str
.toLowerCase()
.replace(/([_][a-z]|^[a-z])/g, (group) => group.slice(-1).toUpperCase())
}
return { comments, customs, defaultValues, extractItemDescFromComment }
}
let instance: ReturnType<(typeof createSettings)>
let instance: ReturnType<typeof createSettings>
export default {
init: (yamlObj: any) => instance = createSettings(yamlObj),
get: () => instance
init: (yamlObj: any) => (instance = createSettings(yamlObj)),
get: () => instance,
}
+6 -2
View File
@@ -6,7 +6,11 @@ import messages from './i18n/messages'
import 'artalk/dist/Artalk.css'
import './style.scss'
import App from './App.vue'
import global, { createArtalkInstance, bootParams, getBootParams } from './global'
import global, {
createArtalkInstance,
bootParams,
getBootParams,
} from './global'
// 初始化 Artalk
global.setArtalk(createArtalkInstance())
@@ -25,7 +29,7 @@ const i18n = createI18n({
legacy: false, // use i18n in Composition API
locale: getBootParams().locale || 'en',
fallbackLocale: 'en',
messages
messages,
})
app.use(i18n)
@@ -18,23 +18,29 @@ const search = ref('')
onMounted(() => {
// 初始化导航条
if (user.isAdmin) {
nav.updateTabs({
admin_all: 'all',
admin_pending: 'pending',
all: 'personal',
}, 'admin_all')
nav.updateTabs(
{
admin_all: 'all',
admin_pending: 'pending',
all: 'personal',
},
'admin_all',
)
} else {
nav.updateTabs({
all: 'all',
mentions: 'mentions',
mine: 'mine',
pending: 'pending',
}, 'all')
nav.updateTabs(
{
all: 'all',
mentions: 'mentions',
mine: 'mine',
pending: 'pending',
},
'all',
)
}
watch(curtTab, (curtTab) => {
artalk!.ctx.fetch({
offset: 0
offset: 0,
})
})
@@ -71,14 +77,17 @@ onMounted(() => {
listEl.value?.append($el)
// 搜索功能
nav.enableSearch((value: string) => {
search.value = value
artalk!.reload()
}, () => {
if (search.value === '') return
search.value = ''
artalk!.reload()
})
nav.enableSearch(
(value: string) => {
search.value = value
artalk!.reload()
},
() => {
if (search.value === '') return
search.value = ''
artalk!.reload()
},
)
})
</script>
@@ -18,11 +18,11 @@ onMounted(() => {
if (splitted[1]) bootParams.viewParams = JSON.parse(splitted[1])
}
const LinkMap: {[key:string]:string} = {
const LinkMap: { [key: string]: string } = {
comments: '/comments',
pages: '/pages',
sites: '/sites',
settings: '/settings'
settings: '/settings',
}
router.replace(LinkMap[bootParams.view] || '/comments')
})
+67 -34
View File
@@ -13,19 +13,23 @@ const router = useRouter()
const { t } = useI18n()
let userForm = ref({
email: '',
password: ''
email: '',
password: '',
})
let version = ref('')
let buildHash = ref('')
let loginErr = ref('')
let userSelector = ref<string[]|null>(null)
let userSelector = ref<string[] | null>(null)
onMounted(() => {
global.getArtalk()!.ctx.getApi().system.version().then((v) => {
version.value = v.version
buildHash.value = v.commit_hash
})
global
.getArtalk()!
.ctx.getApi()
.system.version()
.then((v) => {
version.value = v.version
buildHash.value = v.commit_hash
})
})
function onFocus() {
@@ -34,49 +38,78 @@ function onFocus() {
function login(username?: string) {
loginErr.value = ''
global.getArtalk()!.ctx.getApi().user.login(
username || '', userForm.value.email, userForm.value.password
).then((resp) => {
const user = resp.user
global.getArtalk()!.ctx.get('user').update({
nick: user.name,
email: user.email,
link: user.link,
isAdmin: user.is_admin,
token: resp.token,
global
.getArtalk()!
.ctx.getApi()
.user.login(username || '', userForm.value.email, userForm.value.password)
.then((resp) => {
const user = resp.user
global.getArtalk()!.ctx.get('user').update({
nick: user.name,
email: user.email,
link: user.link,
isAdmin: user.is_admin,
token: resp.token,
})
global.importUserDataFromArtalkInstance()
router.replace('/')
})
.catch((e) => {
if (e.data?.need_name_select) {
userSelector.value = e.data?.need_name_select
} else {
loginErr.value = e.msg || t('loginFailure')
}
})
global.importUserDataFromArtalkInstance()
router.replace('/')
}).catch((e) => {
if (e.data?.need_name_select) {
userSelector.value = e.data?.need_name_select
} else {
loginErr.value = e.msg || t('loginFailure')
}
})
}
</script>
<template>
<div class="login-dialog">
<a href="https://artalk.js.org/" target="_blank">
<img class="logo" src="../assets/favicon.png" alt="logo" draggable="false" />
<img
class="logo"
src="../assets/favicon.png"
alt="logo"
draggable="false"
/>
</a>
<form class="login-form" @submit.prevent="login()">
<input type="text" :placeholder="t('email')" v-model="userForm.email" @focus="onFocus">
<input type="password" :placeholder="t('password')" v-model="userForm.password" @focus="onFocus">
<div v-if="!!loginErr" class="err-msg atk-fade-in">{{loginErr}}</div>
<input
type="text"
:placeholder="t('email')"
v-model="userForm.email"
@focus="onFocus"
/>
<input
type="password"
:placeholder="t('password')"
v-model="userForm.password"
@focus="onFocus"
/>
<div v-if="!!loginErr" class="err-msg atk-fade-in">{{ loginErr }}</div>
<button type="submit">{{ t('login') }}</button>
</form>
<div class="copyright">
Powered by <a href="https://artalk.js.org" target="_blank">Artalk</a> (v{{version}} / {{buildHash}})
Powered by
<a href="https://artalk.js.org" target="_blank">Artalk</a>
(v{{ version }} / {{ buildHash }})
</div>
<div v-if="userSelector" class="layer">
<div class="user-selector atk-fade-in">
<div class="text">{{ t('loginSelectHint') }}</div>
<div class="user-list">
<div v-for="u in userSelector" class="item" @click="userSelector = null; login(u)">{{u}}</div>
<div
v-for="u in userSelector"
class="item"
@click="
userSelector = null
login(u)
"
>
{{ u }}
</div>
</div>
</div>
</div>
@@ -148,7 +181,7 @@ function login(username?: string) {
display: block;
&:hover {
opacity: .95;
opacity: 0.95;
}
}
@@ -203,7 +236,7 @@ function login(username?: string) {
& > .item {
cursor: pointer;
padding: 10px 20px;
transition: border .3s ease;
transition: border 0.3s ease;
border-left: 2px solid transparent;
&:hover {
+48 -23
View File
@@ -9,7 +9,7 @@ import Pagination from '../components/Pagination.vue'
const nav = useNavStore()
const { site: curtSite } = storeToRefs(useUserStore())
const pages = ref<ArtalkType.PageData[]>([])
const curtEditPageID = ref<number|null>(null)
const curtEditPageID = ref<number | null>(null)
const { t } = useI18n()
const pageSize = ref(20)
@@ -22,9 +22,7 @@ const refreshBtn = ref({
})
onMounted(() => {
nav.updateTabs({
}, '')
nav.updateTabs({}, '')
reqPages(0)
@@ -34,7 +32,7 @@ onMounted(() => {
})
// Refresh task status recovery
getRefreshTaskStatus().then(d => {
getRefreshTaskStatus().then((d) => {
if (d.is_progress === true) {
refreshBtn.value.isRun = true
refreshBtn.value.statusText = d.msg
@@ -50,7 +48,7 @@ onUnmounted(() => {
})
function scrollHandler() {
showActBarBorder.value = (nav.scrollableArea!.scrollTop > 10)
showActBarBorder.value = nav.scrollableArea!.scrollTop > 10
}
function editPage(page: ArtalkType.PageData) {
@@ -59,12 +57,15 @@ function editPage(page: ArtalkType.PageData) {
function reqPages(offset: number) {
nav.setPageLoading(true)
artalk?.ctx.getApi().page.pageGet(curtSite.value, offset, pageSize.value)
.then(data => {
artalk?.ctx
.getApi()
.page.pageGet(curtSite.value, offset, pageSize.value)
.then((data) => {
pageTotal.value = data.total
pages.value = data.pages
nav.scrollPageToTop()
}).finally(() => {
})
.finally(() => {
nav.setPageLoading(false)
})
}
@@ -74,22 +75,27 @@ function onChangePage(offset: number) {
}
function onPageItemUpdate(page: ArtalkType.PageData) {
const index = pages.value.findIndex(p => p.id === page.id)
const index = pages.value.findIndex((p) => p.id === page.id)
if (index != -1) {
const orgPage = pages.value[index]
Object.keys(page).forEach(key => {
Object.keys(page).forEach((key) => {
;(orgPage as any)[key] = (page as any)[key]
})
}
}
function onPageItemRemove(id: number) {
const index = pages.value.findIndex(p => p.id === id)
const index = pages.value.findIndex((p) => p.id === id)
pages.value.splice(index, 1)
}
async function getRefreshTaskStatus() {
return await artalk!.ctx.getApi().page.pageFetch(undefined, undefined, true) as { is_progress: boolean, msg: string }
return (await artalk!.ctx
.getApi()
.page.pageFetch(undefined, undefined, true)) as {
is_progress: boolean
msg: string
}
}
function startRefreshTaskWatchdog() {
@@ -132,11 +138,19 @@ async function refreshAllPages() {
}
function cacheFlush() {
artalk!.ctx.getApi().admin.cacheFlushAll().then((d: any) => alert(d.msg)).catch(() => alert(t('opFailed')))
artalk!.ctx
.getApi()
.admin.cacheFlushAll()
.then((d: any) => alert(d.msg))
.catch(() => alert(t('opFailed')))
}
function cacheWarm() {
artalk!.ctx.getApi().admin.cacheWarmUp().then((d: any) => alert(d.msg)).catch(() => alert(t('opFailed')))
artalk!.ctx
.getApi()
.admin.cacheWarmUp()
.then((d: any) => alert(d.msg))
.catch(() => alert(t('opFailed')))
}
function openPage(url: string) {
@@ -146,18 +160,29 @@ function openPage(url: string) {
<template>
<div class="atk-page-list-wrap">
<div class="atk-header-action-bar" :class="{ 'bordered': showActBarBorder }">
<div class="atk-header-action-bar" :class="{ bordered: showActBarBorder }">
<span class="atk-update-all-title-btn" @click="refreshAllPages()">
<i class="atk-icon atk-icon-sync" :class="{'atk-rotate': refreshBtn.isRun}"></i>
<span class="atk-text">{{ refreshBtn.isRun ? refreshBtn.statusText : t('updateTitle') }}</span>
<i
class="atk-icon atk-icon-sync"
:class="{ 'atk-rotate': refreshBtn.isRun }"
></i>
<span class="atk-text">
{{ refreshBtn.isRun ? refreshBtn.statusText : t('updateTitle') }}
</span>
</span>
<span class="atk-cache-flush-all-btn" @click="cacheFlush()">
<span class="atk-text">{{ t('cacheClear') }}</span>
</span>
<span class="atk-cache-warm-up-btn" @click="cacheWarm()">
<span class="atk-text">{{ t('cacheWarm') }}</span>
</span>
<span class="atk-cache-flush-all-btn" @click="cacheFlush()"><span class="atk-text">{{ t('cacheClear') }}</span></span>
<span class="atk-cache-warm-up-btn" @click="cacheWarm()"><span class="atk-text">{{ t('cacheWarm') }}</span></span>
</div>
<div class="atk-page-list">
<div v-for="(page) in pages" class="atk-page-item">
<div v-for="page in pages" class="atk-page-item">
<div class="atk-page-main">
<div class="atk-title" @click="openPage(page.url)">{{ page.title }}</div>
<div class="atk-title" @click="openPage(page.url)">
{{ page.title }}
</div>
<div class="atk-sub" @click="openPage(page.url)">{{ page.url }}</div>
</div>
<div class="atk-page-actions">
@@ -196,7 +221,7 @@ function openPage(url: string) {
background: var(--at-color-bg);
z-index: 10;
border-bottom: 1px solid transparent;
transition: .3s ease-out padding;
transition: 0.3s ease-out padding;
&.bordered {
padding-bottom: 10px;
@@ -15,8 +15,8 @@ let settingsTpl = shallowReactive({ tpl: null as any })
onMounted(() => {
nav.updateTabs({
'sites': 'site',
'transfer': 'transfer',
sites: 'site',
transfer: 'transfer',
})
watch(curtTab, (tab) => {
@@ -24,12 +24,15 @@ onMounted(() => {
else if (tab === 'transfer') router.replace('/transfer')
})
artalk!.ctx.getApi().system.getSettings().then((data) => {
const yamlObj = YAML.parseDocument(data.template)
settingsTpl.tpl = yamlObj
settings.init(yamlObj)
settings.get().customs.value = YAML.parseDocument(data.custom)
})
artalk!.ctx
.getApi()
.system.getSettings()
.then((data) => {
const yamlObj = YAML.parseDocument(data.template)
settingsTpl.tpl = yamlObj
settings.init(yamlObj)
settings.get().customs.value = YAML.parseDocument(data.custom)
})
})
function save() {
@@ -37,7 +40,7 @@ function save() {
try {
yamlStr = settings.get().customs.value?.toString() || ''
} catch (err) {
alert('YAML export error: '+err)
alert('YAML export error: ' + err)
console.error(err)
return
}
@@ -50,14 +53,19 @@ function save() {
if (isLoading.value) return
isLoading.value = true
artalk!.ctx.getApi().system.saveSettings(yamlStr).then(() => {
alert(t('settingSaved'))
}).catch((err) => {
console.error(err)
alert(t('settingSaveFailed')+': '+err)
}).finally(() => {
isLoading.value = false
})
artalk!.ctx
.getApi()
.system.saveSettings(yamlStr)
.then(() => {
alert(t('settingSaved'))
})
.catch((err) => {
console.error(err)
alert(t('settingSaveFailed') + ': ' + err)
})
.finally(() => {
isLoading.value = false
})
}
</script>
@@ -65,14 +73,14 @@ function save() {
<div v-if="!!settingsTpl.tpl" class="settings">
<div class="act-bar">
<div class="status-text"></div>
<button class="save-btn" @click="save()"><i class="atk-icon atk-icon-yes" /> {{ t('apply') }}</button>
<button class="save-btn" @click="save()">
<i class="atk-icon atk-icon-yes" />
{{ t('apply') }}
</button>
<LoadingLayer v-if="isLoading" />
</div>
<div class="pfs">
<PreferenceGrp
:tpl-data="settingsTpl.tpl.toJS()"
:path="[]"
/>
<PreferenceGrp :tpl-data="settingsTpl.tpl.toJS()" :path="[]" />
<div class="notice">{{ t('settingNotice') }}</div>
</div>
</div>
@@ -124,7 +132,7 @@ function save() {
border: 0;
&:active {
opacity: .9;
opacity: 0.9;
}
i {
@@ -141,7 +149,8 @@ function save() {
padding: 10px 30px;
}
:deep(input[type="text"]), :deep(select) {
:deep(input[type='text']),
:deep(select) {
font-size: 17px;
width: 100%;
height: 35px;
+24 -18
View File
@@ -5,22 +5,24 @@ import type { ArtalkType } from 'artalk'
const nav = useNavStore()
const sites = ref<ArtalkType.SiteData[]>([])
const curtEditSite = ref<ArtalkType.SiteData|null>(null)
const curtEditSite = ref<ArtalkType.SiteData | null>(null)
const showSiteCreate = ref(false)
const siteCreateInitVal = ref()
const { t } = useI18n()
onMounted(() => {
nav.updateTabs({
}, 'sites')
nav.updateTabs({}, 'sites')
nav.setPageLoading(true)
artalk?.ctx.getApi().site.siteGet().then(gotSites => {
sites.value = gotSites
}).finally(() => {
nav.setPageLoading(false)
})
artalk?.ctx
.getApi()
.site.siteGet()
.then((gotSites) => {
sites.value = gotSites
})
.finally(() => {
nav.setPageLoading(false)
})
// 通过启动参数打开站点创建
const vp = bootParams.viewParams
@@ -46,7 +48,8 @@ const sitesGrouped = computed(() => {
let j = -1
for (let i = 0; i < sites.value.length; i++) {
const item = sites.value[i]
if (i % 4 === 0) { // 每 4 个一组
if (i % 4 === 0) {
// 每 4 个一组
grp.push([])
j++
}
@@ -67,10 +70,10 @@ function onNewSiteCreated(siteNew: ArtalkType.SiteData) {
}
function onSiteItemUpdate(site: ArtalkType.SiteData) {
const index = sites.value.findIndex(s => s.id === site.id)
const index = sites.value.findIndex((s) => s.id === site.id)
if (index != -1) {
const orgSite = sites.value[index]
Object.keys(site).forEach(key => {
Object.keys(site).forEach((key) => {
;(orgSite as any)[key] = (site as any)[key]
})
}
@@ -78,7 +81,7 @@ function onSiteItemUpdate(site: ArtalkType.SiteData) {
}
function onSiteItemRemove(id: number) {
const index = sites.value.findIndex(p => p.id === id)
const index = sites.value.findIndex((p) => p.id === id)
sites.value.splice(index, 1)
nav.refreshSites()
}
@@ -89,7 +92,9 @@ function onSiteItemRemove(id: number) {
<div class="atk-header">
<div class="atk-title">{{ t('siteCount', { count: sites.length }) }}</div>
<div class="atk-actions">
<div class="atk-item atk-site-add-btn" @click="create()"><i class="atk-icon atk-icon-plus"></i></div>
<div class="atk-item atk-site-add-btn" @click="create()">
<i class="atk-icon atk-icon-plus"></i>
</div>
</div>
</div>
<SiteCreate
@@ -99,7 +104,7 @@ function onSiteItemRemove(id: number) {
@done="onNewSiteCreated"
/>
<div class="atk-site-rows-wrap">
<template v-for="(sites) in sitesGrouped">
<template v-for="sites in sitesGrouped">
<template v-if="curtEditSite !== null">
<SiteEditor
v-if="!!sites.includes(curtEditSite)"
@@ -111,7 +116,7 @@ function onSiteItemRemove(id: number) {
</template>
<div class="atk-site-row">
<div
v-for="(site) in sites"
v-for="site in sites"
class="atk-site-item"
:class="{ 'atk-active': curtEditSite === site }"
@click="edit(site)"
@@ -218,7 +223,8 @@ function onSiteItemRemove(id: number) {
}
}
:deep(.atk-site-edit), :deep(.atk-site-add) {
:deep(.atk-site-edit),
:deep(.atk-site-add) {
position: relative;
min-height: 120px;
width: 100%;
@@ -247,7 +253,7 @@ function onSiteItemRemove(id: number) {
width: 100%;
height: 6px;
background: var(--at-color-main);
opacity: .4;
opacity: 0.4;
left: 0;
bottom: 6px;
}
@@ -2,7 +2,7 @@
import { useNavStore } from '../stores/nav'
import { useUserStore } from '../stores/user'
import { artalk } from '../global'
import { storeToRefs } from 'pinia';
import { storeToRefs } from 'pinia'
const nav = useNavStore()
const user = useUserStore()
@@ -12,7 +12,7 @@ const { t } = useI18n()
const importParams = ref({
siteName: '',
siteURL: '',
payload: ''
payload: '',
})
const isLoading = ref(false)
@@ -23,15 +23,18 @@ const importTaskApiURL = ref('')
const uploadedFilename = ref('')
const importTaskStarted = ref(false)
const importTaskParams = ref<{[k:string]:string}>({})
const importTaskParams = ref<{ [k: string]: string }>({})
const exportTaskStarted = ref(false)
onMounted(() => {
nav.updateTabs({
'import': 'import',
'export': 'export',
}, 'import')
nav.updateTabs(
{
import: 'import',
export: 'export',
},
'import',
)
watch(curtTab, (tab) => {
if (tab === 'export') {
startExportTask()
@@ -113,7 +116,10 @@ async function startExportTask() {
function downloadFile(filename: string, text: string) {
const el = document.createElement('a')
el.setAttribute('href', `data:text/json;charset=utf-8,${encodeURIComponent(text)}`);
el.setAttribute(
'href',
`data:text/json;charset=utf-8,${encodeURIComponent(text)}`,
)
el.setAttribute('download', filename)
el.style.display = 'none'
document.body.appendChild(el)
@@ -131,7 +137,10 @@ function getYmdHisFilename() {
const minutes = date.getMinutes()
const seconds = date.getSeconds()
return `${year}${month}${day}-${hours}${padWithZeros(minutes, 2)}${padWithZeros(seconds, 2)}`
return `${year}${month}${day}-${hours}${padWithZeros(
minutes,
2,
)}${padWithZeros(seconds, 2)}`
}
function padWithZeros(vNumber: number, width: number) {
@@ -145,12 +154,21 @@ function padWithZeros(vNumber: number, width: number) {
<template>
<LoadingLayer v-if="isLoading" />
<LogTerminal v-if="importTaskStarted" :api-url="importTaskApiURL" :req-params="importTaskParams" @back="importTaskDone()" />
<LogTerminal
v-if="importTaskStarted"
:api-url="importTaskApiURL"
:req-params="importTaskParams"
@back="importTaskDone()"
/>
<div v-show="!importTaskStarted" class="atk-form">
<div class="atk-label atk-data-file-label">Artrans {{ t('dataFile') }}</div>
<FileUploader :api-url="uploadApiURL" @done="fileUploaded">
<template v-slot:tip>
使用「<a href="https://artalk.js.org/guide/transfer.html" target="_blank">转换工具</a>」将评论数据转为 Artrans 格式
使用「
<a href="https://artalk.js.org/guide/transfer.html" target="_blank">
转换工具
</a>
」将评论数据转为 Artrans 格式
</template>
<template v-slot:done-msg>
{{ t('uploadReadyToImport') }}
@@ -175,12 +193,16 @@ function padWithZeros(vNumber: number, width: number) {
<div class="atk-label">{{ t('payload') }} ({{ t('optional') }})</div>
<textarea name="AtkPayload" v-model="importParams.payload"></textarea>
<span class="atk-desc">
参考「<a href="https://artalk.js.org/guide/transfer.html" target="_blank">文档 · 数据迁移</a>」
参考「
<a href="https://artalk.js.org/guide/transfer.html" target="_blank">
文档 · 数据迁移
</a>
」
</span>
<button class="atk-btn" name="AtkSubmit" @click="startImportTask()">{{ t('import') }}</button>
<button class="atk-btn" name="AtkSubmit" @click="startImportTask()">
{{ t('import') }}
</button>
</div>
</template>
<style scoped lang="scss">
</style>
<style scoped lang="scss"></style>
+51 -21
View File
@@ -17,14 +17,17 @@ const pagination = ref<InstanceType<typeof Pagination>>()
const curtType = ref('all')
const addingUser = ref(false)
const editingUser = ref<ArtalkType.UserDataForAdmin|undefined>()
const editingUser = ref<ArtalkType.UserDataForAdmin | undefined>()
onMounted(() => {
nav.updateTabs({
'all': 'all',
'admin': 'admin',
'create': 'create'
}, 'all')
nav.updateTabs(
{
all: 'all',
admin: 'admin',
create: 'create',
},
'all',
)
watch(curtTab, (tab) => {
if (tab === 'create') {
@@ -51,12 +54,15 @@ onMounted(() => {
function reqUsers(offset: number) {
nav.setPageLoading(true)
artalk?.ctx.getApi().user.userList(offset, pageSize.value, curtType.value as any)
.then(got => {
artalk?.ctx
.getApi()
.user.userList(offset, pageSize.value, curtType.value as any)
.then((got) => {
pageTotal.value = got.total
users.value = got.users
nav.scrollPageToTop()
}).finally(() => {
})
.finally(() => {
nav.setPageLoading(false)
})
}
@@ -76,11 +82,11 @@ function editUser(user: ArtalkType.UserDataForAdmin) {
}
function updateUser(user: ArtalkType.UserDataForAdmin) {
const index = users.value.findIndex(u => u.id === user.id)
const index = users.value.findIndex((u) => u.id === user.id)
if (index != -1) {
// 修改用户
const orgUser = users.value[index]
Object.keys(users).forEach(key => {
Object.keys(users).forEach((key) => {
;(orgUser as any)[key] = (users as any)[key]
})
} else {
@@ -98,10 +104,16 @@ function closeEditUser() {
}
function delUser(user: ArtalkType.UserDataForAdmin) {
if (window.confirm(`该操作将删除 用户:"${user.name}" 邮箱:"${user.email}" 所有评论,包括其评论下面他人的回复评论,是否继续?`)) {
artalk!.ctx.getApi().user.userDel(user.id)
if (
window.confirm(
`该操作将删除 用户:"${user.name}" 邮箱:"${user.email}" 所有评论,包括其评论下面他人的回复评论,是否继续?`,
)
) {
artalk!.ctx
.getApi()
.user.userDel(user.id)
.then(() => {
const index = users.value.findIndex(u => u.id === user.id)
const index = users.value.findIndex((u) => u.id === user.id)
users.value.splice(index, 1)
if (user.is_in_conf) {
@@ -109,7 +121,7 @@ function delUser(user: ArtalkType.UserDataForAdmin) {
}
})
.catch((e) => {
alert('删除失败:'+e.msg)
alert('删除失败:' + e.msg)
})
}
}
@@ -118,21 +130,39 @@ function delUser(user: ArtalkType.UserDataForAdmin) {
<template>
<div class="user-list-wrap">
<div class="user-list">
<div v-for="(user) in users" class="user-item">
<div v-for="user in users" class="user-item">
<div class="user-main">
<div class="title">
{{ user.name }}
<span class="badge-grp">
<span v-if="user.badge_name" class="badge" :style="{ backgroundColor: user.badge_color }">{{user.badge_name}}</span>
<span v-else-if="user.is_admin" class="badge admin" :title="t('userAdminHint')">{{ t('Admin') }}</span>
<span v-if="user.is_in_conf" class="badge in-conf" :title="t('userInConfHint')">{{ t('Config') }}</span>
<span
v-if="user.badge_name"
class="badge"
:style="{ backgroundColor: user.badge_color }"
>
{{ user.badge_name }}
</span>
<span
v-else-if="user.is_admin"
class="badge admin"
:title="t('userAdminHint')"
>
{{ t('Admin') }}
</span>
<span
v-if="user.is_in_conf"
class="badge in-conf"
:title="t('userInConfHint')"
>
{{ t('Config') }}
</span>
</span>
</div>
<div class="sub">{{ user.email }}</div>
</div>
<div class="user-actions">
<span @click="editUser(user)">{{ t('edit') }}</span>
<span>{{ t('comment') }} ({{user.comment_count}})</span>
<span>{{ t('comment') }} ({{ user.comment_count }})</span>
<span @click="delUser(user)">{{ t('delete') }}</span>
</div>
</div>
@@ -188,7 +218,7 @@ function delUser(user: ArtalkType.UserDataForAdmin) {
color: #fff;
&.admin {
background: #0083FF;
background: #0083ff;
}
&.in-conf {
+33 -13
View File
@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { artalk } from '../global'
import type { ArtalkType } from 'artalk'
type TabsObj = {[name: string]: string}
type TabsObj = { [name: string]: string }
export const useNavStore = defineStore('nav', () => {
const curtTab = ref('')
@@ -13,11 +13,11 @@ export const useNavStore = defineStore('nav', () => {
const siteSwitcherShow = ref(false)
const isSearchEnabled = ref(false)
const searchEvent = ref<((val: string) => void)|null>(null)
const searchResetEvent = ref<(() => void)|null>(null)
const searchEvent = ref<((val: string) => void) | null>(null)
const searchResetEvent = ref<(() => void) | null>(null)
const isPageLoading = ref(false)
const scrollableArea = ref<HTMLElement|null>(null)
const scrollableArea = ref<HTMLElement | null>(null)
const updateTabs = (aTabs: TabsObj, activeTab?: string) => {
tabs.value = aTabs
@@ -53,23 +53,43 @@ export const useNavStore = defineStore('nav', () => {
}
const refreshSites = () => {
artalk?.ctx.getApi().site.siteGet().then((respSites) => {
sites.value = respSites
})
artalk?.ctx
.getApi()
.site.siteGet()
.then((respSites) => {
sites.value = respSites
})
}
const enableSearch = (searchEvt: ((val: string) => void), searchResetEvt: () => void) => {
const enableSearch = (
searchEvt: (val: string) => void,
searchResetEvt: () => void,
) => {
isSearchEnabled.value = true
searchEvent.value = searchEvt
searchResetEvent.value = searchResetEvt
}
return {
sites, curtPage, curtTab, tabs, siteSwitcherShow, scrollableArea, isPageLoading,
updateTabs, setTabActive,
showSiteSwitcher, hideSiteSwitcher, toggleSiteSwitcher,
scrollPageToTop, scrollPageToEl, setPageLoading,
sites,
curtPage,
curtTab,
tabs,
siteSwitcherShow,
scrollableArea,
isPageLoading,
updateTabs,
setTabActive,
showSiteSwitcher,
hideSiteSwitcher,
toggleSiteSwitcher,
scrollPageToTop,
scrollPageToEl,
setPageLoading,
refreshSites,
isSearchEnabled, searchEvent, searchResetEvent, enableSearch,
isSearchEnabled,
searchEvent,
searchResetEvent,
enableSearch,
}
})
+13 -8
View File
@@ -6,7 +6,8 @@ body {
margin: 0;
}
.artalk, .atk-layer-wrap {
.artalk,
.atk-layer-wrap {
font-family: $font-family;
}
@@ -59,7 +60,8 @@ body {
}
}
input, textarea {
input,
textarea {
display: block;
line-height: $form-height;
padding: 0 10px;
@@ -75,7 +77,7 @@ body {
border-bottom-color: var(--at-color-bg-light);
}
&[type="file"] {
&[type='file'] {
background: transparent;
border: 2px solid var(--at-color-border);
cursor: pointer;
@@ -91,7 +93,7 @@ body {
}
}
&[type="file"]::-webkit-file-upload-button {
&[type='file']::-webkit-file-upload-button {
background: transparent;
color: var(--at-color-font);
border: 0;
@@ -103,7 +105,7 @@ body {
line-height: 25px;
margin-right: 15px;
cursor: pointer;
transition: .2s ease;
transition: 0.2s ease;
-webkit-appearance: none;
border-radius: 0;
}
@@ -151,7 +153,9 @@ body {
user-select: none;
}
input, select, button {
input,
select,
button {
font-size: 15px;
}
@@ -185,7 +189,7 @@ body {
font-size: 14px;
.atk-curt {
color: var(--at-color-main)
color: var(--at-color-main);
}
.atk-abort {
@@ -213,7 +217,8 @@ body {
}
}
.atk-log {}
.atk-log {
}
.atk-iframe {
width: 100%;
@@ -7,15 +7,15 @@
&::-webkit-scrollbar-thumb,
&::-webkit-scrollbar-thumb:window-inactive {
background: rgba(86,86,86,.3);
background: rgba(86, 86, 86, 0.3);
}
&::-webkit-scrollbar-thumb:vertical:hover {
background: rgba(65,74,82,.77);
background: rgba(65, 74, 82, 0.77);
}
&::-webkit-scrollbar-thumb:vertical:active {
background: rgba(41,47,53,.77);
background: rgba(41, 47, 53, 0.77);
}
}
@@ -1,4 +1,5 @@
$font-family: "Helvetica Neue", "PingFangSC-Light", "Hiragino Sans GB", "Microsoft YaHei", "WenQuanYi Micro Hei", sans-serif;
$font-family: 'Helvetica Neue', 'PingFangSC-Light', 'Hiragino Sans GB',
'Microsoft YaHei', 'WenQuanYi Micro Hei', sans-serif;
$font-size: 14px;
$line-height: 1.6em;
$block-radius: 6px;
+71 -18
View File
@@ -39,14 +39,49 @@ import type {
declare module 'vue-router/auto/routes' {
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/comments': RouteRecordInfo<'/comments', '/comments', Record<never, never>, Record<never, never>>,
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
'/pages': RouteRecordInfo<'/pages', '/pages', Record<never, never>, Record<never, never>>,
'/settings': RouteRecordInfo<'/settings', '/settings', Record<never, never>, Record<never, never>>,
'/sites': RouteRecordInfo<'/sites', '/sites', Record<never, never>, Record<never, never>>,
'/transfer': RouteRecordInfo<'/transfer', '/transfer', Record<never, never>, Record<never, never>>,
'/users': RouteRecordInfo<'/users', '/users', Record<never, never>, Record<never, never>>,
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>
'/comments': RouteRecordInfo<
'/comments',
'/comments',
Record<never, never>,
Record<never, never>
>
'/login': RouteRecordInfo<
'/login',
'/login',
Record<never, never>,
Record<never, never>
>
'/pages': RouteRecordInfo<
'/pages',
'/pages',
Record<never, never>,
Record<never, never>
>
'/settings': RouteRecordInfo<
'/settings',
'/settings',
Record<never, never>,
Record<never, never>
>
'/sites': RouteRecordInfo<
'/sites',
'/sites',
Record<never, never>,
Record<never, never>
>
'/transfer': RouteRecordInfo<
'/transfer',
'/transfer',
Record<never, never>,
Record<never, never>
>
'/users': RouteRecordInfo<
'/users',
'/users',
Record<never, never>,
Record<never, never>
>
}
}
@@ -59,29 +94,39 @@ declare module 'vue-router/auto' {
* Type safe version of `RouteLocationNormalized` (the type of `to` and `from` in navigation guards).
* Allows passing the name of the route to be passed as a generic.
*/
export type RouteLocationNormalized<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationNormalizedTypedList<RouteNamedMap>[Name]
export type RouteLocationNormalized<
Name extends keyof RouteNamedMap = keyof RouteNamedMap,
> = RouteLocationNormalizedTypedList<RouteNamedMap>[Name]
/**
* Type safe version of `RouteLocationNormalizedLoaded` (the return type of `useRoute()`).
* Allows passing the name of the route to be passed as a generic.
*/
export type RouteLocationNormalizedLoaded<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[Name]
export type RouteLocationNormalizedLoaded<
Name extends keyof RouteNamedMap = keyof RouteNamedMap,
> = RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[Name]
/**
* Type safe version of `RouteLocationResolved` (the returned route of `router.resolve()`).
* Allows passing the name of the route to be passed as a generic.
*/
export type RouteLocationResolved<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationResolvedTypedList<RouteNamedMap>[Name]
export type RouteLocationResolved<
Name extends keyof RouteNamedMap = keyof RouteNamedMap,
> = RouteLocationResolvedTypedList<RouteNamedMap>[Name]
/**
* Type safe version of `RouteLocation` . Allows passing the name of the route to be passed as a generic.
*/
export type RouteLocation<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationTypedList<RouteNamedMap>[Name]
export type RouteLocation<
Name extends keyof RouteNamedMap = keyof RouteNamedMap,
> = RouteLocationTypedList<RouteNamedMap>[Name]
/**
* Type safe version of `RouteLocationRaw` . Allows passing the name of the route to be passed as a generic.
*/
export type RouteLocationRaw<Name extends keyof RouteNamedMap = keyof RouteNamedMap> =
export type RouteLocationRaw<
Name extends keyof RouteNamedMap = keyof RouteNamedMap,
> =
| RouteLocationAsString<RouteNamedMap>
| RouteLocationAsRelativeTypedList<RouteNamedMap>[Name]
| RouteLocationAsPathTypedList<RouteNamedMap>[Name]
@@ -89,19 +134,27 @@ declare module 'vue-router/auto' {
/**
* Generate a type safe params for a route location. Requires the name of the route to be passed as a generic.
*/
export type RouteParams<Name extends keyof RouteNamedMap> = RouteNamedMap[Name]['params']
export type RouteParams<Name extends keyof RouteNamedMap> =
RouteNamedMap[Name]['params']
/**
* Generate a type safe raw params for a route location. Requires the name of the route to be passed as a generic.
*/
export type RouteParamsRaw<Name extends keyof RouteNamedMap> = RouteNamedMap[Name]['paramsRaw']
export type RouteParamsRaw<Name extends keyof RouteNamedMap> =
RouteNamedMap[Name]['paramsRaw']
export function useRouter(): RouterTyped
export function useRoute<Name extends keyof RouteNamedMap = keyof RouteNamedMap>(name?: Name): RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[Name]
export function useRoute<
Name extends keyof RouteNamedMap = keyof RouteNamedMap,
>(name?: Name): RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[Name]
export const useLink: UseLinkFnTyped<RouteNamedMap>
export function onBeforeRouteLeave(guard: NavigationGuard<RouteNamedMap>): void
export function onBeforeRouteUpdate(guard: NavigationGuard<RouteNamedMap>): void
export function onBeforeRouteLeave(
guard: NavigationGuard<RouteNamedMap>,
): void
export function onBeforeRouteUpdate(
guard: NavigationGuard<RouteNamedMap>,
): void
export const RouterLink: RouterLinkTyped<RouteNamedMap>
export const RouterLinkProps: RouterLinkPropsTyped<RouteNamedMap>
+11 -8
View File
@@ -12,8 +12,8 @@ export default defineConfig({
base: './',
build: {
target: 'es2015',
outDir: resolve(__dirname, "dist"),
minify: 'terser'
outDir: resolve(__dirname, 'dist'),
minify: 'terser',
},
plugins: [
VueRouter({ importMode: 'sync' }),
@@ -24,14 +24,17 @@ export default defineConfig({
}),
(() => ({
name: 'prod-vue-resolver',
resolveId (id) {
resolveId(id) {
// @issue https://github.com/vitejs/vite/issues/6607
// dev mode vite resolves vue in other way
// only in prod mode, `id === vue` is true
if(id === 'vue') {
return resolve(__dirname, './node_modules/vue/dist/vue.runtime.esm-bundler.js')
if (id === 'vue') {
return resolve(
__dirname,
'./node_modules/vue/dist/vue.runtime.esm-bundler.js',
)
}
}
},
}))(),
],
server: {
@@ -40,8 +43,8 @@ export default defineConfig({
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "./src/style/_variables.scss";@import "./src/style/_extends.scss";`
},
additionalData: `@import "./src/style/_variables.scss";@import "./src/style/_extends.scss";`,
},
},
},
})
+283 -141
View File
@@ -1,155 +1,297 @@
<!DOCTYPE html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Artalk DEMO</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, target-densitydpi=device-dpi" />
<style>
* { box-sizing: border-box; }
body { padding:45px 10px;margin:0;background:#f4f4f4 }
body.dark-mode { background:#000; }
.card { margin:0 auto;max-width:700px;position:relative;overflow:hidden;background:#FFF;border-radius:4px;padding:20px;border: 1px solid transparent; }
body.dark-mode .card { background: #1e2223;border: 1px solid #2d3235; }
.card:not(:last-child) { margin-bottom:10px; }
.card.billboard { text-align: center; }
.title > a { display:block;text-decoration:none;color:#0083ff;font-size:35px;margin-top:0;margin-bottom:10px; }
.title > a:hover { margin-left:0 }
.title > a > span { opacity:0 }
.title > a:hover>span { opacity:1 }
.desc { font-size:12px;color:#697182 }
body.dark-mode .desc { color:#848da3 }
.social { margin-top:20px }
.social > a:not(:last-child) { margin-right:2px }
@media only screen and (max-width: 768px) {
body { padding: 0;background: #FFF; }
.card.billboard { padding-top: 20px; }
.card { width: 100%;padding: 5px; }
}
.float-btn { user-select: none;bottom: 25px;display: block;cursor: pointer;text-align: center;width: 60px;height: 37px;line-height: 37px;font-size: 13px;color: #757e91;background: #fff;opacity: 0.8;border-radius: 4px;border: 1px solid #eceff2;position: fixed; }
.to-night { z-index: 99999;left: 25px;background: #1e2223;color: #fff; }
body.dark-mode .to-night { background: #006fd9;border: 1px solid #2d3235;color: #fff; }
.to-top { right: 25px; }
</style>
<head>
<meta charset="UTF-8" />
<title>Artalk DEMO</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, target-densitydpi=device-dpi"
/>
<style>
* {
box-sizing: border-box;
}
body {
padding: 45px 10px;
margin: 0;
background: #f4f4f4;
}
body.dark-mode {
background: #000;
}
.card {
margin: 0 auto;
max-width: 700px;
position: relative;
overflow: hidden;
background: #fff;
border-radius: 4px;
padding: 20px;
border: 1px solid transparent;
}
body.dark-mode .card {
background: #1e2223;
border: 1px solid #2d3235;
}
.card:not(:last-child) {
margin-bottom: 10px;
}
.card.billboard {
text-align: center;
}
.title > a {
display: block;
text-decoration: none;
color: #0083ff;
font-size: 35px;
margin-top: 0;
margin-bottom: 10px;
}
.title > a:hover {
margin-left: 0;
}
.title > a > span {
opacity: 0;
}
.title > a:hover > span {
opacity: 1;
}
.desc {
font-size: 12px;
color: #697182;
}
body.dark-mode .desc {
color: #848da3;
}
.social {
margin-top: 20px;
}
.social > a:not(:last-child) {
margin-right: 2px;
}
@media only screen and (max-width: 768px) {
body {
padding: 0;
background: #fff;
}
.card.billboard {
padding-top: 20px;
}
.card {
width: 100%;
padding: 5px;
}
}
.float-btn {
user-select: none;
bottom: 25px;
display: block;
cursor: pointer;
text-align: center;
width: 60px;
height: 37px;
line-height: 37px;
font-size: 13px;
color: #757e91;
background: #fff;
opacity: 0.8;
border-radius: 4px;
border: 1px solid #eceff2;
position: fixed;
}
.to-night {
z-index: 99999;
left: 25px;
background: #1e2223;
color: #fff;
}
body.dark-mode .to-night {
background: #006fd9;
border: 1px solid #2d3235;
color: #fff;
}
.to-top {
right: 25px;
}
</style>
<!-- For mobile testing -->
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
<script>
// VConsole will be exported to `window.VConsole` by default.
if (navigator.userAgentData.mobile) {
var vConsole = new window.VConsole();
}
</script>
</head>
<body>
<div class="card billboard atk-fade-in">
<div class="title"><a href="https://github.com/ArtalkJS/Artalk" target="_blank"><span>『 </span>Artalk<span> 』</span></a></div>
<div class="desc">一款简洁的自托管评论系统 <span id="ArtalkPV"></span> | <span id="ArtalkCount"></span></div>
<div class="social">
<a href="https://www.npmjs.com/package/artalk" aria-label="npm package version" target="_blank"><img src="https://img.shields.io/npm/v/artalk.svg?style=flat-square"></a>
<a href="https://www.npmjs.com/package/artalk" aria-label="npm package download number" target="_blank"><img src="https://img.shields.io/npm/dt/artalk.svg?style=flat-square"></a>
<a href="https://github.com/ArtalkJS/Artalk/commits/master" aria-label="last commit" target="_blank"><img src="https://img.shields.io/github/last-commit/ArtalkJS/Artalk?style=flat-square"></a>
<a href="https://github.com/ArtalkJS/Artalk/blob/master/LICENSE" aria-label="open source license" target="_blank"><img src="https://img.shields.io/github/license/ArtalkJS/Artalk.svg?style=flat-square"></a>
<a href="https://github.com/ArtalkJS/Artalk" aria-label="GitHub star number" target="_blank"><img src="https://img.shields.io/github/stars/ArtalkJS/Artalk?style=flat-square"></a>
<!-- For mobile testing -->
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
<script>
// VConsole will be exported to `window.VConsole` by default.
if (navigator.userAgentData.mobile) {
var vConsole = new window.VConsole()
}
</script>
</head>
<body>
<div class="card billboard atk-fade-in">
<div class="title">
<a href="https://github.com/ArtalkJS/Artalk" target="_blank">
<span>『</span>
Artalk
<span>』</span>
</a>
</div>
<div class="desc">
一款简洁的自托管评论系统
<span id="ArtalkPV"></span>
|
<span id="ArtalkCount"></span>
</div>
<div class="social">
<a
href="https://www.npmjs.com/package/artalk"
aria-label="npm package version"
target="_blank"
>
<img
src="https://img.shields.io/npm/v/artalk.svg?style=flat-square"
/>
</a>
<a
href="https://www.npmjs.com/package/artalk"
aria-label="npm package download number"
target="_blank"
>
<img
src="https://img.shields.io/npm/dt/artalk.svg?style=flat-square"
/>
</a>
<a
href="https://github.com/ArtalkJS/Artalk/commits/master"
aria-label="last commit"
target="_blank"
>
<img
src="https://img.shields.io/github/last-commit/ArtalkJS/Artalk?style=flat-square"
/>
</a>
<a
href="https://github.com/ArtalkJS/Artalk/blob/master/LICENSE"
aria-label="open source license"
target="_blank"
>
<img
src="https://img.shields.io/github/license/ArtalkJS/Artalk.svg?style=flat-square"
/>
</a>
<a
href="https://github.com/ArtalkJS/Artalk"
aria-label="GitHub star number"
target="_blank"
>
<img
src="https://img.shields.io/github/stars/ArtalkJS/Artalk?style=flat-square"
/>
</a>
</div>
</div>
</div>
<div class="card atk-fade-in">
<div id="comments"></div>
</div>
<div class="float-btn to-night atk-fade-in">Blink</div>
<div class="float-btn to-top atk-fade-in" style="display: none;">TOP</div>
<div class="card atk-fade-in">
<div id="comments"></div>
</div>
<div class="float-btn to-night atk-fade-in">Blink</div>
<div class="float-btn to-top atk-fade-in" style="display: none">TOP</div>
<script type="module">
import Artalk from './src/main'
var artalk = Artalk.init({
el: '#comments',
pageKey: 'https://artalk.js.org/guide/intro.html',
pageTitle: 'Artalk Home',
server: (import.meta.env.DEV) ? 'http://localhost:23366' : 'https://artalk.qwqaq.com',
site: 'ArtalkDocs',
//useBackendConf: true,
});
window.artalk = artalk;
window.Artalk = Artalk;
;(function () {
const toTopElem = document.querySelector('.to-top')
toTopElem.addEventListener('click', () => {
window.scrollTo(0, 0)
<script type="module">
import Artalk from './src/main'
var artalk = Artalk.init({
el: '#comments',
pageKey: 'https://artalk.js.org/guide/intro.html',
pageTitle: 'Artalk Home',
server: import.meta.env.DEV
? 'http://localhost:23366'
: 'https://artalk.qwqaq.com',
site: 'ArtalkDocs',
//useBackendConf: true,
})
checkShowToTop()
document.addEventListener('scroll', () => {
window.artalk = artalk
window.Artalk = Artalk
;(function () {
const toTopElem = document.querySelector('.to-top')
toTopElem.addEventListener('click', () => {
window.scrollTo(0, 0)
})
checkShowToTop()
})
document.addEventListener('scroll', () => {
checkShowToTop()
})
function checkShowToTop() {
const scrollTop = window.scrollY
const whereShow = window.screen.height
const isNeedShow = (scrollTop > 0) && (scrollTop > whereShow)
function checkShowToTop() {
const scrollTop = window.scrollY
const whereShow = window.screen.height
const isNeedShow = scrollTop > 0 && scrollTop > whereShow
if (isNeedShow) {
toTopElem.style.display = ''
} else {
toTopElem.style.display = 'none'
}
}
})();
;(function () {
// @Refer: https://stackoverflow.com/questions/50840168/how-to-detect-if-the-os-is-in-dark-mode-in-browsers
let darkMode = false
if (getLS() == "true") {
darkMode = true;
} else {
try {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
darkMode = true;
}
} catch {}
}
function getLS() {
return localStorage.getItem("dark_mode_on") || null
}
function setLS(val) {
return localStorage.setItem("dark_mode_on", String(val))
}
function checkAndApplyDarkMode () {
artalk.setDarkMode(darkMode)
if (darkMode) {
document.body.classList.add('dark-mode')
} else {
document.body.classList.remove('dark-mode')
}
}
checkAndApplyDarkMode()
const toNightElem = document.querySelector('.to-night')
toNightElem.addEventListener('click', () => {
darkMode = !darkMode
setLS(darkMode)
checkAndApplyDarkMode()
})
// Listen
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
const newColorScheme = e.matches ? "dark" : "light";
if (newColorScheme == "dark") {
darkMode = true
if (isNeedShow) {
toTopElem.style.display = ''
} else {
darkMode = false
toTopElem.style.display = 'none'
}
}
})()
;(function () {
// @Refer: https://stackoverflow.com/questions/50840168/how-to-detect-if-the-os-is-in-dark-mode-in-browsers
let darkMode = false
if (getLS() == 'true') {
darkMode = true
} else {
try {
if (
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
darkMode = true
}
} catch {}
}
function getLS() {
return localStorage.getItem('dark_mode_on') || null
}
function setLS(val) {
return localStorage.setItem('dark_mode_on', String(val))
}
function checkAndApplyDarkMode() {
artalk.setDarkMode(darkMode)
if (darkMode) {
document.body.classList.add('dark-mode')
} else {
document.body.classList.remove('dark-mode')
}
}
checkAndApplyDarkMode()
const toNightElem = document.querySelector('.to-night')
toNightElem.addEventListener('click', () => {
darkMode = !darkMode
setLS(darkMode)
checkAndApplyDarkMode()
});
} catch {}
})()
</script>
</body>
})
// Listen
try {
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
const newColorScheme = e.matches ? 'dark' : 'light'
if (newColorScheme == 'dark') {
darkMode = true
} else {
darkMode = false
}
checkAndApplyDarkMode()
})
} catch {}
})()
</script>
</body>
</html>
+13 -10
View File
@@ -34,14 +34,17 @@ export default defineConfig({
},
],
// Run your local dev server before starting the tests.
webServer: [{
command: 'pnpm dev --port 5173',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
}, {
cwd: '../../../', // ugly :(
command: 'make dev',
url: 'http://localhost:23366',
reuseExistingServer: !process.env.CI,
}]
webServer: [
{
command: 'pnpm dev --port 5173',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
{
cwd: '../../../', // ugly :(
command: 'make dev',
url: 'http://localhost:23366',
reuseExistingServer: !process.env.CI,
},
],
})
+1 -1
View File
@@ -1,5 +1,5 @@
import autoprefixer from 'autoprefixer'
export default {
plugins: [ autoprefixer ]
plugins: [autoprefixer],
}
+4 -3
View File
@@ -12,12 +12,12 @@ fs.rmSync(outDir, { recursive: true, force: true })
const libraries = []
fs.readdirSync(i18nPath).forEach(f => {
fs.readdirSync(i18nPath).forEach((f) => {
if (['index.ts', 'external.ts'].includes(f)) return
const filename = path.join(i18nPath, f)
const lang = path.parse(filename).name
const content = fs.readFileSync(filename);
const content = fs.readFileSync(filename)
// only compile the external locale
if (!content.toString().includes('defineLocaleExternal')) return
@@ -25,7 +25,8 @@ fs.readdirSync(i18nPath).forEach(f => {
libraries.push({
entry: filename,
name: lang,
fileName: (format) => ((format == "umd") ? `${lang}.js` : `${lang}.${format}.js`),
fileName: (format) =>
format == 'umd' ? `${lang}.js` : `${lang}.${format}.js`,
})
})
+9 -7
View File
@@ -2,16 +2,18 @@ import { ApiOptions } from './_options'
import { POST, Fetch } from './_request'
abstract class ApiBase {
constructor(
protected options: ApiOptions
) {}
constructor(protected options: ApiOptions) {}
protected async POST<T>(path: string, data?: {[key: string]: any}) {
return POST<T>(this.options, this.options.baseURL+path, data)
protected async POST<T>(path: string, data?: { [key: string]: any }) {
return POST<T>(this.options, this.options.baseURL + path, data)
}
protected async Fetch(path: string, init: RequestInit, timeout?: number): Promise<any> {
return Fetch(this.options, this.options.baseURL+path, init, timeout)
protected async Fetch(
path: string,
init: RequestInit,
timeout?: number,
): Promise<any> {
return Fetch(this.options, this.options.baseURL + path, init, timeout)
}
/**
+49 -22
View File
@@ -4,7 +4,12 @@ import { ApiOptions } from './_options'
import $t from '../i18n'
/** 公共请求函数 */
export async function Fetch(opts: ApiOptions, input: RequestInfo, init: RequestInit, timeout?: number): Promise<any> {
export async function Fetch(
opts: ApiOptions,
input: RequestInfo,
init: RequestInit,
timeout?: number,
): Promise<any> {
// JWT
if (opts.apiToken) {
const headers = new Headers(init.headers) // 保留原有 headers
@@ -31,28 +36,42 @@ export async function Fetch(opts: ApiOptions, input: RequestInfo, init: RequestI
// 重新发起请求
const recall = (resolve, reject) => {
Fetch(opts, input, init)
.then(d => { resolve(d) })
.catch(e => { reject(e) })
.then((d) => {
resolve(d)
})
.catch((e) => {
reject(e)
})
}
// 请求弹出层验证
if (json.data?.need_captcha) {
// 请求需要验证码
json = await (new Promise<any>((resolve, reject) => {
opts.onNeedCheckCaptcha && opts.onNeedCheckCaptcha({
data: { imgData: json.data.img_data, iframe: json.data.iframe },
recall: () => { recall(resolve, reject) },
reject: () => { reject(json) }
})
}))
json = await new Promise<any>((resolve, reject) => {
opts.onNeedCheckCaptcha &&
opts.onNeedCheckCaptcha({
data: { imgData: json.data.img_data, iframe: json.data.iframe },
recall: () => {
recall(resolve, reject)
},
reject: () => {
reject(json)
},
})
})
} else if (json.data?.need_login || isNoAccess) {
// 请求需要管理员权限
json = await (new Promise<any>((resolve, reject) => {
opts.onNeedCheckAdmin && opts.onNeedCheckAdmin({
recall: () => { recall(resolve, reject) },
reject: () => { reject(json) }
})
}))
json = await new Promise<any>((resolve, reject) => {
opts.onNeedCheckAdmin &&
opts.onNeedCheckAdmin({
recall: () => {
recall(resolve, reject)
},
reject: () => {
reject(json)
},
})
})
}
if (!json.success) throw json // throw 相当于 reject(json)
@@ -61,7 +80,11 @@ export async function Fetch(opts: ApiOptions, input: RequestInfo, init: RequestI
}
/** 公共 POST 请求 */
export async function POST<T>(opts: ApiOptions, url: string, data?: {[key: string]: any}) {
export async function POST<T>(
opts: ApiOptions,
url: string,
data?: { [key: string]: any },
) {
const init: RequestInit = {
method: 'POST',
}
@@ -69,7 +92,7 @@ export async function POST<T>(opts: ApiOptions, url: string, data?: {[key: strin
init.body = ToFormData(data)
const json = await Fetch(opts, url, init)
return ((json.data || {}) as T)
return (json.data || {}) as T
}
/** 公共 GET 请求 (无 GET 方式 API) */
@@ -81,9 +104,11 @@ export async function POST<T>(opts: ApiOptions, url: string, data?: {[key: strin
// }
/** 对象转 FormData */
export function ToFormData(object: {[key: string]: any}): FormData {
export function ToFormData(object: { [key: string]: any }): FormData {
const formData = new FormData()
Object.keys(object).forEach(key => formData.append(key, String(object[key])))
Object.keys(object).forEach((key) =>
formData.append(key, String(object[key])),
)
return formData
}
@@ -94,10 +119,12 @@ function timeoutFetch(url: RequestInfo, ms: number, opts: RequestInit) {
let promise = fetch(url, { ...opts, signal: controller.signal })
if (ms > 0) {
const timer = setTimeout(() => controller.abort(), ms)
promise.finally(() => { clearTimeout(timer) })
promise.finally(() => {
clearTimeout(timer)
})
}
promise = promise.catch((err) => {
throw ((err || {}).name === 'AbortError') ? new Error($t('reqAborted')) : err
throw (err || {}).name === 'AbortError' ? new Error($t('reqAborted')) : err
})
return promise
}
+19 -5
View File
@@ -7,7 +7,12 @@ import ApiBase from './_base'
*/
export default class CommentApi extends ApiBase {
/** 评论 · 获取 */
public get(offset: number, pageSize: number, flatMode?: boolean, paramsEditor?: (params: any) => void) {
public get(
offset: number,
pageSize: number,
flatMode?: boolean,
paramsEditor?: (params: any) => void,
) {
const params: any = {
page_key: this.options.pageKey,
site_name: this.options.siteName,
@@ -25,7 +30,16 @@ export default class CommentApi extends ApiBase {
}
/** 评论 · 创建 */
public async add(comment: { nick: string, email: string, link: string, content: string, rid: number, page_key: string, page_title?: string, site_name?: string }) {
public async add(comment: {
nick: string
email: string
link: string
content: string
rid: number
page_key: string
page_title?: string
site_name?: string
}) {
const params: any = {
name: comment.nick,
email: comment.email,
@@ -40,7 +54,7 @@ export default class CommentApi extends ApiBase {
if (comment.site_name) params.site_name = comment.site_name
const data = await this.POST<any>('/add', params)
return (data.comment as CommentData)
return data.comment as CommentData
}
/** 评论 · 修改 */
@@ -50,7 +64,7 @@ export default class CommentApi extends ApiBase {
}
const d = await this.POST<any>('/admin/comment-edit', params)
return (d.comment as CommentData)
return d.comment as CommentData
}
/** 评论 · 删除 */
@@ -73,6 +87,6 @@ export default class CommentApi extends ApiBase {
this.withUserInfo(params)
const data = await this.POST<any>('/vote', params)
return (data as {up: number, down: number})
return data as { up: number; down: number }
}
}
+9 -4
View File
@@ -9,13 +9,18 @@ import admin from './admin'
import upload from './upload'
const ApiComponents = {
comment, page, site,
user, system, captcha,
admin, upload
comment,
page,
site,
user,
system,
captcha,
admin,
upload,
}
class Api {
constructor (opts: ApiOptions) {
constructor(opts: ApiOptions) {
Object.entries(ApiComponents).forEach(([key, ApiComponent]) => {
this[key] = new ApiComponent(opts)
})
+21 -10
View File
@@ -14,7 +14,7 @@ export default class PageApi extends ApiBase {
}
const d = await this.POST<any>('/admin/page-get', params)
return (d as { pages: PageData[], total: number })
return d as { pages: PageData[]; total: number }
}
/** 页面 · 修改 */
@@ -28,7 +28,7 @@ export default class PageApi extends ApiBase {
}
const d = await this.POST<any>('/admin/page-edit', params)
return (d.page as PageData)
return d.page as PageData
}
/** 页面 · 删除 */
@@ -49,14 +49,14 @@ export default class PageApi extends ApiBase {
if (getStatus) params.get_status = getStatus
const d = await this.POST<any>('/admin/page-fetch', params)
return (d as any)
return d as any
}
/** PV */
public async pv() {
const params: any = {
page_key: this.options.pageKey || '',
page_title: this.options.pageTitle || ''
page_title: this.options.pageTitle || '',
}
const p = await this.POST<any>('/pv', params)
@@ -65,19 +65,30 @@ export default class PageApi extends ApiBase {
/** 统计 */
public async stat(
type: 'latest_comments'|'latest_pages'|'pv_most_pages'|'comment_most_pages'|
'page_pv'|'site_pv'|'page_comment'|'site_comment',
pageKeys?: string|string[],
limit?: number
type:
| 'latest_comments'
| 'latest_pages'
| 'pv_most_pages'
| 'comment_most_pages'
| 'page_pv'
| 'site_pv'
| 'page_comment'
| 'site_comment',
pageKeys?: string | string[],
limit?: number,
) {
const params: any = {
type,
}
if (pageKeys) params.page_keys = Array.isArray(pageKeys) ? pageKeys.join(',') : pageKeys
if (pageKeys)
params.page_keys = Array.isArray(pageKeys) ? pageKeys.join(',') : pageKeys
if (limit) params.limit = limit
const data = await this.POST<PageData[]|CommentData[]|object|number>(`/stat`, params)
const data = await this.POST<PageData[] | CommentData[] | object | number>(
`/stat`,
params,
)
return data
}
}
+7 -6
View File
@@ -10,18 +10,19 @@ export default class SiteApi extends ApiBase {
const params: any = {}
const d = await this.POST<any>('/admin/site-get', params)
return (d.sites as SiteData[])
return d.sites as SiteData[]
}
/** 站点 · 创建 */
public async siteAdd(name: string, urls: string) {
const params: any = {
name, urls,
site_name: '' // 全局保留字段,当前站点名
name,
urls,
site_name: '', // 全局保留字段,当前站点名
}
const d = await this.POST<any>('/admin/site-add', params)
return (d.site as SiteData)
return d.site as SiteData
}
/** 站点 · 修改 */
@@ -33,7 +34,7 @@ export default class SiteApi extends ApiBase {
}
const d = await this.POST<any>('/admin/site-edit', params)
return (d.site as SiteData)
return d.site as SiteData
}
/** 站点 · 删除 */
@@ -46,6 +47,6 @@ export default class SiteApi extends ApiBase {
/** 导出 */
public async export() {
const d = await this.Fetch(`/admin/export`, { method: 'POST' }, 0)
return (d.data?.data || '' as string)
return d.data?.data || ('' as string)
}
}
+12 -3
View File
@@ -28,7 +28,9 @@ export default class SystemApi extends ApiBase {
/** 获取配置数据 */
public async getSettings() {
const data = await this.POST<{custom: string, template: string}>('/admin/setting-get')
const data = await this.POST<{ custom: string; template: string }>(
'/admin/setting-get',
)
return data
}
@@ -42,8 +44,15 @@ export default class SystemApi extends ApiBase {
/** 获取 API 版本信息 */
public async version() {
const resp = await fetch(`${this.options.baseURL}/version`, { method: 'POST' })
const resp = await fetch(`${this.options.baseURL}/version`, {
method: 'POST',
})
const data = await resp.json()
return data as { app: string, version: string, commit_hash: string, fe_min_version: string }
return data as {
app: string
version: string
commit_hash: string
fe_min_version: string
}
}
}
+2 -2
View File
@@ -18,10 +18,10 @@ export default class UploadApi extends ApiBase {
const init: RequestInit = {
method: 'POST',
body: form
body: form,
}
const json = await this.Fetch('/img-upload', init)
return ((json.data || {}) as any) as { img_file: string, img_url: string }
return (json.data || {}) as any as { img_file: string; img_url: string }
}
}
+23 -11
View File
@@ -9,18 +9,21 @@ export default class UserApi extends ApiBase {
/** 用户 · 登录 */
public async login(name: string, email: string, password: string) {
const params: any = {
name, email, password
name,
email,
password,
}
const data = await this.POST<any>('/login', params)
return data as { token: string, user: UserData }
return data as { token: string; user: UserData }
}
/** 用户 · 获取 */
public userGet(name: string, email: string) {
const ctrl = new AbortController()
const params: any = {
name, email
name,
email,
}
const req = this.Fetch(`/user-get`, {
@@ -28,7 +31,7 @@ export default class UserApi extends ApiBase {
body: ToFormData(params),
signal: ctrl.signal,
}).then((json) => ({
user: json.data.user as UserData|null,
user: json.data.user as UserData | null,
is_login: json.data.is_login as boolean,
unread: (json.data.unread || []) as NotifyData[],
unread_count: json.data.unread_count || 0,
@@ -36,14 +39,19 @@ export default class UserApi extends ApiBase {
return {
req,
abort: () => { ctrl.abort() },
abort: () => {
ctrl.abort()
},
}
}
/** 用户 · 登录状态 */
public async loginStatus() {
const data = await this.POST<any>('/login-status', this.withUserInfo({}))
return (data || { is_login: false, is_admin: false }) as { is_login: boolean, is_admin: boolean }
return (data || { is_login: false, is_admin: false }) as {
is_login: boolean
is_admin: boolean
}
}
/** 用户 · 注销 */
@@ -69,7 +77,11 @@ export default class UserApi extends ApiBase {
}
/** 用户 · 列表 */
public async userList(offset?: number, limit?: number, type?: 'all'|'admin'|'in_conf') {
public async userList(
offset?: number,
limit?: number,
type?: 'all' | 'admin' | 'in_conf',
) {
const params: any = {
offset: offset || 0,
limit: limit || 15,
@@ -78,7 +90,7 @@ export default class UserApi extends ApiBase {
if (type) params.type = type
const d = await this.POST<any>('/admin/user-get', params)
return (d as { users: UserDataForAdmin[], total: number })
return d as { users: UserDataForAdmin[]; total: number }
}
/** 用户 · 新增 */
@@ -96,7 +108,7 @@ export default class UserApi extends ApiBase {
}
const d = await this.POST<any>('/admin/user-add', params)
return (d.user as UserDataForAdmin)
return d.user as UserDataForAdmin
}
/** 用户 · 修改 */
@@ -115,13 +127,13 @@ export default class UserApi extends ApiBase {
}
const d = await this.POST<any>('/admin/user-edit', params)
return (d.user as UserDataForAdmin)
return d.user as UserDataForAdmin
}
/** 用户 · 删除 */
public userDel(userID: number) {
return this.POST('/admin/user-del', {
id: String(userID)
id: String(userID),
})
}
}
+22 -8
View File
@@ -1,6 +1,11 @@
import './style/main.scss'
import type { ArtalkConfig, EventPayloadMap, ArtalkPlugin, ContextApi } from '~/types'
import type {
ArtalkConfig,
EventPayloadMap,
ArtalkPlugin,
ContextApi,
} from '~/types'
import type { EventHandler } from './lib/event-manager'
import Context from './context'
import { handelCustomConf, convertApiOptions } from './config'
@@ -11,7 +16,7 @@ import Api from './api'
import type { TInjectedServices } from './service'
/** Global Plugins for all instances */
const GlobalPlugins: ArtalkPlugin[] = [ ...DefaultPlugins ]
const GlobalPlugins: ArtalkPlugin[] = [...DefaultPlugins]
/**
* Artalk
@@ -24,7 +29,7 @@ export default class Artalk {
public $root!: HTMLElement
/** Plugins */
protected plugins: ArtalkPlugin[] = [ ...GlobalPlugins ]
protected plugins: ArtalkPlugin[] = [...GlobalPlugins]
constructor(conf: Partial<ArtalkConfig>) {
// Init Config
@@ -41,7 +46,7 @@ export default class Artalk {
})
// Init Plugins
this.plugins.forEach(plugin => {
this.plugins.forEach((plugin) => {
if (typeof plugin === 'function') plugin(this.ctx)
})
@@ -67,17 +72,26 @@ export default class Artalk {
}
/** Add an event listener */
public on<K extends keyof EventPayloadMap>(name: K, handler: EventHandler<EventPayloadMap[K]>) {
public on<K extends keyof EventPayloadMap>(
name: K,
handler: EventHandler<EventPayloadMap[K]>,
) {
this.ctx.on(name, handler)
}
/** Remove an event listener */
public off<K extends keyof EventPayloadMap>(name: K, handler: EventHandler<EventPayloadMap[K]>) {
public off<K extends keyof EventPayloadMap>(
name: K,
handler: EventHandler<EventPayloadMap[K]>,
) {
this.ctx.off(name, handler)
}
/** Trigger an event */
public trigger<K extends keyof EventPayloadMap>(name: K, payload?: EventPayloadMap[K]) {
public trigger<K extends keyof EventPayloadMap>(
name: K,
payload?: EventPayloadMap[K],
) {
this.ctx.trigger(name, payload)
}
@@ -110,7 +124,7 @@ export default class Artalk {
pageKey: conf.pageKey,
countEl: conf.countEl,
pvEl: conf.pvEl,
pvAdd: false
pvAdd: false,
})
}
}
+46 -26
View File
@@ -4,33 +4,47 @@ import ActionBtn from '../components/action-btn'
export default class CommentActions {
private comment: Comment
private get ctx() { return this.comment.ctx }
private get data() { return this.comment.getData() }
private get cConf() { return this.comment.getConf() }
private get ctx() {
return this.comment.ctx
}
private get data() {
return this.comment.getData()
}
private get cConf() {
return this.comment.getConf()
}
public constructor(comment: Comment) {
this.comment = comment
}
/** 投票操作 */
public vote(type: 'up'|'down') {
const actionBtn = (type === 'up') ? this.comment.getRender().voteBtnUp : this.comment.getRender().voteBtnDown
public vote(type: 'up' | 'down') {
const actionBtn =
type === 'up'
? this.comment.getRender().voteBtnUp
: this.comment.getRender().voteBtnDown
this.ctx.getApi().comment.vote(this.data.id, `comment_${type}`)
.then((v) => {
this.data.vote_up = v.up
this.data.vote_down = v.down
this.comment.getRender().voteBtnUp?.updateText()
this.comment.getRender().voteBtnDown?.updateText()
})
.catch((err) => {
actionBtn?.setError(this.ctx.$t('voteFail'))
console.log(err)
})
this.ctx
.getApi()
.comment.vote(this.data.id, `comment_${type}`)
.then((v) => {
this.data.vote_up = v.up
this.data.vote_down = v.down
this.comment.getRender().voteBtnUp?.updateText()
this.comment.getRender().voteBtnDown?.updateText()
})
.catch((err) => {
actionBtn?.setError(this.ctx.$t('voteFail'))
console.log(err)
})
}
/** 管理员 - 评论状态修改 */
public adminEdit(type: 'collapsed'|'pending'|'pinned', btnElem: ActionBtn) {
public adminEdit(
type: 'collapsed' | 'pending' | 'pinned',
btnElem: ActionBtn,
) {
if (btnElem.isLoading) return // 若正在修改中
btnElem.setLoading(true, `${this.ctx.$t('editing')}...`)
@@ -45,15 +59,19 @@ export default class CommentActions {
modify.is_pinned = !modify.is_pinned
}
this.ctx.getApi().comment.commentEdit(modify).then((data) => {
btnElem.setLoading(false)
this.ctx
.getApi()
.comment.commentEdit(modify)
.then((data) => {
btnElem.setLoading(false)
// 刷新当前 Comment UI
this.comment.setData(data)
}).catch((err) => {
console.error(err)
btnElem.setError(this.ctx.$t('editFail'))
})
// 刷新当前 Comment UI
this.comment.setData(data)
})
.catch((err) => {
console.error(err)
btnElem.setError(this.ctx.$t('editFail'))
})
}
/** 管理员 - 评论删除 */
@@ -61,7 +79,9 @@ export default class CommentActions {
if (btnElem.isLoading) return // 若正在删除中
btnElem.setLoading(true, `${this.ctx.$t('deleting')}...`)
this.ctx.getApi().comment.commentDel(this.data.id, this.data.site_name)
this.ctx
.getApi()
.comment.commentDel(this.data.id, this.data.site_name)
.then(() => {
btnElem.setLoading(false)
if (this.cConf.onDelete) this.cConf.onDelete(this.comment)
+7 -4
View File
@@ -24,11 +24,11 @@ export default class Comment extends Component {
private data: CommentData
private cConf: CommentConf
private parent: Comment|null
private parent: Comment | null
private children: Comment[] = []
private nestCurt: number // 当前嵌套层数
private nestMax: number // 最大嵌套层数
private nestMax: number // 最大嵌套层数
constructor(ctx: ContextApi, data: CommentData, conf: CommentConf) {
super(ctx)
@@ -106,7 +106,10 @@ export default class Comment extends Component {
}
/** 置入子评论 */
public putChild(childC: Comment, insertMode: 'append'|'prepend' = 'append') {
public putChild(
childC: Comment,
insertMode: 'append' | 'prepend' = 'append',
) {
childC.parent = this
childC.nestCurt = this.nestCurt + 1 // 嵌套层数 +1
@@ -181,7 +184,7 @@ export default class Comment extends Component {
const info = UADetect(this.data.ua)
return {
browser: `${info.browser} ${info.version}`,
os: `${info.os} ${info.osVersion}`
os: `${info.os} ${info.osVersion}`,
}
}
+16 -12
View File
@@ -10,7 +10,7 @@ export interface IHeightLimitConf {
export interface IHeightLimitRule {
/** Target element need to check */
el: HTMLElement|null|undefined
el: HTMLElement | null | undefined
/** Max height (unit: px) */
max: number
@@ -23,15 +23,16 @@ export type THeightLimitRuleSet = IHeightLimitRule[]
/** Check all elements below the max height limit */
export function check(conf: IHeightLimitConf, rules: THeightLimitRuleSet) {
rules.forEach(({
el, max: maxHeight, imgContains
}) => {
rules.forEach(({ el, max: maxHeight, imgContains }) => {
const _apply = () => {
if (!el) return
if (!conf.scrollable)
applyHeightLimit({ el, maxHeight, postBtnClick: conf.postExpandBtnClick })
else
applyScrollableHeightLimit({ el, maxHeight })
applyHeightLimit({
el,
maxHeight,
postBtnClick: conf.postExpandBtnClick,
})
else applyScrollableHeightLimit({ el, maxHeight })
}
// checking
@@ -39,7 +40,8 @@ export function check(conf: IHeightLimitConf, rules: THeightLimitRuleSet) {
if (el && Utils.getHeight(el) > maxHeight) _apply() // 是否超过高度
}
_check() // check now
if (imgContains && el) // check again if img contains
if (imgContains && el)
// check again if img contains
Utils.onImagesLoaded(el, () => _check())
})
}
@@ -49,8 +51,8 @@ const HEIGHT_LIMIT_CSS = 'atk-height-limit'
/** Apply height limit on an element and add expand btn */
export function applyHeightLimit(obj: {
el: HTMLElement,
maxHeight: number,
el: HTMLElement
maxHeight: number
postBtnClick?: (e: MouseEvent) => void
}) {
if (!obj.el) return
@@ -62,7 +64,9 @@ export function applyHeightLimit(obj: {
obj.el.style.overflow = 'hidden'
/* Expand button */
const $expandBtn = Utils.createElement(`<div class="atk-height-limit-btn">${$t('readMore')}</span>`)
const $expandBtn = Utils.createElement(
`<div class="atk-height-limit-btn">${$t('readMore')}</span>`,
)
$expandBtn.onclick = (e) => {
e.stopPropagation()
disposeHeightLimit(obj.el)
@@ -90,7 +94,7 @@ const HEIGHT_LIMIT_SCROLL_CSS = 'atk-height-limit-scroll'
/** Apply scrollable height limit */
export function applyScrollableHeightLimit(obj: {
el: HTMLElement,
el: HTMLElement
maxHeight: number
}) {
if (!obj.el) return
+33 -19
View File
@@ -10,10 +10,18 @@ import loadRenders from './renders'
export default class Render {
public comment: Comment
public get ctx() { return this.comment.ctx }
public get data() { return this.comment.getData() }
public get conf() { return this.comment.conf }
public get cConf() { return this.comment.getConf() }
public get ctx() {
return this.comment.ctx
}
public get data() {
return this.comment.getData()
}
public get conf() {
return this.comment.conf
}
public get cConf() {
return this.comment.getConf()
}
public $el!: HTMLElement
public $main!: HTMLElement
@@ -22,7 +30,7 @@ export default class Render {
public $headerBadgeWrap!: HTMLElement
public $body!: HTMLElement
public $content!: HTMLElement
public $childrenWrap!: HTMLElement|null
public $childrenWrap!: HTMLElement | null
public $actions!: HTMLElement
public voteBtnUp?: ActionBtn
public voteBtnDown?: ActionBtn
@@ -62,20 +70,24 @@ export default class Render {
const contentMaxH = conf.content
const childrenMaxH = conf.children
HeightLimit.check({
postExpandBtnClick: () => {
// 子评论数仅有 1,直接取消限高
const children = this.comment.getChildren()
if (children.length === 1) HeightLimit.disposeHeightLimit(children[0].getRender().$content)
HeightLimit.check(
{
postExpandBtnClick: () => {
// 子评论数仅有 1,直接取消限高
const children = this.comment.getChildren()
if (children.length === 1)
HeightLimit.disposeHeightLimit(children[0].getRender().$content)
},
scrollable: conf.scrollable,
},
scrollable: conf.scrollable
}, [
// 评论内容限高
{ el: this.$content, max: contentMaxH, imgContains: true },
{ el: this.$replyTo, max: contentMaxH, imgContains: true },
// 子评论区域限高(仅嵌套模式)
{ el: this.$childrenWrap, max: childrenMaxH, imgContains: false }
])
[
// 评论内容限高
{ el: this.$content, max: contentMaxH, imgContains: true },
{ el: this.$replyTo, max: contentMaxH, imgContains: true },
// 子评论区域限高(仅嵌套模式)
{ el: this.$childrenWrap, max: childrenMaxH, imgContains: false },
],
)
}
/** 子评论区域移除限高 */
@@ -102,7 +114,9 @@ export default class Render {
/** 初始化子评论区域 Wrap */
public renderChildrenWrap() {
if (!this.$childrenWrap) {
this.$childrenWrap = Utils.createElement('<div class="atk-comment-children"></div>')
this.$childrenWrap = Utils.createElement(
'<div class="atk-comment-children"></div>',
)
this.$main.append(this.$childrenWrap)
}
@@ -7,28 +7,36 @@ import type Render from '../render'
*/
export default function renderActions(r: Render) {
Object.entries({
renderVote, renderReply,
renderVote,
renderReply,
// 管理员操作
renderCollapse, renderModerator, renderPin, renderEdit, renderDel
renderCollapse,
renderModerator,
renderPin,
renderEdit,
renderDel,
}).forEach(([name, render]) => {
render(r)
})
}
// 操作按钮 - 投票
function renderVote(r: Render) {
if (!r.ctx.conf.vote) return // 关闭投票功能
// 赞同按钮
r.voteBtnUp = new ActionBtn(() => `${r.ctx.$t('voteUp')} (${r.data.vote_up || 0})`).appendTo(r.$actions)
r.voteBtnUp = new ActionBtn(
() => `${r.ctx.$t('voteUp')} (${r.data.vote_up || 0})`,
).appendTo(r.$actions)
r.voteBtnUp.setClick(() => {
r.comment.getActions().vote('up')
})
// 反对按钮
if (r.ctx.conf.voteDown) {
r.voteBtnDown = new ActionBtn(() => `${r.ctx.$t('voteDown')} (${r.data.vote_down || 0})`).appendTo(r.$actions)
r.voteBtnDown = new ActionBtn(
() => `${r.ctx.$t('voteDown')} (${r.data.vote_down || 0})`,
).appendTo(r.$actions)
r.voteBtnDown.setClick(() => {
r.comment.getActions().vote('down')
})
@@ -54,8 +62,9 @@ function renderReply(r: Render) {
// 操作按钮 - 折叠
function renderCollapse(r: Render) {
const collapseBtn = new ActionBtn({
text: () => (r.data.is_collapsed ? r.ctx.$t('expand') : r.ctx.$t('collapse')),
adminOnly: true
text: () =>
r.data.is_collapsed ? r.ctx.$t('expand') : r.ctx.$t('collapse'),
adminOnly: true,
})
collapseBtn.appendTo(r.$actions)
collapseBtn.setClick(() => {
@@ -66,8 +75,9 @@ function renderCollapse(r: Render) {
// 操作按钮 - 审核
function renderModerator(r: Render) {
const pendingBtn = new ActionBtn({
text: () => (r.data.is_pending ? r.ctx.$t('pending') : r.ctx.$t('approved')),
adminOnly: true
text: () =>
r.data.is_pending ? r.ctx.$t('pending') : r.ctx.$t('approved'),
adminOnly: true,
})
pendingBtn.appendTo(r.$actions)
pendingBtn.setClick(() => {
@@ -79,7 +89,7 @@ function renderModerator(r: Render) {
function renderPin(r: Render) {
const pinnedBtn = new ActionBtn({
text: () => (r.data.is_pinned ? r.ctx.$t('unpin') : r.ctx.$t('pin')),
adminOnly: true
adminOnly: true,
})
pinnedBtn.appendTo(r.$actions)
pinnedBtn.setClick(() => {
@@ -91,7 +101,7 @@ function renderPin(r: Render) {
function renderEdit(r: Render) {
const editBtn = new ActionBtn({
text: r.ctx.$t('edit'),
adminOnly: true
adminOnly: true,
})
editBtn.appendTo(r.$actions)
editBtn.setClick(() => {
@@ -9,11 +9,17 @@ export default function renderAvatar(r: Render) {
const $avatarImg = Utils.createElement<HTMLImageElement>('<img />')
const avatarURLBuilder = r.conf.avatarURLBuilder
$avatarImg.src = avatarURLBuilder ? avatarURLBuilder(r.data) : r.comment.getGravatarURL()
$avatarImg.src = avatarURLBuilder
? avatarURLBuilder(r.data)
: r.comment.getGravatarURL()
if (r.data.link) {
const $avatarA = Utils.createElement<HTMLLinkElement>('<a target="_blank" rel="noreferrer noopener nofollow"></a>')
$avatarA.href = Utils.isValidURL(r.data.link) ? r.data.link : `https://${r.data.link}`
const $avatarA = Utils.createElement<HTMLLinkElement>(
'<a target="_blank" rel="noreferrer noopener nofollow"></a>',
)
$avatarA.href = Utils.isValidURL(r.data.link)
? r.data.link
: `https://${r.data.link}`
$avatarA.append($avatarImg)
$avatar.append($avatarA)
} else {
@@ -21,7 +21,8 @@ export default function renderContent(r: Render) {
</div>`)
r.$body.insertAdjacentElement('beforeend', collapsedInfoEl)
const contentShowBtn = collapsedInfoEl.querySelector<HTMLElement>('.atk-show-btn')!
const contentShowBtn =
collapsedInfoEl.querySelector<HTMLElement>('.atk-show-btn')!
contentShowBtn.addEventListener('click', (e) => {
e.stopPropagation() // 防止穿透
@@ -6,7 +6,10 @@ import type Render from '../render'
*/
export default function renderHeader(r: Render) {
Object.entries({
renderNick, renderVerifyBadge, renderDate, renderUABadge
renderNick,
renderVerifyBadge,
renderDate,
renderUABadge,
}).forEach(([name, render]) => {
render(r)
})
@@ -16,9 +19,13 @@ function renderNick(r: Render) {
r.$headerNick = r.$el.querySelector<HTMLElement>('.atk-nick')!
if (r.data.link) {
const $nickA = Utils.createElement<HTMLLinkElement>('<a target="_blank" rel="noreferrer noopener nofollow"></a>')
const $nickA = Utils.createElement<HTMLLinkElement>(
'<a target="_blank" rel="noreferrer noopener nofollow"></a>',
)
$nickA.innerText = r.data.nick
$nickA.href = Utils.isValidURL(r.data.link) ? r.data.link : `https://${r.data.link}`
$nickA.href = Utils.isValidURL(r.data.link)
? r.data.link
: `https://${r.data.link}`
r.$headerNick.append($nickA)
} else {
r.$headerNick.innerText = r.data.nick
@@ -39,7 +46,9 @@ function renderVerifyBadge(ctx: Render) {
}
if (ctx.data.is_pinned) {
const $pinnedBadge = Utils.createElement(`<span class="atk-pinned-badge">${ctx.ctx.$t('pin')}</span>`) // 置顶徽章
const $pinnedBadge = Utils.createElement(
`<span class="atk-pinned-badge">${ctx.ctx.$t('pin')}</span>`,
) // 置顶徽章
ctx.$headerBadgeWrap.append($pinnedBadge)
}
}
@@ -62,7 +71,9 @@ function renderUABadge(ctx: Render) {
$uaWrap.innerHTML = ''
if (ctx.data.ip_region) {
const $regionBadge = Utils.createElement(`<span class="atk-region-badge"></span>`)
const $regionBadge = Utils.createElement(
`<span class="atk-region-badge"></span>`,
)
$regionBadge.innerText = ctx.data.ip_region
$uaWrap.append($regionBadge)
}
@@ -70,7 +81,9 @@ function renderUABadge(ctx: Render) {
if (ctx.ctx.conf.uaBadge) {
const { browser, os } = ctx.comment.getUserUA()
if (String(browser).trim()) {
const $uaBrowser = Utils.createElement(`<span class="atk-ua ua-browser"></span>`)
const $uaBrowser = Utils.createElement(
`<span class="atk-ua ua-browser"></span>`,
)
$uaBrowser.innerText = browser
$uaWrap.append($uaBrowser)
}
@@ -8,12 +8,17 @@ import Pending from './pending'
import Actions from './actions'
const Renders = {
Avatar, Header, Content, ReplyAt,
ReplyTo, Pending, Actions
Avatar,
Header,
Content,
ReplyAt,
ReplyTo,
Pending,
Actions,
}
export default function loadRenders(r: Render) {
Object.entries(Renders).forEach(([ name, render ]) => {
Object.entries(Renders).forEach(([name, render]) => {
render(r)
})
}
@@ -7,6 +7,8 @@ import type Render from '../render'
export default function renderPending(r: Render) {
if (!r.data.is_pending) return
const pendingEl = Utils.createElement(`<div class="atk-pending">${r.ctx.$t('pendingMsg')}</div>`)
const pendingEl = Utils.createElement(
`<div class="atk-pending">${r.ctx.$t('pendingMsg')}</div>`,
)
r.$body.prepend(pendingEl)
}
@@ -8,9 +8,14 @@ export default function renderReplyAt(r: Render) {
if (r.cConf.isFlatMode || r.data.rid === 0) return // not 平铺模式 或 根评论
if (!r.cConf.replyTo) return
r.$replyAt = Utils.createElement(`<span class="atk-item atk-reply-at"><span class="atk-arrow"></span><span class="atk-nick"></span></span>`)
r.$replyAt.querySelector<HTMLElement>('.atk-nick')!.innerText = `${r.cConf.replyTo.nick}`
r.$replyAt.onclick = () => { r.comment.getActions().goToReplyComment() }
r.$replyAt = Utils.createElement(
`<span class="atk-item atk-reply-at"><span class="atk-arrow"></span><span class="atk-nick"></span></span>`,
)
r.$replyAt.querySelector<HTMLElement>('.atk-nick')!.innerText =
`${r.cConf.replyTo.nick}`
r.$replyAt.onclick = () => {
r.comment.getActions().goToReplyComment()
}
r.$headerBadgeWrap.insertAdjacentElement('afterend', r.$replyAt)
}
@@ -11,14 +11,20 @@ export default function renderReplyTo(r: Render) {
r.$replyTo = Utils.createElement(`
<div class="atk-reply-to">
<div class="atk-meta">${r.ctx.$t('reply')} <span class="atk-nick"></span>:</div>
<div class="atk-meta">${r.ctx.$t(
'reply',
)} <span class="atk-nick"></span>:</div>
<div class="atk-content"></div>
</div>`)
const $nick = r.$replyTo.querySelector<HTMLElement>('.atk-nick')!
$nick.innerText = `@${r.cConf.replyTo.nick}`
$nick.onclick = () => { r.comment.getActions().goToReplyComment() }
$nick.onclick = () => {
r.comment.getActions().goToReplyComment()
}
let replyContent = marked(r.cConf.replyTo.content)
if (r.cConf.replyTo.is_collapsed) replyContent = `[${Utils.htmlEncode(r.ctx.$t('collapsed'))}]`
r.$replyTo.querySelector<HTMLElement>('.atk-content')!.innerHTML = replyContent
if (r.cConf.replyTo.is_collapsed)
replyContent = `[${Utils.htmlEncode(r.ctx.$t('collapsed'))}]`
r.$replyTo.querySelector<HTMLElement>('.atk-content')!.innerHTML =
replyContent
r.$body.prepend(r.$replyTo)
}
@@ -26,16 +26,20 @@ export default class ActionBtn {
public msgRecTimer?: number // 消息显示复原定时器
public msgRecTimerFunc?: Function // 消息显示复原操作
public get isMessaging() { return !!this.msgRecTimer } // 消息正在显示
public get isMessaging() {
return !!this.msgRecTimer
} // 消息正在显示
public isConfirming = false // 正在确认
public confirmRecTimer?: number // 确认消息复原定时器
/** 构造函数 */
constructor(opts: ActionBtnOptions|string|(() => string)) {
this.$el = Utils.createElement(`<span class="atk-common-action-btn"></span>`)
constructor(opts: ActionBtnOptions | string | (() => string)) {
this.$el = Utils.createElement(
`<span class="atk-common-action-btn"></span>`,
)
this.opts = (typeof opts !== 'object') ? ({ text: opts }) : opts
this.opts = typeof opts !== 'object' ? { text: opts } : opts
this.$el.innerText = this.getText()
// 仅管理员可操作
@@ -50,7 +54,9 @@ export default class ActionBtn {
/** 获取按钮文字(动态/静态) */
private getText() {
return (typeof this.opts.text === 'string') ? this.opts.text : this.opts.text()
return typeof this.opts.text === 'string'
? this.opts.text
: this.opts.text()
}
/** 设置点击事件 */
@@ -131,7 +137,12 @@ export default class ActionBtn {
}
/** 设置消息 */
public setMsg(text: string, className?: string, duringTime?: number, after?: Function) {
public setMsg(
text: string,
className?: string,
duringTime?: number,
after?: Function,
) {
this.setLoading(false)
if (className) this.$el.classList.add(className)
this.$el.innerText = text
@@ -9,11 +9,13 @@ const AdminChecker: Checker = {
const data = {
name: checker.getUser().getData().nick,
email: checker.getUser().getData().email,
password: inputVal
password: inputVal,
}
return (async () => {
const resp = await checker.getApi().user.login(data.name, data.email, data.password)
const resp = await checker
.getApi()
.user.login(data.name, data.email, data.password)
return resp.token
})()
},
@@ -25,12 +27,12 @@ const AdminChecker: Checker = {
onSuccess(checker, userToken, inputVal, formEl) {
checker.getUser().update({
isAdmin: true,
token: userToken
token: userToken,
})
checker.getOpts().onReload()
},
onError(checker, err, inputVal, formEl) {}
onError(checker, err, inputVal, formEl) {},
}
export default AdminChecker
@@ -7,13 +7,17 @@ import type { CheckerCtx } from '.'
export function imgBody(checker: CheckerCtx) {
// 图片验证方式
const elem = Utils.createElement(
`<span><img class="atk-captcha-img" src="${checker.get('img_data') || ''}">${$t('captchaCheck')}</span>`
);
`<span><img class="atk-captcha-img" src="${
checker.get('img_data') || ''
}">${$t('captchaCheck')}</span>`,
)
// 刷新验证码
elem.querySelector<HTMLElement>('.atk-captcha-img')!.onclick = () => {
const imgEl = elem.querySelector('.atk-captcha-img')
checker.getApi().captcha.captchaGet()
checker
.getApi()
.captcha.captchaGet()
.then((imgData) => {
imgEl!.setAttribute('src', imgData)
})
@@ -26,25 +30,38 @@ export function imgBody(checker: CheckerCtx) {
/** iframe 形式的通用验证服务 */
export function iframeBody(checker: CheckerCtx) {
const $iframeWrap = Utils.createElement(`<div class="atk-checker-iframe-wrap"></div>`)
const $iframe = Utils.createElement<HTMLIFrameElement>(`<iframe class="atk-fade-in"></iframe>`)
const $iframeWrap = Utils.createElement(
`<div class="atk-checker-iframe-wrap"></div>`,
)
const $iframe = Utils.createElement<HTMLIFrameElement>(
`<iframe class="atk-fade-in"></iframe>`,
)
$iframe.style.display = 'none'
Ui.showLoading($iframeWrap, { transparentBg: true })
$iframe.src = `${checker.getOpts().getIframeURLBase() || ''}/api/captcha/get?t=${+new Date()}`
$iframe.src = `${
checker.getOpts().getIframeURLBase() || ''
}/api/captcha/get?t=${+new Date()}`
$iframe.onload = () => {
$iframe.style.display = ''
Ui.hideLoading($iframeWrap)
}
$iframeWrap.append($iframe)
const $closeBtn = Utils.createElement(`<div class="atk-close-btn"><i class="atk-icon atk-icon-close"></i></div>`)
const $closeBtn = Utils.createElement(
`<div class="atk-close-btn"><i class="atk-icon atk-icon-close"></i></div>`,
)
$iframeWrap.append($closeBtn)
checker.hideInteractInput()
// 轮询状态
let stop = false // 打断
const sleep = (ms: number) => new Promise((resolve) => { window.setTimeout(() => { resolve(null) }, ms) })
const sleep = (ms: number) =>
new Promise((resolve) => {
window.setTimeout(() => {
resolve(null)
}, ms)
})
;(async function queryStatus() {
await sleep(1000)
if (stop) return
@@ -52,7 +69,9 @@ export function iframeBody(checker: CheckerCtx) {
try {
const resp = await checker.getApi().captcha.captchaStatus()
isPass = resp.is_pass
} catch { isPass = false }
} catch {
isPass = false
}
if (isPass) {
checker.triggerSuccess()
} else {
@@ -1,4 +1,3 @@
import type { Checker } from '.'
import * as render from './captcha-renders'
@@ -19,8 +18,7 @@ const CaptchaChecker: Checker = {
onError(checker, err, inputVal, formEl) {
formEl.querySelector<HTMLElement>('.atk-captcha-img')!.click() // 刷新验证码
formEl.querySelector<HTMLInputElement>('input[type="text"]')!.value = '' // 清空输入框输入
}
},
}
export default CaptchaChecker
@@ -30,7 +30,7 @@ export interface CheckerLauncherOptions {
* Checker 发射台
*/
export default class CheckerLauncher {
constructor(private opts: CheckerLauncherOptions) { }
constructor(private opts: CheckerLauncherOptions) {}
public checkCaptcha(payload: CheckerCaptchaPayload) {
this.fire(CaptchaChecker, payload, (ctx) => {
@@ -43,32 +43,41 @@ export default class CheckerLauncher {
this.fire(AdminChecker, payload)
}
public fire(checker: Checker, payload: CheckerPayload, postFire?: (c: CheckerCtx) => void) {
public fire(
checker: Checker,
payload: CheckerPayload,
postFire?: (c: CheckerCtx) => void,
) {
// 显示层
const layer = this.opts.getCtx().get('layerManager').create(`checker-${new Date().getTime()}`)
const layer = this.opts
.getCtx()
.get('layerManager')
.create(`checker-${new Date().getTime()}`)
layer.show()
// 构建 Checker 的上下文
const checkerStore: CheckerStore = {}
let hideInteractInput = false
const checkerCtx: CheckerCtx = {
set: (key, val) => { checkerStore[key] = val },
get: (key) => (checkerStore[key]),
getOpts: () => (this.opts),
getUser: () => (this.opts.getCtx().get('user')),
set: (key, val) => {
checkerStore[key] = val
},
get: (key) => checkerStore[key],
getOpts: () => this.opts,
getUser: () => this.opts.getCtx().get('user'),
getApi: () => this.opts.getApi(),
hideInteractInput: () => {
hideInteractInput = true
},
triggerSuccess: () => {
this.close(checker, layer)
if (checker.onSuccess) checker.onSuccess(checkerCtx, "", "", formEl)
if (payload.onSuccess) payload.onSuccess("", dialog.$el)
if (checker.onSuccess) checker.onSuccess(checkerCtx, '', '', formEl)
if (payload.onSuccess) payload.onSuccess('', dialog.$el)
},
cancel: () => {
this.close(checker, layer)
if (payload.onCancel) payload.onCancel()
}
},
}
if (postFire) postFire(checkerCtx)
@@ -79,7 +88,9 @@ export default class CheckerLauncher {
// 输入框
const $input = Utils.createElement<HTMLInputElement>(
`<input id="check" type="${checker.inputType || 'text'}" autocomplete="off" required placeholder="">`
`<input id="check" type="${
checker.inputType || 'text'
}" autocomplete="off" required placeholder="">`,
)
formEl.appendChild($input)
setTimeout(() => $input.focus(), 80) // 延迟 Focus
@@ -123,14 +134,16 @@ export default class CheckerLauncher {
// 请求成功
this.close(checker, layer)
if (checker.onSuccess) checker.onSuccess(checkerCtx, data, inputVal, formEl)
if (checker.onSuccess)
checker.onSuccess(checkerCtx, data, inputVal, formEl)
if (payload.onSuccess) payload.onSuccess(inputVal, dialog.$el)
})
.catch((err) => {
// 请求失败
btnTextSet(String(err.msg || String(err)))
if (checker.onError) checker.onError(checkerCtx, err, inputVal, formEl)
if (checker.onError)
checker.onError(checkerCtx, err, inputVal, formEl)
// 错误显示 3s 后恢复按钮
const tf = setTimeout(() => btnTextRestore(), 3000)
@@ -152,7 +165,9 @@ export default class CheckerLauncher {
if (hideInteractInput) {
$input.style.display = 'none'
dialog.$el.querySelector<HTMLElement>('.atk-layer-dialog-actions')!.style.display = 'none'
dialog.$el.querySelector<HTMLElement>(
'.atk-layer-dialog-actions',
)!.style.display = 'none'
}
// 层装载 dialog 元素
@@ -173,8 +188,18 @@ export interface Checker {
inputType?: 'password' | 'text'
body: (checker: CheckerCtx) => HTMLElement
request: (checker: CheckerCtx, inputVal: string) => Promise<string>
onSuccess?: (checker: CheckerCtx, respData: string, inputVal: string, formEl: HTMLElement) => void
onError?: (checker: CheckerCtx, err: any, inputVal: string, formEl: HTMLElement) => void
onSuccess?: (
checker: CheckerCtx,
respData: string,
inputVal: string,
formEl: HTMLElement,
) => void
onError?: (
checker: CheckerCtx,
err: any,
inputVal: string,
formEl: HTMLElement,
) => void
}
interface CheckerStore {
+8 -6
View File
@@ -1,7 +1,7 @@
import $t from '@/i18n'
import * as Utils from '../lib/utils'
type BtnClickHandler = (btnEl: HTMLElement, dialog: Dialog) => boolean|void
type BtnClickHandler = (btnEl: HTMLElement, dialog: Dialog) => boolean | void
/**
* 对话框
@@ -11,18 +11,20 @@ export default class Dialog {
public $content: HTMLElement
public $actions: HTMLElement
constructor (contentEl: HTMLElement) {
constructor(contentEl: HTMLElement) {
this.$el = Utils.createElement(
`<div class="atk-layer-dialog-wrap">
<div class="atk-layer-dialog">
<div class="atk-layer-dialog-content"></div>
<div class="atk-layer-dialog-actions"></div>
</div>
</div>`
</div>`,
)
// 按钮
this.$actions = this.$el.querySelector<HTMLElement>('.atk-layer-dialog-actions')!
this.$actions = this.$el.querySelector<HTMLElement>(
'.atk-layer-dialog-actions',
)!
// 内容
this.$content = this.$el.querySelector('.atk-layer-dialog-content')!
@@ -32,7 +34,7 @@ export default class Dialog {
/** 按钮 · 确定 */
public setYes(handler: BtnClickHandler) {
const btn = Utils.createElement<HTMLButtonElement>(
`<button data-action="confirm">${$t('confirm')}</button>`
`<button data-action="confirm">${$t('confirm')}</button>`,
)
btn.onclick = this.onBtnClick(handler)
this.$actions.appendChild(btn)
@@ -43,7 +45,7 @@ export default class Dialog {
/** 按钮 · 取消 */
public setNo(handler: BtnClickHandler) {
const btn = Utils.createElement<HTMLButtonElement>(
`<button data-action="cancel">${$t('cancel')}</button>`
`<button data-action="cancel">${$t('cancel')}</button>`,
)
btn.onclick = this.onBtnClick(handler)
this.$actions.appendChild(btn)
@@ -13,16 +13,22 @@ export interface ErrorDialogOptions {
}
export function showErrorDialog(opts: ErrorDialogOptions) {
const errEl = Utils.createElement(`<span>${opts.errMsg},${$t('listLoadFailMsg')}<br/></span>`)
const errEl = Utils.createElement(
`<span>${opts.errMsg},${$t('listLoadFailMsg')}<br/></span>`,
)
if (opts.retryFn) {
const $retryBtn = Utils.createElement(`<span style="cursor:pointer;">${$t('listRetry')}</span>`)
const $retryBtn = Utils.createElement(
`<span style="cursor:pointer;">${$t('listRetry')}</span>`,
)
$retryBtn.onclick = () => opts.retryFn && opts.retryFn()
errEl.appendChild($retryBtn)
}
if (opts.onOpenSidebar) {
const $openSidebar = Utils.createElement('<span atk-only-admin-show> | <span style="cursor:pointer;">打开控制台</span></span>')
const $openSidebar = Utils.createElement(
'<span atk-only-admin-show> | <span style="cursor:pointer;">打开控制台</span></span>',
)
errEl.appendChild($openSidebar)
$openSidebar.onclick = () => opts.onOpenSidebar && opts.onOpenSidebar()
}
+32 -11
View File
@@ -40,7 +40,8 @@ export default class Pagination {
<input type="text" class="atk-input" aria-label="Enter the number of page" />
<div class="atk-btn atk-btn-next">Next</div>
</div>
</div>`)
</div>`,
)
this.$input = this.$el.querySelector('.atk-input')!
this.$input.value = `${this.page}`
@@ -74,11 +75,23 @@ export default class Pagination {
const value = this.$input.value.trim()
const modify = () => {
if (value === '') { this.setInput(this.page);return }
if (value === '') {
this.setInput(this.page)
return
}
let page = Number(value)
if (Number.isNaN(page)) { this.setInput(this.page);return }
if (page < 1) { this.setInput(this.page);return }
if (page > this.maxPage) { this.setInput(this.maxPage);page = this.maxPage }
if (Number.isNaN(page)) {
this.setInput(this.page)
return
}
if (page < 1) {
this.setInput(this.page)
return
}
if (page > this.maxPage) {
this.setInput(this.maxPage)
page = this.maxPage
}
this.change(page)
}
@@ -89,13 +102,17 @@ export default class Pagination {
public prev() {
const page = this.page - 1
if (page < 1) { return }
if (page < 1) {
return
}
this.change(page)
}
public next() {
const page = this.page + 1
if (page > this.maxPage) { return }
if (page > this.maxPage) {
return
}
this.change(page)
}
@@ -130,13 +147,17 @@ export default class Pagination {
if (keyCode === 38) {
// 上键
const page = Number(this.$input.value) + 1
if (page > this.maxPage) { return }
this.setInput(page);
if (page > this.maxPage) {
return
}
this.setInput(page)
this.input()
} else if (keyCode === 40) {
// 下键
const page = Number(this.$input.value) - 1
if (page < 1) { return }
if (page < 1) {
return
}
this.setInput(page)
this.input()
} else if (keyCode === 13) {
@@ -146,7 +167,7 @@ export default class Pagination {
}
/** 加载 */
setLoading (isLoading: boolean) {
setLoading(isLoading: boolean) {
if (isLoading) Ui.showLoading(this.$el)
else Ui.hideLoading(this.$el)
}
@@ -23,7 +23,7 @@ export default class ReadMoreBtn {
/** 是否有更多内容 */
get hasMore() {
return this.total > (this.offset + this.opts.pageSize)
return this.total > this.offset + this.opts.pageSize
}
public constructor(opts: ReadMoreBtnOptions) {
@@ -31,17 +31,20 @@ export default class ReadMoreBtn {
this.origText = this.opts.text || this.origText
this.$el = Utils.createElement(
`<div class="atk-list-read-more" style="display: none;">
`<div class="atk-list-read-more" style="display: none;">
<div class="atk-list-read-more-inner">
<div class="atk-loading-icon" style="display: none;"></div>
<span class="atk-text">${this.origText}</span>
</div>
</div>`)
</div>`,
)
this.$loading = this.$el.querySelector<HTMLElement>('.atk-loading-icon')!
this.$text = this.$el.querySelector<HTMLElement>('.atk-text')!
this.$el.onclick = () => { this.click() }
this.$el.onclick = () => {
this.click()
}
}
click() {
@@ -60,7 +63,7 @@ export default class ReadMoreBtn {
}
/** 加载 */
setLoading (isLoading: boolean) {
setLoading(isLoading: boolean) {
this.$loading.style.display = isLoading ? '' : 'none'
this.$text.style.display = isLoading ? 'none' : ''
}
+37 -18
View File
@@ -9,7 +9,9 @@ import Defaults from './defaults'
* @param customConf - The custom config object which is provided by the user
* @returns The config for Artalk instance creation
*/
export function handelCustomConf(customConf: Partial<ArtalkConfig>): ArtalkConfig {
export function handelCustomConf(
customConf: Partial<ArtalkConfig>,
): ArtalkConfig {
// 合并默认配置
const conf: ArtalkConfig = Utils.mergeDeep(Defaults, customConf)
@@ -48,12 +50,11 @@ export function handelCustomConf(customConf: Partial<ArtalkConfig>): ArtalkConfi
}
// flatMode
if (conf.flatMode === true || Number(conf.nestMax) <= 1)
conf.flatMode = true
if (conf.flatMode === true || Number(conf.nestMax) <= 1) conf.flatMode = true
// 自动判断启用平铺模式
if (conf.flatMode === 'auto')
conf.flatMode = window.matchMedia("(max-width: 768px)").matches
conf.flatMode = window.matchMedia('(max-width: 768px)').matches
return conf
}
@@ -66,18 +67,23 @@ export function handelCustomConf(customConf: Partial<ArtalkConfig>): ArtalkConfi
*/
export function handleConfFormServer(conf: Partial<ArtalkConfig>) {
const DisabledKeys: (keyof ArtalkConfig)[] = [
'el', 'pageKey', 'pageTitle', 'server', 'site', 'darkMode'
'el',
'pageKey',
'pageTitle',
'server',
'site',
'darkMode',
]
Object.keys(conf).forEach(k => {
Object.keys(conf).forEach((k) => {
if (DisabledKeys.includes(k as any)) delete conf[k]
})
// Patch: `emoticons` config string to json
if (conf.emoticons && typeof conf.emoticons === "string") {
if (conf.emoticons && typeof conf.emoticons === 'string') {
conf.emoticons = conf.emoticons.trim()
if (conf.emoticons.startsWith("[") || conf.emoticons.startsWith("{")) {
if (conf.emoticons.startsWith('[') || conf.emoticons.startsWith('{')) {
conf.emoticons = JSON.parse(conf.emoticons) // pase json
} else if (conf.emoticons === "false") {
} else if (conf.emoticons === 'false') {
conf.emoticons = false
}
}
@@ -92,7 +98,10 @@ export function handleConfFormServer(conf: Partial<ArtalkConfig>) {
* @param ctx - If `ctx` not provided, `checkAdmin` and `checkCaptcha` will be disabled
* @returns ApiOptions for Api client instance creation
*/
export function convertApiOptions(conf: Partial<ArtalkConfig>, ctx?: ContextApi): ApiOptions {
export function convertApiOptions(
conf: Partial<ArtalkConfig>,
ctx?: ContextApi,
): ApiOptions {
return {
baseURL: `${conf.server}/api`,
siteName: conf.site || '',
@@ -100,15 +109,21 @@ export function convertApiOptions(conf: Partial<ArtalkConfig>, ctx?: ContextApi)
pageTitle: conf.pageTitle || '',
timeout: conf.reqTimeout,
apiToken: ctx?.get('user').getData().token,
userInfo: ctx?.get('user').checkHasBasicUserInfo() ? {
name: ctx?.get('user').getData().nick,
email: ctx?.get('user').getData().email,
} : undefined,
userInfo: ctx?.get('user').checkHasBasicUserInfo()
? {
name: ctx?.get('user').getData().nick,
email: ctx?.get('user').getData().email,
}
: undefined,
onNeedCheckAdmin(payload) {
ctx?.checkAdmin({
onSuccess: () => { payload.recall() },
onCancel: () => { payload.reject() },
onSuccess: () => {
payload.recall()
},
onCancel: () => {
payload.reject()
},
})
},
@@ -116,8 +131,12 @@ export function convertApiOptions(conf: Partial<ArtalkConfig>, ctx?: ContextApi)
ctx?.checkCaptcha({
imgData: payload.data.imgData,
iframe: payload.data.iframe,
onSuccess: () => { payload.recall() },
onCancel: () => { payload.reject() },
onSuccess: () => {
payload.recall()
},
onCancel: () => {
payload.reject()
},
})
},
}
+11 -4
View File
@@ -1,4 +1,11 @@
import type { ArtalkConfig, CommentData, ListFetchParams, ContextApi, EventPayloadMap, SidebarShowPayload } from '~/types'
import type {
ArtalkConfig,
CommentData,
ListFetchParams,
ContextApi,
EventPayloadMap,
SidebarShowPayload,
} from '~/types'
import type { TInjectedServices } from './service'
import Api from './api'
@@ -13,7 +20,7 @@ import EventManager from './lib/event-manager'
import { convertApiOptions, handelCustomConf } from './config'
// Auto dependency injection
interface Context extends TInjectedServices { }
interface Context extends TInjectedServices {}
/**
* Artalk Context
@@ -122,11 +129,11 @@ class Context implements ContextApi {
}
/* i18n */
public $t(key: I18n.I18nKeys, args: {[key: string]: string} = {}): string {
public $t(key: I18n.I18nKeys, args: { [key: string]: string } = {}): string {
return I18n.t(key, args)
}
public setDarkMode(darkMode: boolean|'auto'): void {
public setDarkMode(darkMode: boolean | 'auto'): void {
// prevent trigger 'conf-loaded' to improve performance
// this.updateConf({ ...this.conf, darkMode })
this.conf.darkMode = darkMode
+14 -8
View File
@@ -1,4 +1,12 @@
import type { NotifyData, PageData, CommentData, DataManagerApi, ListFetchParams, ListLastFetchData, EventPayloadMap } from '~/types'
import type {
NotifyData,
PageData,
CommentData,
DataManagerApi,
ListFetchParams,
ListLastFetchData,
EventPayloadMap,
} from '~/types'
import EventManager from './lib/event-manager'
export class DataManager implements DataManagerApi {
@@ -8,9 +16,7 @@ export class DataManager implements DataManagerApi {
private unreads: NotifyData[] = []
private page?: PageData
constructor(
protected events: EventManager<EventPayloadMap>
) {}
constructor(protected events: EventManager<EventPayloadMap>) {}
getLoading() {
return this.loading
@@ -40,7 +46,7 @@ export class DataManager implements DataManagerApi {
}
findComment(id: number) {
return this.comments.find(c => c.id === id)
return this.comments.find((c) => c.id === id)
}
clearComments() {
@@ -64,7 +70,7 @@ export class DataManager implements DataManagerApi {
}
updateComment(comment: CommentData) {
this.comments = this.comments.map(c => {
this.comments = this.comments.map((c) => {
if (c.id === comment.id) return comment
return c
})
@@ -74,9 +80,9 @@ export class DataManager implements DataManagerApi {
}
deleteComment(id: number) {
const comment = this.comments.find(c => c.id === id)
const comment = this.comments.find((c) => c.id === id)
if (!comment) throw new Error(`Comment ${id} not found`)
this.comments = this.comments.filter(c => c.id !== id)
this.comments = this.comments.filter((c) => c.id !== id)
this.events.trigger('comment-deleted', comment)
this.events.trigger('list-loaded', this.comments)
+1 -1
View File
@@ -17,7 +17,7 @@ const defaults: ArtalkConfig = {
nestMax: 2,
nestSort: 'DATE_ASC',
emoticons: "https://cdn.jsdelivr.net/gh/ArtalkJS/Emoticons/grps/default.json",
emoticons: 'https://cdn.jsdelivr.net/gh/ArtalkJS/Emoticons/grps/default.json',
vote: true,
voteDown: false,
+4 -4
View File
@@ -1,13 +1,13 @@
<div class="atk-main-editor">
<div class="atk-header">
<input name="nick" class="atk-nick" type="text" required="required">
<input name="email" class="atk-email" type="email" required="required">
<input name="link" class="atk-link" type="url">
<input name="nick" class="atk-nick" type="text" required="required" />
<input name="email" class="atk-email" type="email" required="required" />
<input name="link" class="atk-link" type="url" />
</div>
<div class="atk-textarea-wrap">
<textarea class="atk-textarea"></textarea>
</div>
<div class="atk-plug-panel-wrap" style="display: none;"></div>
<div class="atk-plug-panel-wrap" style="display: none"></div>
<div class="atk-bottom">
<div class="atk-item atk-plug-btn-wrap"></div>
<div class="atk-item">
+23 -7
View File
@@ -9,9 +9,15 @@ class Editor extends Component implements EditorApi {
private ui: EditorUI
private state: EditorStateManager
getUI() { return this.ui }
getPlugs() { return this.ctx.get('editorPlugs') }
getState() { return this.state.get() }
getUI() {
return this.ui
}
getPlugs() {
return this.ctx.get('editorPlugs')
}
getState() {
return this.state.get()
}
constructor(ctx: ContextApi) {
super(ctx)
@@ -55,14 +61,24 @@ class Editor extends Component implements EditorApi {
insertContent(val: string) {
if ((document as any).selection) {
this.ui.$textarea.focus();
(document as any).selection.createRange().text = val
this.ui.$textarea.focus()
} else if (this.ui.$textarea.selectionStart || this.ui.$textarea.selectionStart === 0) {
;(document as any).selection.createRange().text = val
this.ui.$textarea.focus()
} else if (
this.ui.$textarea.selectionStart ||
this.ui.$textarea.selectionStart === 0
) {
const sStart = this.ui.$textarea.selectionStart
const sEnd = this.ui.$textarea.selectionEnd
const sT = this.ui.$textarea.scrollTop
this.setContent(this.ui.$textarea.value.substring(0, sStart) + val + this.ui.$textarea.value.substring(sEnd, this.ui.$textarea.value.length))
this.setContent(
this.ui.$textarea.value.substring(0, sStart) +
val +
this.ui.$textarea.value.substring(
sEnd,
this.ui.$textarea.value.length,
),
)
this.ui.$textarea.focus()
this.ui.$textarea.selectionStart = sStart + val.length
this.ui.$textarea.selectionEnd = sStart + val.length
+14 -5
View File
@@ -7,7 +7,7 @@ export default class EditorStateManager {
constructor(private editor: Editor) {}
private stateCurt: EditorState = 'normal'
private stateUnmountFn: (() => void)|null = null
private stateUnmountFn: (() => void) | null = null
/** Get current state */
get() {
@@ -20,7 +20,10 @@ export default class EditorStateManager {
* @param state The state to switch
* @param payload The cause of state switch
*/
switch(state: EditorState, payload?: { $comment: HTMLElement, comment: CommentData }) {
switch(
state: EditorState,
payload?: { $comment: HTMLElement; comment: CommentData },
) {
// trigger unmount
if (this.stateUnmountFn) {
this.stateUnmountFn()
@@ -34,13 +37,19 @@ export default class EditorStateManager {
if (state !== 'normal' && payload) {
// move editor position
let moveAfterEl = payload.$comment
if (!this.editor.conf.flatMode) moveAfterEl = moveAfterEl.querySelector<HTMLElement>('.atk-footer')!
if (!this.editor.conf.flatMode)
moveAfterEl = moveAfterEl.querySelector<HTMLElement>('.atk-footer')!
this.editor.getPlugs()?.get(Mover)?.move(moveAfterEl)
const $relative = this.editor.ctx.conf.scrollRelativeTo && this.editor.ctx.conf.scrollRelativeTo()
const $relative =
this.editor.ctx.conf.scrollRelativeTo &&
this.editor.ctx.conf.scrollRelativeTo()
Ui.scrollIntoView(this.editor.getUI().$el, true, $relative)
const plugin = this.editor.getPlugs()?.getPlugs().find(p => p.editorStateEffectWhen === state)
const plugin = this.editor
.getPlugs()
?.getPlugs()
.find((p) => p.editorStateEffectWhen === state)
if (plugin && plugin.editorStateEffect) {
this.stateUnmountFn = plugin.editorStateEffect(payload.comment)
}
+10 -3
View File
@@ -1,10 +1,17 @@
import type { I18n } from '.'
export const GLOBAL_LOCALES_KEY = "ArtalkI18n"
export const GLOBAL_LOCALES_KEY = 'ArtalkI18n'
export function defineLocaleExternal(lang: string, locale: I18n, aliases?: string[]) {
export function defineLocaleExternal(
lang: string,
locale: I18n,
aliases?: string[],
) {
if (!window[GLOBAL_LOCALES_KEY]) window[GLOBAL_LOCALES_KEY] = {}
window[GLOBAL_LOCALES_KEY][lang] = locale
if (aliases) aliases.forEach(l => { window[GLOBAL_LOCALES_KEY][l] = locale })
if (aliases)
aliases.forEach((l) => {
window[GLOBAL_LOCALES_KEY][l] = locale
})
return locale
}
+6 -6
View File
@@ -9,7 +9,7 @@ export type I18nKeys = keyof I18n
// https://en.wikipedia.org/wiki/ISO_639
// https://datatracker.ietf.org/doc/html/rfc5646#section-2.1
export const internal = {
'en': en,
en: en,
'en-US': en,
'zh-CN': zhCN,
}
@@ -21,7 +21,7 @@ export function findLocaleSet(lang: string): I18n {
// normalize a key of language to `ISO 639`
lang = lang.replace(
/^([a-zA-Z]+)(-[a-zA-Z]+)?$/,
(_, p1: string, p2: string) => (p1.toLowerCase() + (p2 || '').toUpperCase())
(_, p1: string, p2: string) => p1.toLowerCase() + (p2 || '').toUpperCase(),
)
// internal finding
@@ -42,22 +42,22 @@ export function findLocaleSet(lang: string): I18n {
/**
* System locale setting
*/
let LocaleConf: I18n|string = 'en'
let LocaleConf: I18n | string = 'en'
let LocaleDict: I18n = findLocaleSet(LocaleConf) // en by default
/**
* Set system locale
*/
export function setLocale(locale: I18n|string) {
export function setLocale(locale: I18n | string) {
if (locale === LocaleConf) return
LocaleConf = locale
LocaleDict = (typeof locale === 'string') ? findLocaleSet(locale) : locale
LocaleDict = typeof locale === 'string' ? findLocaleSet(locale) : locale
}
/**
* Get an i18n message by key
*/
export function t(key: I18nKeys, args: {[key: string]: string} = {}) {
export function t(key: I18nKeys, args: { [key: string]: string } = {}) {
let str = LocaleDict?.[key] || key
str = str.replace(/\{\s*(\w+?)\s*\}/g, (_, token) => args[token] || '')
+84 -78
View File
@@ -1,86 +1,92 @@
import { defineLocaleExternal } from './external'
export default defineLocaleExternal('jp', {
/* エディタ */
placeholder: 'コメントを残す',
noComment: 'コメントなし',
send: '送信',
save: '保存',
nick: 'ニックネーム',
email: 'メール',
link: 'ウェブサイト',
emoticon: '絵文字',
preview: 'プレビュー',
image: '画像',
uploadFail: 'アップロードに失敗しました',
commentFail: 'コメントに失敗しました',
restoredMsg: 'コンテンツが復元されました',
onlyAdminCanReply: '管理者のみが返信できます',
uploadLoginMsg: 'アップロードするには、名前とメールアドレスを入力してください',
export default defineLocaleExternal(
'jp',
{
/* エディタ */
placeholder: 'コメントを残す',
noComment: 'コメントなし',
send: '送信',
save: '保存',
nick: 'ニックネーム',
email: 'メール',
link: 'ウェブサイト',
emoticon: '絵文字',
preview: 'プレビュー',
image: '画像',
uploadFail: 'アップロードに失敗しました',
commentFail: 'コメントに失敗しました',
restoredMsg: 'コンテンツが復元されました',
onlyAdminCanReply: '管理者のみが返信できます',
uploadLoginMsg:
'アップロードするには、名前とメールアドレスを入力してください',
/* リスト */
counter: '{count} コメント',
sortLatest: '最新のもの',
sortOldest: '最も古い',
sortBest: 'ベスト',
sortAuthor: '著者',
openComment: 'コメントを開く',
closeComment: 'コメントを閉じる',
listLoadFailMsg: 'コメントの読み込みに失敗しました',
listRetry: 'クリックで再試行',
loadMore: 'もっと読み込む',
/* リスト */
counter: '{count} コメント',
sortLatest: '最新のもの',
sortOldest: '最も古い',
sortBest: 'ベスト',
sortAuthor: '著者',
openComment: 'コメントを開く',
closeComment: 'コメントを閉じる',
listLoadFailMsg: 'コメントの読み込みに失敗しました',
listRetry: 'クリックで再試行',
loadMore: 'もっと読み込む',
/* コメント */
admin: '管理者',
reply: '返信',
voteUp: 'アップ',
voteDown: 'ダウン',
voteFail: '投票に失敗しました',
readMore: '続きを読む',
actionConfirm: '確認',
collapse: '折りたたむ',
collapsed: '折りたたまれた',
collapsedMsg: 'このコメントは折りたたまれました',
expand: '展開',
approved: '承認されました',
pending: '保留中',
pendingMsg: '保留中、コメント投稿者にのみ表示されます',
edit: '編集',
editCancel: '編集をキャンセル',
delete: '削除',
deleteConfirm: '確認',
pin: 'ピン',
unpin: 'ピン外し',
/* コメント */
admin: '管理者',
reply: '返信',
voteUp: 'アップ',
voteDown: 'ダウン',
voteFail: '投票に失敗しました',
readMore: '続きを読む',
actionConfirm: '確認',
collapse: '折りたたむ',
collapsed: '折りたたまれた',
collapsedMsg: 'このコメントは折りたたまれました',
expand: '展開',
approved: '承認されました',
pending: '保留中',
pendingMsg: '保留中、コメント投稿者にのみ表示されます',
edit: '編集',
editCancel: '編集をキャンセル',
delete: '削除',
deleteConfirm: '確認',
pin: 'ピン',
unpin: 'ピン外し',
/* 時間 */
seconds: '秒前',
minutes: '分前',
hours: '時間前',
days: '数日前',
now: 'たった今',
/* 時間 */
seconds: '秒前',
minutes: '分前',
hours: '時間前',
days: '数日前',
now: 'たった今',
/* チェッカー */
adminCheck: '管理者パスワードを入力してください:',
captchaCheck: '続けるにはCAPTCHAを入力してください:',
confirm: '確認',
cancel: '取消',
/* チェッカー */
adminCheck: '管理者パスワードを入力してください:',
captchaCheck: '続けるにはCAPTCHAを入力してください:',
confirm: '確認',
cancel: '取消',
/* サイドバー */
msgCenter: 'メッセージ',
ctrlCenter: 'コンソール',
/* サイドバー */
msgCenter: 'メッセージ',
ctrlCenter: 'コンソール',
/* 一般 */
frontend: 'フロントエンド',
backend: 'バックエンド',
loading: '読み込み中',
loadFail: '読み込みに失敗しました',
editing: '編集中',
editFail: '編集に失敗しました',
deleting: '削除中',
deleteFail: '削除に失敗しました',
reqGot: 'リクエストを取得しました',
reqAborted: 'リクエストがタイムアウトしたか、予期せず終了した',
updateMsg: 'フルな体験をするためには、Artalk {name} をアップデートしてください',
currentVersion: '現在のバージョン',
ignore: '無視する',
}, ['jp-JP'])
/* 一般 */
frontend: 'フロントエンド',
backend: 'バックエンド',
loading: '読み込み中',
loadFail: '読み込みに失敗しました',
editing: '編集中',
editFail: '編集に失敗しました',
deleting: '削除中',
deleteFail: '削除に失敗しました',
reqGot: 'リクエストを取得しました',
reqAborted: 'リクエストがタイムアウトしたか、予期せず終了した',
updateMsg:
'フルな体験をするためには、Artalk {name} をアップデートしてください',
currentVersion: '現在のバージョン',
ignore: '無視する',
},
['jp-JP'],
)
@@ -17,8 +17,15 @@ export function getScrollbarHelper() {
lock() {
document.body.style.overflow = 'hidden'
const barPaddingRight = parseInt(window.getComputedStyle(document.body, null).getPropertyValue('padding-right'), 10)
document.body.style.paddingRight = `${Ui.getScrollBarWidth() + barPaddingRight || 0}px`
}
const barPaddingRight = parseInt(
window
.getComputedStyle(document.body, null)
.getPropertyValue('padding-right'),
10,
)
document.body.style.paddingRight = `${
Ui.getScrollBarWidth() + barPaddingRight || 0
}px`
},
}
}
@@ -1,7 +1,9 @@
<div class="atk-sidebar-layer">
<div class="atk-sidebar-inner">
<div class="atk-sidebar-header">
<div class="atk-sidebar-close"><i class="atk-icon atk-icon-close"></i></div>
<div class="atk-sidebar-close">
<i class="atk-icon atk-icon-close"></i>
</div>
</div>
<div class="atk-sidebar-iframe-wrap"></div>
</div>
+12 -9
View File
@@ -25,7 +25,9 @@ export default class SidebarLayer extends Component {
}
// event
this.ctx.on('user-changed', () => { this.firstShow = true })
this.ctx.on('user-changed', () => {
this.firstShow = true
})
}
private firstShow = true
@@ -40,7 +42,7 @@ export default class SidebarLayer extends Component {
// 管理员身份验证 (若身份失效,弹出验证窗口)
this.authCheck({
onSuccess: () => this.show(conf) // retry show after auth check
onSuccess: () => this.show(conf), // retry show after auth check
})
// 第一次加载
@@ -100,7 +102,7 @@ export default class SidebarLayer extends Component {
},
onCancel: () => {
this.hide()
}
},
})
}
}
@@ -119,24 +121,25 @@ export default class SidebarLayer extends Component {
const $iframe = Utils.createElement<HTMLIFrameElement>('<iframe></iframe>')
// 准备 Iframe 参数
const baseURL = (import.meta.env.DEV) ? 'http://localhost:23367/'
const baseURL = import.meta.env.DEV
? 'http://localhost:23367/'
: Utils.getURLBasedOnApi({
base: this.ctx.conf.server,
path: '/sidebar/',
})
base: this.ctx.conf.server,
path: '/sidebar/',
})
const query: any = {
pageKey: this.conf.pageKey,
site: this.conf.site || '',
user: JSON.stringify(this.ctx.get('user').getData()),
time: +new Date()
time: +new Date(),
}
if (view) query.view = view
if (this.conf.darkMode) query.darkMode = '1'
if (typeof this.conf.locale === 'string') query.locale = this.conf.locale
const urlParams = new URLSearchParams(query);
const urlParams = new URLSearchParams(query)
this.iframeLoad($iframe, `${baseURL}?${urlParams.toString()}`)
return $iframe
+5 -2
View File
@@ -9,7 +9,7 @@ export class LayerWrap {
constructor() {
this.$wrap = Utils.createElement(
`<div class="atk-layer-wrap" style="display: none;"><div class="atk-layer-mask"></div></div>`
`<div class="atk-layer-wrap" style="display: none;"><div class="atk-layer-mask"></div></div>`,
)
this.$mask = this.$wrap.querySelector<HTMLElement>('.atk-layer-mask')!
}
@@ -50,7 +50,10 @@ export class LayerWrap {
hide(callback?: () => void) {
// if wrap contains more than one item, do not hide entire wrap
if (this.items.filter(e => e.isConnected && e.style.display !== 'none').length > 1) {
if (
this.items.filter((e) => e.isConnected && e.style.display !== 'none')
.length > 1
) {
callback && callback()
return
}
+1 -3
View File
@@ -4,9 +4,7 @@ export default abstract class Component {
public $el!: HTMLElement
public readonly conf: ArtalkConfig
public constructor(
public ctx: ContextApi
) {
public constructor(public ctx: ContextApi) {
this.conf = ctx.conf
}
}
+29 -15
View File
@@ -10,7 +10,7 @@ function Detect(userAgent?: string) {
browser: '',
device: '',
language: '',
version: ''
version: '',
}
// 内核
@@ -18,7 +18,7 @@ function Detect(userAgent?: string) {
Trident: u.includes('Trident') || u.includes('NET CLR'),
Presto: u.includes('Presto'),
WebKit: u.includes('AppleWebKit'),
Gecko: u.includes('Gecko/')
Gecko: u.includes('Gecko/'),
}
// 浏览器
@@ -60,7 +60,7 @@ function Detect(userAgent?: string) {
const osMatch = {
Windows: u.includes('Windows'),
Linux: u.includes('Linux') || u.includes('X11'),
'macOS': u.includes('Macintosh'),
macOS: u.includes('Macintosh'),
Android: u.includes('Android') || u.includes('Adr'),
Ubuntu: u.includes('Ubuntu'),
FreeBSD: u.includes('FreeBSD'),
@@ -77,12 +77,12 @@ function Detect(userAgent?: string) {
// 设备
const deviceMatch = {
Mobile: u.includes('Mobi') || u.includes('iPh') || u.includes('480'),
Tablet: u.includes('Tablet') || u.includes('Pad') || u.includes('Nexus 7')
Tablet: u.includes('Tablet') || u.includes('Pad') || u.includes('Nexus 7'),
}
// 修正
if (deviceMatch.Mobile) {
deviceMatch.Mobile = !(u.includes('iPad'))
deviceMatch.Mobile = !u.includes('iPad')
} else if (browserMatch.Chrome && u.includes('Edg')) {
// Chrome 内核的 Edge
browserMatch.Chrome = false
@@ -97,7 +97,7 @@ function Detect(userAgent?: string) {
// 语言
dest.language = (() => {
const g = ((nav as any).browserLanguage || nav.language)
const g = (nav as any).browserLanguage || nav.language
const arr = g.split('-')
if (arr[1]) arr[1] = arr[1].toUpperCase()
return arr.join('_')
@@ -130,16 +130,17 @@ function Detect(userAgent?: string) {
'5.1': 'XP',
'5.0': '2000',
'10.0': '10',
'11.0': '11' // 自定的,不是微软官方的判断方法
'11.0': '11', // 自定的,不是微软官方的判断方法
}
return wvHash[v] || v
},
Android: () => u.replace(/^.*Android ([\d.]+);.*$/, '$1'),
iOS: () => u.replace(/^.*OS ([\d_]+) like.*$/, '$1').replace(/_/g, '.'),
Debian: () => u.replace(/^.*Debian\/([\d.]+).*$/, '$1'),
'Windows Phone': () => u.replace(/^.*Windows Phone( OS)? ([\d.]+);.*$/, '$2'),
'macOS': () => u.replace(/^.*Mac OS X ([\d_]+).*$/, '$1').replace(/_/g, '.'),
WebOS: () => u.replace(/^.*hpwOS\/([\d.]+);.*$/, '$1')
'Windows Phone': () =>
u.replace(/^.*Windows Phone( OS)? ([\d.]+);.*$/, '$2'),
macOS: () => u.replace(/^.*Mac OS X ([\d_]+).*$/, '$1').replace(/_/g, '.'),
WebOS: () => u.replace(/^.*hpwOS\/([\d.]+);.*$/, '$1'),
}
dest.osVersion = ''
@@ -153,13 +154,23 @@ function Detect(userAgent?: string) {
// 浏览器版本信息
const version = {
Safari: () => u.replace(/^.*Version\/([\d.]+).*$/, '$1'),
Chrome: () => u.replace(/^.*Chrome\/([\d.]+).*$/, '$1').replace(/^.*CriOS\/([\d.]+).*$/, '$1'),
IE: () => u.replace(/^.*MSIE ([\d.]+).*$/, '$1').replace(/^.*rv:([\d.]+).*$/, '$1'),
Chrome: () =>
u
.replace(/^.*Chrome\/([\d.]+).*$/, '$1')
.replace(/^.*CriOS\/([\d.]+).*$/, '$1'),
IE: () =>
u.replace(/^.*MSIE ([\d.]+).*$/, '$1').replace(/^.*rv:([\d.]+).*$/, '$1'),
Edge: () => u.replace(/^.*(Edge|Edg|Edg[A-Z]{1})\/([\d.]+).*$/, '$2'),
Firefox: () => u.replace(/^.*Firefox\/([\d.]+).*$/, '$1').replace(/^.*FxiOS\/([\d.]+).*$/, '$1'),
Firefox: () =>
u
.replace(/^.*Firefox\/([\d.]+).*$/, '$1')
.replace(/^.*FxiOS\/([\d.]+).*$/, '$1'),
'Firefox Focus': () => u.replace(/^.*Focus\/([\d.]+).*$/, '$1'),
Chromium: () => u.replace(/^.*Chromium\/([\d.]+).*$/, '$1'),
Opera: () => u.replace(/^.*Opera\/([\d.]+).*$/, '$1').replace(/^.*OPR\/([\d.]+).*$/, '$1'),
Opera: () =>
u
.replace(/^.*Opera\/([\d.]+).*$/, '$1')
.replace(/^.*OPR\/([\d.]+).*$/, '$1'),
Vivaldi: () => u.replace(/^.*Vivaldi\/([\d.]+).*$/, '$1'),
Yandex: () => u.replace(/^.*YaBrowser\/([\d.]+).*$/, '$1'),
Kindle: () => u.replace(/^.*Version\/([\d.]+).*$/, '$1'),
@@ -168,7 +179,10 @@ function Detect(userAgent?: string) {
QQ: () => u.replace(/^.*QQ\/([\d.]+).*$/, '$1'),
Baidu: () => u.replace(/^.*BIDUBrowser[\s/]([\d.]+).*$/, '$1'),
UC: () => u.replace(/^.*UC?Browser\/([\d.]+).*$/, '$1'),
Sogou: () => u.replace(/^.*SE ([\d.X]+).*$/, '$1').replace(/^.*SogouMobileBrowser\/([\d.]+).*$/, '$1'),
Sogou: () =>
u
.replace(/^.*SE ([\d.X]+).*$/, '$1')
.replace(/^.*SogouMobileBrowser\/([\d.]+).*$/, '$1'),
'2345Explorer': () => u.replace(/^.*2345Explorer\/([\d.]+).*$/, '$1'),
TheWorld: () => u.replace(/^.*TheWorld ([\d.]+).*$/, '$1'),
MIUI: () => u.replace(/^.*MiuiBrowser\/([\d.]+).*$/, '$1'),
+33 -9
View File
@@ -1,5 +1,8 @@
export type EventHandler<T> = (payload: T) => void
export interface Event<PayloadMap, K extends keyof PayloadMap = keyof PayloadMap> extends EventOptions {
export interface Event<
PayloadMap,
K extends keyof PayloadMap = keyof PayloadMap,
> extends EventOptions {
name: K
handler: EventHandler<PayloadMap[K]>
}
@@ -8,28 +11,49 @@ export interface EventOptions {
}
export interface EventManagerFuncs<PayloadMap> {
on<K extends keyof PayloadMap>(name: K, handler: EventHandler<PayloadMap[K]>, opts?: EventOptions): void
off<K extends keyof PayloadMap>(name: K, handler: EventHandler<PayloadMap[K]>): void
on<K extends keyof PayloadMap>(
name: K,
handler: EventHandler<PayloadMap[K]>,
opts?: EventOptions,
): void
off<K extends keyof PayloadMap>(
name: K,
handler: EventHandler<PayloadMap[K]>,
): void
trigger<K extends keyof PayloadMap>(name: K, payload?: PayloadMap[K]): void
}
export default class EventManager<PayloadMap> implements EventManagerFuncs<PayloadMap> {
export default class EventManager<PayloadMap>
implements EventManagerFuncs<PayloadMap>
{
private events: Event<PayloadMap>[] = []
/**
* Add an event listener for a specific event name
*/
public on<K extends keyof PayloadMap>(name: K, handler: EventHandler<PayloadMap[K]>, opts: EventOptions = {}) {
this.events.push({ name, handler: handler as EventHandler<PayloadMap[keyof PayloadMap]>, ...opts })
public on<K extends keyof PayloadMap>(
name: K,
handler: EventHandler<PayloadMap[K]>,
opts: EventOptions = {},
) {
this.events.push({
name,
handler: handler as EventHandler<PayloadMap[keyof PayloadMap]>,
...opts,
})
}
/**
* Remove an event listener for a specific event name and handler
*/
public off<K extends keyof PayloadMap>(name: K, handler: EventHandler<PayloadMap[K]>) {
public off<K extends keyof PayloadMap>(
name: K,
handler: EventHandler<PayloadMap[K]>,
) {
if (!handler) return // not allow remove all events with same name
this.events = this.events.filter((evt) =>
!(evt.name === name && evt.handler === handler))
this.events = this.events.filter(
(evt) => !(evt.name === name && evt.handler === handler),
)
}
/**
+36 -20
View File
@@ -8,25 +8,41 @@ export function getRenderer() {
return renderer
}
export const markedLinkRenderer = (renderer: any, orgLinkRenderer: Function) => (href: string, title: string, text: string): string => {
const localLink = href?.startsWith(`${window.location.protocol}//${window.location.hostname}`);
const html = orgLinkRenderer.call(renderer, href, title, text);
return html.replace(/^<a /, `<a target="_blank" ${!localLink ? `rel="noreferrer noopener nofollow"` : ''} `);
}
export const markedCodeRenderer = () => (block: string, lang: string | undefined): string => {
// Colorize the block only if the language is known to highlight.js
const realLang = (!lang ? 'plaintext' : lang)
let colorized = block
if ((window as any).hljs) {
if (realLang && (window as any).hljs.getLanguage(realLang)) {
colorized = (window as any).hljs.highlight(realLang, block).value
}
} else {
colorized = renderCode(block)
export const markedLinkRenderer =
(renderer: any, orgLinkRenderer: Function) =>
(href: string, title: string, text: string): string => {
const localLink = href?.startsWith(
`${window.location.protocol}//${window.location.hostname}`,
)
const html = orgLinkRenderer.call(renderer, href, title, text)
return html.replace(
/^<a /,
`<a target="_blank" ${
!localLink ? `rel="noreferrer noopener nofollow"` : ''
} `,
)
}
return `<pre rel="${realLang}">\n`
+ `<code class="hljs language-${realLang}">${colorized.replace(/&amp;/g, '&')}</code>\n`
+ `</pre>`
}
export const markedCodeRenderer =
() =>
(block: string, lang: string | undefined): string => {
// Colorize the block only if the language is known to highlight.js
const realLang = !lang ? 'plaintext' : lang
let colorized = block
if ((window as any).hljs) {
if (realLang && (window as any).hljs.getLanguage(realLang)) {
colorized = (window as any).hljs.highlight(realLang, block).value
}
} else {
colorized = renderCode(block)
}
return (
`<pre rel="${realLang}">\n` +
`<code class="hljs language-${realLang}">${colorized.replace(
/&amp;/g,
'&',
)}</code>\n` +
`</pre>`
)
}
+28 -13
View File
@@ -7,7 +7,7 @@ import { getRenderer } from './marked-renderer'
type Replacer = (raw: string) => string
export type TMarked = typeof libMarked
let instance: (typeof libMarked)|undefined
let instance: typeof libMarked | undefined
let replacers: Replacer[] = []
const markedOptions: MarkedOptions = {
@@ -32,12 +32,16 @@ export function setReplacers(arr: Replacer[]) {
/** 初始化 marked */
export function initMarked() {
try { if (!libMarked.name) return } catch { return }
try {
if (!libMarked.name) return
} catch {
return
}
// @see https://github.com/markedjs/marked/blob/4afb228d956a415624c4e5554bb8f25d047676fe/src/Tokenizer.js#L329
libMarked.setOptions({
renderer: getRenderer(),
...markedOptions
...markedOptions,
})
instance = libMarked
@@ -46,7 +50,8 @@ export function initMarked() {
/** 解析 markdown */
export default function marked(src: string): string {
let markedContent = getInstance()?.parse(src)
if (!markedContent) { // 无 Markdown 模式简单处理
if (!markedContent) {
// 无 Markdown 模式简单处理
markedContent = simpleMarked(src)
}
@@ -54,19 +59,29 @@ export default function marked(src: string): string {
// 内容替换器
replacers.forEach((replacer) => {
if (typeof replacer === 'function')
dest = replacer(dest)
if (typeof replacer === 'function') dest = replacer(dest)
})
return dest
}
function simpleMarked(src: string) {
return src
// .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/```\s*([^]+?.*?[^]+?[^]+?)```/g, (_, code) => `<pre><code>${renderCode(code)}</code></pre>`)
// .replace(/`([^`]+?)`/g, '<code>$1</code>')
.replace(/!\[(.*?)\]\((.*?)\)/g, (_, alt, imgSrc) => `<img src="${imgSrc}" alt="${alt}" />`)
.replace(/\[(.*?)\]\((.*?)\)/g, (_, text, link) => `<a href="${link}" target="_blank">${text}</a>`)
.replace(/\n/g, '<br>')
return (
src
// .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(
/```\s*([^]+?.*?[^]+?[^]+?)```/g,
(_, code) => `<pre><code>${renderCode(code)}</code></pre>`,
)
// .replace(/`([^`]+?)`/g, '<code>$1</code>')
.replace(
/!\[(.*?)\]\((.*?)\)/g,
(_, alt, imgSrc) => `<img src="${imgSrc}" alt="${alt}" />`,
)
.replace(
/\[(.*?)\]\((.*?)\)/g,
(_, text, link) => `<a href="${link}" target="_blank">${text}</a>`,
)
.replace(/\n/g, '<br>')
)
}
+61 -16
View File
@@ -7,14 +7,56 @@ const insaneOptions = {
// @link https://leucosite.com/Microsoft-Edge-RCE/
// @link https://medium.com/@knownsec404team/analysis-of-the-security-issues-of-url-scheme-in-pc-from-cve-2018-8495-934478a36756
allowedSchemes: [
'http', 'https', 'mailto',
'data' // for support base64 encoded image (安全性有待考虑)
'http',
'https',
'mailto',
'data', // for support base64 encoded image (安全性有待考虑)
],
allowedTags: [
'a', 'abbr', 'article', 'b', 'blockquote', 'br', 'caption', 'code', 'del', 'details', 'div', 'em',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'main', 'mark',
'ol', 'p', 'pre', 'section', 'span', 'strike', 'strong', 'sub', 'summary', 'sup', 'table',
'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'
'a',
'abbr',
'article',
'b',
'blockquote',
'br',
'caption',
'code',
'del',
'details',
'div',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'ins',
'kbd',
'li',
'main',
'mark',
'ol',
'p',
'pre',
'section',
'span',
'strike',
'strong',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'u',
'ul',
],
allowedAttributes: {
'*': ['title', 'accesskey'],
@@ -24,30 +66,33 @@ const insaneOptions = {
code: ['class'],
span: ['class', 'style'],
},
filter: node => {
filter: (node) => {
// allow hljs style
const allowed = [
[ 'code', /^hljs\W+language-(.*)$/ ],
[ 'span', /^(hljs-.*)$/ ]
['code', /^hljs\W+language-(.*)$/],
['span', /^(hljs-.*)$/],
]
allowed.forEach(([ tag, reg ]) => {
allowed.forEach(([tag, reg]) => {
if (
node.tag === tag
&& !!node.attrs.class
&& !(reg as RegExp).test(node.attrs.class)
node.tag === tag &&
!!node.attrs.class &&
!(reg as RegExp).test(node.attrs.class)
) {
delete node.attrs.class
}
})
// allow <span> set color sty
if (node.tag === 'span' && !!node.attrs.style
&& !/^color:(\W+)?#[0-9a-f]{3,6};?$/i.test(node.attrs.style)) {
if (
node.tag === 'span' &&
!!node.attrs.style &&
!/^color:(\W+)?#[0-9a-f]{3,6};?$/i.test(node.attrs.style)
) {
delete node.attrs.style
}
return true
}
},
}
export function sanitize(content: string): string {
+35 -15
View File
@@ -2,16 +2,20 @@ import type { NotifyLevel } from '~/types'
import * as Utils from './utils'
/** 显示加载 */
export function showLoading(parentElem: HTMLElement, conf?: { transparentBg?: boolean }) {
export function showLoading(
parentElem: HTMLElement,
conf?: { transparentBg?: boolean },
) {
// Use :scope https://developer.mozilla.org/en-US/docs/Web/CSS/:scope
let $loading = parentElem.querySelector<HTMLElement>(':scope > .atk-loading')
if (!$loading) {
$loading = Utils.createElement(
`<div class="atk-loading atk-fade-in" style="display: none;">
`<div class="atk-loading atk-fade-in" style="display: none;">
<div class="atk-loading-spinner">
<svg viewBox="25 25 50 50"><circle cx="50" cy="50" r="20" fill="none" stroke-width="2" stroke-miterlimit="10"></circle></svg>
</div>
</div>`)
</div>`,
)
if (conf?.transparentBg) $loading.style.background = 'transparent'
parentElem.appendChild($loading)
}
@@ -29,7 +33,9 @@ export function showLoading(parentElem: HTMLElement, conf?: { transparentBg?: bo
/** 隐藏加载 */
export function hideLoading(parentElem: HTMLElement) {
const $loading = parentElem.querySelector<HTMLElement>(':scope > .atk-loading')
const $loading = parentElem.querySelector<HTMLElement>(
':scope > .atk-loading',
)
if ($loading) $loading.style.display = 'none'
}
@@ -40,7 +46,10 @@ export function setLoading(val: boolean, parentElem: HTMLElement) {
}
/** 元素是否用户可见 */
export function isVisible(el: HTMLElement, viewport: HTMLElement = document.documentElement) {
export function isVisible(
el: HTMLElement,
viewport: HTMLElement = document.documentElement,
) {
const viewportHeight = viewport.clientHeight
const docViewTop = viewport.scrollTop
@@ -50,11 +59,15 @@ export function isVisible(el: HTMLElement, viewport: HTMLElement = document.docu
const elemTop = rect.top + docViewTop
const elemBottom = elemTop + rect.height
return (elemBottom <= docViewBottom) //&& (elemTop >= docViewTop) 注释因为假如 el 比 viewport 还高就会失效
return elemBottom <= docViewBottom //&& (elemTop >= docViewTop) 注释因为假如 el 比 viewport 还高就会失效
}
/** 滚动到元素中心 */
export function scrollIntoView(elem: HTMLElement, enableAnim: boolean = true, relativeTo?: HTMLElement) {
export function scrollIntoView(
elem: HTMLElement,
enableAnim: boolean = true,
relativeTo?: HTMLElement,
) {
let top: number
if (relativeTo) {
@@ -69,9 +82,10 @@ export function scrollIntoView(elem: HTMLElement, enableAnim: boolean = true, re
}
const scrollOptions: ScrollToOptions = {
top, left: 0,
top,
left: 0,
// behavior: enableAnim ? 'smooth' : 'instant',
behavior: 'instant'
behavior: 'instant',
}
if (relativeTo) relativeTo.scroll(scrollOptions)
@@ -88,9 +102,11 @@ export function showNotify(
const timeout = 3000 // 持续显示时间 ms
const notifyElem = Utils.createElement(
`<div class="atk-notify atk-fade-in" style="background-color: ${colors[type]}"><span class="atk-notify-content"></span></div>`
`<div class="atk-notify atk-fade-in" style="background-color: ${colors[type]}"><span class="atk-notify-content"></span></div>`,
)
const notifyContentEl = notifyElem.querySelector<HTMLElement>('.atk-notify-content')!
const notifyContentEl = notifyElem.querySelector<HTMLElement>(
'.atk-notify-content',
)!
notifyContentEl.innerHTML = Utils.htmlEncode(msg).replace('\n', '<br/>')
wrapElem.appendChild(notifyElem)
@@ -119,7 +135,7 @@ export function showNotify(
export function playFadeAnim(
elem: HTMLElement,
after?: () => void,
type: 'in' | 'out' = 'in'
type: 'in' | 'out' = 'in',
) {
elem.classList.add(`atk-fade-${type}`)
// 动画结束清除 class
@@ -142,7 +158,11 @@ export function playFadeOutAnim(elem: HTMLElement, after?: () => void) {
}
/** 设置全局错误 */
export function setError(parentElem: HTMLElement, html: string | HTMLElement | null, title: string = '<span class="atk-error-title">Artalk Error</span>') {
export function setError(
parentElem: HTMLElement,
html: string | HTMLElement | null,
title: string = '<span class="atk-error-title">Artalk Error</span>',
) {
let elem = parentElem.querySelector<HTMLElement>('.atk-error-layer')
if (html === null) {
if (elem !== null) elem.remove()
@@ -150,7 +170,7 @@ export function setError(parentElem: HTMLElement, html: string | HTMLElement | n
}
if (!elem) {
elem = Utils.createElement(
`<div class="atk-error-layer">${title}<span class="atk-error-text"></span></div>`
`<div class="atk-error-layer">${title}<span class="atk-error-text"></span></div>`,
)
parentElem.appendChild(elem)
}
@@ -189,5 +209,5 @@ export function getScrollBarWidth() {
document.body.removeChild(outer)
return (w1 - w2)
return w1 - w2
}
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more