Compare commits
54
Commits
v2.4.2
...
master-copy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe196d857 | ||
|
|
77cf80a277 | ||
|
|
0c2366cf41 | ||
|
|
59dfd1f0ef | ||
|
|
3a3a28b209 | ||
|
|
9e0aaa29c4 | ||
|
|
f0d51b8baf | ||
|
|
f4c1cfde79 | ||
|
|
0f1a205766 | ||
|
|
738bd10b2e | ||
|
|
733c4801df | ||
|
|
c1b489e59d | ||
|
|
407f090e1f | ||
|
|
874972198a | ||
|
|
0050d5922c | ||
|
|
f8d6dd6891 | ||
|
|
9b3b6c97dd | ||
|
|
92d340ff9c | ||
|
|
703d7c530f | ||
|
|
d4461e3715 | ||
|
|
18c71b5fe8 | ||
|
|
2cdb56d04f | ||
|
|
df81a20f74 | ||
|
|
aec362743b | ||
|
|
9dccfe49e7 | ||
|
|
d4a8db8554 | ||
|
|
e85bdb5cf3 | ||
|
|
5382e98c0b | ||
|
|
0153ae79e3 | ||
|
|
30be52032a | ||
|
|
c52769ced5 | ||
|
|
f247782e40 | ||
|
|
b676c0511d | ||
|
|
3d819081f5 | ||
|
|
97d290ad5d | ||
|
|
44ef08d1d6 | ||
|
|
298c26cd56 | ||
|
|
768a04c833 | ||
|
|
7ef4cf9c52 | ||
|
|
336126cb14 | ||
|
|
5d3bb57f25 | ||
|
|
e23819400e | ||
|
|
e63d0b7ebb | ||
|
|
6365389129 | ||
|
|
eda835c7e7 | ||
|
|
2ae3ac05b5 | ||
|
|
392583ba61 | ||
|
|
177e002b99 | ||
|
|
4fde28675e | ||
|
|
cd60adbcef | ||
|
|
5bdb2c762c | ||
|
|
70c6ee24a4 | ||
|
|
caf690fe9e | ||
|
|
1de9565e55 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
version: 2.1
|
||||
orbs:
|
||||
node: circleci/node@5.0.2
|
||||
node: circleci/node@5.0.3
|
||||
jobs:
|
||||
build:
|
||||
docker:
|
||||
|
||||
@@ -21,6 +21,7 @@ module.exports = {
|
||||
'no-plusplus': 0,
|
||||
'no-param-reassign': 0,
|
||||
'no-console': 0,
|
||||
'no-underscore-dangle': 0,
|
||||
'no-constructor-return': 0,
|
||||
'class-methods-use-this': 0,
|
||||
'spaced-comment': 0,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "master" ]
|
||||
schedule:
|
||||
- cron: '40 17 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Use pnpm
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 7.11.0
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Use pnpm
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 7.11.0
|
||||
|
||||
|
||||
@@ -56,52 +56,27 @@
|
||||
|
||||
前往:[“**部署文档**”](https://artalk.js.org/guide/deploy.html)
|
||||
|
||||
```sh
|
||||
$ pnpm add artalk
|
||||
```
|
||||
|
||||
```ts
|
||||
import Artalk from 'artalk'
|
||||
|
||||
new Artalk({
|
||||
el: '#Comments',
|
||||
pageKey: 'http://your_domain/post/1', // 页面链接
|
||||
pageTitle: '关于如何引入 Artalk 这档子事', // 页面标题
|
||||
server: 'http://localhost:8080/api', // 后端地址
|
||||
site: 'Artalk 的博客 (你的站点名)',
|
||||
el: '#Comments',
|
||||
site: 'Artalk 的博客',
|
||||
server: 'https://artalk.example.com'
|
||||
})
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```sh
|
||||
# 为 Artalk 创建一个目录
|
||||
mkdir Artalk
|
||||
cd Artalk
|
||||
|
||||
# 拉取 docker 镜像
|
||||
docker pull artalk/artalk-go
|
||||
|
||||
# 生成配置文件
|
||||
docker run -it -v $(pwd)/data:/data --rm artalk/artalk-go gen config data/artalk-go.yml
|
||||
|
||||
# 编辑配置文件
|
||||
vim data/artalk-go.yml
|
||||
|
||||
# 运行 docker 容器
|
||||
```bash
|
||||
docker run -d \
|
||||
--name artalk-go \
|
||||
-p 0.0.0.0:8080:23366 \
|
||||
-p 8080:23366 \
|
||||
-v $(pwd)/data:/data \
|
||||
artalk/artalk-go
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```sh
|
||||
mkdir Artalk
|
||||
cd Artalk
|
||||
|
||||
```bash
|
||||
vim docker-compose.yaml
|
||||
```
|
||||
|
||||
@@ -117,7 +92,7 @@ services:
|
||||
- ./data:/data
|
||||
```
|
||||
|
||||
```sh
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
@@ -145,7 +120,7 @@ docker-compose up -d
|
||||
- [x] MySQL
|
||||
- [x] Postgres
|
||||
- [x] SQLServer
|
||||
- [x] 多缓存支持
|
||||
- [x] 多缓存数据库支持
|
||||
- [x] In-memory (内建缓存)
|
||||
- [x] Redis
|
||||
- [x] Memcache
|
||||
@@ -162,10 +137,10 @@ docker-compose up -d
|
||||
- [x] 我的
|
||||
- [x] 待审
|
||||
- [x] 管理员控制台
|
||||
- [x] 评论 (增/删/改)
|
||||
- [x] 页面 (增/删/改)
|
||||
- [x] 站点 (增/删/改)
|
||||
- [ ] 用户 (增/删/改)
|
||||
- [x] 评论
|
||||
- [x] 页面
|
||||
- [x] 站点
|
||||
- [x] 用户
|
||||
- [x] 设置 (GUI)
|
||||
- [x] 数据导入 ([Artransfer](https://artalk.js.org/guide/transfer.html))
|
||||
- [x] Artrans
|
||||
@@ -178,13 +153,13 @@ docker-compose up -d
|
||||
- [x] Twikoo
|
||||
- [x] Artalk v1 (PHP)
|
||||
- [x] 数据导出
|
||||
- [x] 邮件异步队列发送
|
||||
- [ ] 邮件队列持久化
|
||||
- [x] 邮件多种发送方式
|
||||
- [x] SMTP
|
||||
- [x] 阿里云邮件
|
||||
- [x] 系统调用 sendmail
|
||||
- [x] 邮件多模板自定义
|
||||
- [x] 邮件异步队列发送
|
||||
- [ ] 队列持久化
|
||||
- [x] 用户已读标记
|
||||
- [x] 验证码
|
||||
- [x] 图片验证码
|
||||
@@ -210,21 +185,21 @@ docker-compose up -d
|
||||
- [x] JWT 登陆状态验证
|
||||
- [x] 时区自定义
|
||||
- [x] 只看作者功能
|
||||
- [ ] AT 提及 (@)
|
||||
- [ ] 评论话题 (#)
|
||||
- [ ] 表情包统一管理
|
||||
- [ ] 导入表情包
|
||||
- [ ] 表情包图片地址控制
|
||||
- [x] 图片上传
|
||||
- [x] 图片上传到图床 ([upgit](https://github.com/pluveto/upgit))
|
||||
- [ ] 图片管理
|
||||
- [ ] 附件上传 / 管理
|
||||
- [ ] 表情包统一管理
|
||||
- [ ] 导入表情包
|
||||
- [ ] 表情包图片地址控制
|
||||
- [ ] AT 提及 (@)
|
||||
- [ ] 评论话题 (#)
|
||||
- [ ] 评论标签分类系统
|
||||
- [ ] 主题样式更换
|
||||
- [ ] 规范化 API
|
||||
- [ ] 扩展中心
|
||||
- [ ] 开放用户注册
|
||||
- [ ] 接入第三方登录
|
||||
- [ ] 第三方登录接入
|
||||
- [x] 多语言 / 国际化 (i18n)
|
||||
- [x] 一键升级
|
||||
|
||||
|
||||
+7
-7
@@ -11,10 +11,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-dynamic-import-vars": "1.4.4",
|
||||
"@types/node": "16.11.59",
|
||||
"@types/node": "16.18.11",
|
||||
"@typescript-eslint/eslint-plugin": "5.30.7",
|
||||
"@typescript-eslint/parser": "5.30.7",
|
||||
"autoprefixer": "10.4.12",
|
||||
"autoprefixer": "10.4.13",
|
||||
"eslint": "8.23.1",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-airbnb-typescript": "17.0.0",
|
||||
@@ -23,14 +23,14 @@
|
||||
"eslint-plugin-compat": "4.0.2",
|
||||
"eslint-plugin-import": "2.26.0",
|
||||
"less": "4.1.3",
|
||||
"postcss": "8.4.16",
|
||||
"postcss": "8.4.20",
|
||||
"prettier": "2.7.1",
|
||||
"rollup": "2.79.0",
|
||||
"terser": "5.15.0",
|
||||
"rollup": "2.79.1",
|
||||
"terser": "5.15.1",
|
||||
"typescript": "4.7.4",
|
||||
"vite": "3.1.3",
|
||||
"vite": "3.1.8",
|
||||
"vite-plugin-dts": "1.5.0",
|
||||
"vite-tsconfig-paths": "3.5.0"
|
||||
"vite-tsconfig-paths": "3.5.2"
|
||||
},
|
||||
"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"
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ declare global {
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineLoader: typeof import('vue-router/auto')['defineLoader']
|
||||
const definePage: typeof import('vue-router/auto')['definePage']
|
||||
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']
|
||||
@@ -38,6 +38,7 @@ declare global {
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolveDirective: typeof import('vue')['resolveDirective']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
|
||||
+1
@@ -23,5 +23,6 @@ declare module '@vue/runtime-core' {
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "@artalk/artalk-sidebar",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "LGPL-3.0",
|
||||
"description": "The sidebar of artalk",
|
||||
"private": true,
|
||||
@@ -13,20 +13,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"artalk": "workspace: *",
|
||||
"pinia": "^2.0.22",
|
||||
"vue": "^3.2.37",
|
||||
"vue-router": "^4.1.5",
|
||||
"yaml": "^2.1.1"
|
||||
"pinia": "^2.0.28",
|
||||
"vue": "^3.2.45",
|
||||
"vue-router": "^4.1.6",
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^3.1.0",
|
||||
"@vitejs/plugin-vue": "^3.1.2",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"sass": "^1.55.0",
|
||||
"typescript": "^4.6.4",
|
||||
"unplugin-auto-import": "^0.11.2",
|
||||
"unplugin-vue-components": "^0.22.7",
|
||||
"unplugin-vue-router": "^0.2.1",
|
||||
"vite": "^3.1.3",
|
||||
"vue-tsc": "^0.40.4"
|
||||
"unplugin-auto-import": "^0.11.4",
|
||||
"unplugin-vue-components": "^0.22.12",
|
||||
"unplugin-vue-router": "^0.2.3",
|
||||
"vite": "^3.1.8",
|
||||
"vue-tsc": "^0.40.13"
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useNavStore } from './stores/nav'
|
||||
import { useUserStore } from './stores/user'
|
||||
import global, { bootParams, createArtalkInstance } from './global'
|
||||
|
||||
const nav = useNavStore()
|
||||
const user = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { scrollableArea } = storeToRefs(nav)
|
||||
const artalkLoaded = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
createArtalkInstance().then(artalkInstance => {
|
||||
// 初始化 Artalk
|
||||
global.setArtalk(artalkInstance)
|
||||
onBeforeMount(() => {
|
||||
if (bootParams.user?.email) {
|
||||
global.getArtalk().ctx.user.update(bootParams.user)
|
||||
} else {
|
||||
try {
|
||||
global.importUserDataFromArtalkInstance()
|
||||
} catch (e) {
|
||||
// console.error(e)
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户资料
|
||||
global.getArtalk()!.ctx.user.update(bootParams.user)
|
||||
|
||||
// 验证登陆身份有效性
|
||||
// artalkInstance.ctx.getApi().user.loginStatus()
|
||||
// .then(resp => {
|
||||
// if (resp.is_admin && !resp.is_login) {
|
||||
// router.replace('/login')
|
||||
// }
|
||||
// })
|
||||
|
||||
artalkLoaded.value = true
|
||||
// 验证登陆身份有效性
|
||||
global.getArtalk().ctx.getApi().user.loginStatus().then(resp => {
|
||||
if (resp.is_admin && !resp.is_login) {
|
||||
global.getArtalk().ctx.user.logout()
|
||||
user.logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="artalkLoaded">
|
||||
<Header />
|
||||
<Tab />
|
||||
<Header />
|
||||
<Tab />
|
||||
|
||||
<div ref="scrollableArea" class="main artalk atk-sidebar">
|
||||
<div class="atk-sidebar-inner">
|
||||
<router-view />
|
||||
</div>
|
||||
<div class="main artalk atk-sidebar">
|
||||
<div ref="scrollableArea" class="atk-sidebar-inner">
|
||||
<router-view />
|
||||
</div>
|
||||
<LoadingLayer v-if="nav.isPageLoading" />
|
||||
</div>
|
||||
<LoadingLayer v-if="!artalkLoaded" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.main {
|
||||
overflow-y: auto;
|
||||
height: calc(100vh - 61px - 41px);
|
||||
padding-bottom: 50px;
|
||||
.atk-sidebar-inner {
|
||||
overflow-y: auto;
|
||||
height: calc(100vh - 61px - 41px);
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
|
||||
// 分页条占位
|
||||
:deep(.atk-pagination-wrap) {
|
||||
|
||||
@@ -30,7 +30,7 @@ db:
|
||||
# 数据库地址
|
||||
host: "localhost"
|
||||
# 数据库端口
|
||||
port: "3306"
|
||||
port: 3306
|
||||
# 数据库账户
|
||||
user: "root"
|
||||
# 数据库密码
|
||||
@@ -69,8 +69,7 @@ cache:
|
||||
db: 0
|
||||
|
||||
# 可信域名
|
||||
trusted_domains:
|
||||
- "https://artalk.你的域名:23366"
|
||||
trusted_domains: [] # 例如:["https://artalk.example.com:23366"]
|
||||
|
||||
# SSL
|
||||
ssl:
|
||||
@@ -83,11 +82,11 @@ ssl:
|
||||
|
||||
# 管理员账户
|
||||
admin_users:
|
||||
- name: "admin"
|
||||
email: "admin@example.com"
|
||||
password: "" # 支持 bcrypt 或 md5 加密,如:"(md5)50c21190c6e4e5418c6a90d2b5031119"
|
||||
badge_name: "管理员"
|
||||
badge_color: "#FF6C00"
|
||||
# - name: "admin"
|
||||
# email: "admin@example.com"
|
||||
# password: "" # 支持 bcrypt 或 md5 加密,如:"(md5)50c21190c6e4e5418c6a90d2b5031119"
|
||||
# badge_name: "管理员"
|
||||
# badge_color: "#FF6C00"
|
||||
|
||||
# 评论审核
|
||||
moderator:
|
||||
@@ -224,18 +223,18 @@ admin_notify:
|
||||
url: ""
|
||||
# 钉钉
|
||||
ding_talk:
|
||||
enabled: true
|
||||
enabled: false
|
||||
token: ""
|
||||
secret: ""
|
||||
# Slack
|
||||
slack:
|
||||
enabled: true
|
||||
enabled: false
|
||||
oauth_token: ""
|
||||
receivers:
|
||||
- "CHANNEL_ID"
|
||||
# LINE
|
||||
line:
|
||||
enabled: true
|
||||
enabled: false
|
||||
channel_secret: ""
|
||||
channel_access_token: ""
|
||||
receivers:
|
||||
@@ -270,22 +269,20 @@ frontend:
|
||||
countEl: "#ArtalkCount"
|
||||
# 编辑器实时预览功能
|
||||
preview: true
|
||||
# 平铺模式
|
||||
# 平铺模式 ["auto", true, false]
|
||||
flatMode: "auto"
|
||||
# 最大嵌套层数
|
||||
nestMax: 2
|
||||
# 嵌套评论排序规则
|
||||
# 嵌套评论排序规则 ["DATE_ASC", "DATE_DESC", "VOTE_UP_DESC"]
|
||||
nestSort: DATE_ASC
|
||||
# 头像
|
||||
gravatar:
|
||||
# Gravatar 镜像地址
|
||||
mirror: "https://sdn.geekzu.org/avatar/"
|
||||
mirror: "https://cravatar.cn/avatar/"
|
||||
# 默认头像
|
||||
default: "mp"
|
||||
# 评论分页
|
||||
pagination:
|
||||
# 每页评论数
|
||||
pageSize: 20
|
||||
# 加载更多模式 (关闭则使用分页条)
|
||||
readMore: true
|
||||
# 滚动加载
|
||||
@@ -300,5 +297,3 @@ frontend:
|
||||
reqTimeout: 15000
|
||||
# 版本检测
|
||||
versionCheck: true
|
||||
# 语言设定
|
||||
locale: zh-CN
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -130,7 +130,7 @@ function onFiledEditorNo() {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.atk-page-edit-layer {
|
||||
z-index: 999;
|
||||
z-index: 9;
|
||||
background: var(--at-color-bg);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
|
||||
@@ -6,11 +6,11 @@ const props = defineProps<{
|
||||
/** 每页数量 */
|
||||
pageSize: number
|
||||
|
||||
/** 加载中 */
|
||||
isLoading?: boolean
|
||||
/** 禁用 */
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const { pageSize, total, isLoading } = toRefs(props)
|
||||
const { pageSize, total, disabled } = toRefs(props)
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 翻页事件 */
|
||||
@@ -34,12 +34,14 @@ function changePage(page: number) {
|
||||
|
||||
/* 按钮点击操作 */
|
||||
function prev() {
|
||||
if (disabled?.value) return
|
||||
const page = curtPage.value - 1
|
||||
if (page < 1) { return }
|
||||
changePage(page)
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (disabled?.value) return
|
||||
const page = curtPage.value + 1
|
||||
if (page > maxPage.value) { return }
|
||||
changePage(page)
|
||||
@@ -107,7 +109,7 @@ defineExpose({ prev, next, reset })
|
||||
<div class="atk-pagination">
|
||||
<div
|
||||
class="atk-btn atk-btn-prev"
|
||||
:class="{ 'atk-disabled': prevDisabled }"
|
||||
:class="{ 'atk-disabled': disabled || prevDisabled }"
|
||||
@click="prev()"
|
||||
>Prev</div>
|
||||
<input
|
||||
@@ -117,14 +119,14 @@ defineExpose({ prev, next, reset })
|
||||
v-model="inputValue"
|
||||
@input="triggerInput(false)"
|
||||
@keydown="onInputKeydown"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<div
|
||||
class="atk-btn atk-btn-next"
|
||||
:class="{ 'atk-disabled': nextDisabled }"
|
||||
:class="{ 'atk-disabled': disabled || nextDisabled }"
|
||||
@click="next()"
|
||||
>Next</div>
|
||||
</div>
|
||||
<LoadingLayer v-if="isLoading" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -7,29 +7,34 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const ci = getCurrentInstance()
|
||||
const customValue = ref<string[]>([])
|
||||
|
||||
const customValue = ref([])
|
||||
const update = () => {
|
||||
customValue.value = (settings.customs.value?.getIn(props.path) as any)?.items || []
|
||||
ci?.proxy?.$forceUpdate()
|
||||
function update() {
|
||||
customValue.value = (settings.get().customs.value?.getIn(props.path) as any)?.items || []
|
||||
}
|
||||
watch(settings.customs, (customs) => {
|
||||
|
||||
function save() {
|
||||
settings.get().customs.value?.setIn([...props.path], customValue.value)
|
||||
}
|
||||
|
||||
watch(settings.get().customs, (customs) => {
|
||||
update()
|
||||
ci?.proxy?.$forceUpdate()
|
||||
})
|
||||
|
||||
function onChange(index: number, val: string) {
|
||||
settings.customs.value?.setIn([...props.path, index], val)
|
||||
customValue.value[index] = val
|
||||
save()
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
settings.customs.value?.deleteIn([...props.path, index])
|
||||
update()
|
||||
customValue.value.splice(index, 1)
|
||||
save()
|
||||
}
|
||||
|
||||
function add() {
|
||||
if (!customValue.value) settings.customs.value?.setIn([...props.path], [''])
|
||||
else settings.customs.value?.setIn([...props.path, customValue.value.length], '')
|
||||
update()
|
||||
customValue.value.push('')
|
||||
save()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import settings from '../lib/settings'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
tplData: Object|Array<any>
|
||||
path: (string|number)[]
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(evt: 'toggle', path?: string): void
|
||||
}>()
|
||||
|
||||
const desc = computed(() => settings.extractItemDescFromComment(props.path))
|
||||
const desc = computed(() => settings.get().extractItemDescFromComment(props.path))
|
||||
const level = computed(() => props.path.length)
|
||||
|
||||
const expanded = ref(true)
|
||||
@@ -24,7 +22,7 @@ function onHeadClick(evt: Event) {
|
||||
if (!expanded.value) {
|
||||
expanded.value = true
|
||||
// nextTick(() => {
|
||||
// nav.scrollToEl(evt.target as HTMLElement)
|
||||
// nav.scrollPageToEl(evt.target as HTMLElement)
|
||||
// })
|
||||
} else {
|
||||
expanded.value = false
|
||||
@@ -41,18 +39,20 @@ function onHeadClick(evt: Event) {
|
||||
<div v-show="expanded" class="pf-body">
|
||||
<!-- Array -->
|
||||
<template v-if="Array.isArray(tplData)">
|
||||
<div v-if="path.join('.') === 'admin_users'" class="coming-soon">暂不支持编辑,敬请期待 😉(<b>咕咕咕</b></div>
|
||||
<PreferenceArr v-else :tpl-data="tplData" :path="path" />
|
||||
<PreferenceArr :tpl-data="tplData" :path="path" />
|
||||
</template>
|
||||
<!-- Object -->
|
||||
<template v-else>
|
||||
<div v-for="[key, value] in Object.entries(tplData)">
|
||||
<!-- Admin Users -->
|
||||
<template v-if="key === 'admin_users'"></template>
|
||||
<!-- Grp -->
|
||||
<PreferenceGrp
|
||||
v-if="value !== null && typeof value === 'object'"
|
||||
v-else-if="value !== null && typeof value === 'object'"
|
||||
:tpl-data="value"
|
||||
:path="[...path, key]"
|
||||
:toggle="emits('toggle')"
|
||||
/>
|
||||
<!-- Item Input -->
|
||||
<PreferenceItem
|
||||
v-else
|
||||
:tpl-data="value"
|
||||
@@ -156,17 +156,4 @@ function onHeadClick(evt: Event) {
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
b {
|
||||
font-weight: normal;
|
||||
color: #000;
|
||||
background: #000;
|
||||
transition: .1s ease background;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,11 +6,16 @@ const props = defineProps<{
|
||||
path: (string|number)[]
|
||||
}>()
|
||||
|
||||
const desc = computed(() => settings.extractItemDescFromComment(props.path))
|
||||
const customValue = computed(() => settings.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) {
|
||||
settings.customs.value?.setIn(props.path, value)
|
||||
function onChange(value: boolean|string|number) {
|
||||
// 类型转换
|
||||
if (value === "true") value = true
|
||||
else if (value === "false") value = false
|
||||
else if (!isNaN(Number(value))) value = Number(value)
|
||||
|
||||
settings.get().customs.value?.setIn(props.path, value)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,16 +7,16 @@ type PageItem = { label: string, link: string, hide?: boolean }
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { curtPage, curtTab, tabs } = storeToRefs(useNavStore())
|
||||
const nav = useNavStore()
|
||||
|
||||
const { curtPage, curtTab, tabs, isSearchEnabled } = storeToRefs(nav)
|
||||
const { isAdmin } = storeToRefs(useUserStore())
|
||||
const indicator = ref<'pages'|'tabs'>('tabs')
|
||||
const tabListEl = ref<HTMLElement|null>(null)
|
||||
|
||||
const pages = ref<{ [name: string]: PageItem }>({})
|
||||
|
||||
onMounted(() => {
|
||||
const pages = computed((): { [name: string]: PageItem } => {
|
||||
if (isAdmin.value) {
|
||||
pages.value = {
|
||||
return {
|
||||
comments: {
|
||||
label: '评论',
|
||||
link: '/comments',
|
||||
@@ -25,9 +25,14 @@ onMounted(() => {
|
||||
label: '页面',
|
||||
link: '/pages',
|
||||
},
|
||||
users: {
|
||||
label: '用户',
|
||||
link: '/users',
|
||||
},
|
||||
sites: {
|
||||
label: '站点',
|
||||
link: '/sites',
|
||||
hide: true,
|
||||
},
|
||||
transfer: {
|
||||
label: '迁移',
|
||||
@@ -40,20 +45,24 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pages.value = {
|
||||
return {
|
||||
comments: {
|
||||
label: '评论',
|
||||
link: '/comments',
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isSearchShow = ref(false)
|
||||
const searchValue = ref('')
|
||||
const searchInputEl = ref<HTMLInputElement|null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
tabListEl.value!.addEventListener('wheel', (evt) => {
|
||||
evt.preventDefault()
|
||||
tabListEl.value!.scrollLeft += evt.deltaY
|
||||
})
|
||||
|
||||
curtPage.value = route.name.replace(/^\//, '')
|
||||
})
|
||||
|
||||
function toggleIndicator() {
|
||||
@@ -70,9 +79,40 @@ function switchTab(tabName: string) {
|
||||
curtTab.value = tabName
|
||||
}
|
||||
|
||||
// @link https://router.vuejs.org/zh/guide/advanced/navigation-guards.html
|
||||
router.beforeEach((to, from) => {
|
||||
isSearchEnabled.value = false
|
||||
})
|
||||
|
||||
router.afterEach((to, from, failure) => {
|
||||
curtPage.value = to.name.replace(/^\//, '')
|
||||
})
|
||||
|
||||
function showSearch() {
|
||||
isSearchShow.value = true
|
||||
nextTick(() => {
|
||||
searchInputEl.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function hideSearch() {
|
||||
isSearchShow.value = false
|
||||
searchValue.value = ''
|
||||
if (nav.searchResetEvent) {
|
||||
nav.searchResetEvent()
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchSubmit(evt: Event) {
|
||||
if (searchValue.value.trim() === '') {
|
||||
searchInputEl.value?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (nav.searchEvent) {
|
||||
nav.searchEvent(searchValue.value.trim())
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -91,6 +131,12 @@ router.afterEach((to, from, failure) => {
|
||||
:class="{ active: curtTab === tabName }"
|
||||
@click="switchTab(tabName as string)"
|
||||
>{{ tabLabel }}</div>
|
||||
|
||||
<div
|
||||
v-if="isSearchEnabled"
|
||||
class="item search-btn"
|
||||
@click="showSearch()"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<!-- pages -->
|
||||
@@ -104,17 +150,45 @@ router.afterEach((to, from, failure) => {
|
||||
>{{ page.label }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="isSearchShow"
|
||||
class="search-layer atk-fade-in"
|
||||
@submit.prevent="onSearchSubmit"
|
||||
>
|
||||
<div class="item back-btn" @click="hideSearch()">
|
||||
<div class="icon arrow"></div>
|
||||
</div>
|
||||
<input ref="searchInputEl" type="text" placeholder="搜索关键字..." v-model="searchValue" required>
|
||||
<button type="submit" class="item search-btn"></button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #eceff2;
|
||||
height: 41px;
|
||||
|
||||
.icon {
|
||||
width: 17px;
|
||||
height: 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50%;
|
||||
|
||||
&.menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='14' height='11' viewBox='0 0 14 11' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_1_2)'%3E%3Crect width='14' height='11' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0H14V1H0V0ZM0 5H14V6H0V5ZM14 10H0V11H14V10Z' fill='black'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_1_2'%3E%3Crect width='14' height='11' fill='none'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
&.arrow {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='9' height='14' viewBox='0 0 9 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='9' height='13' fill='none'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M7.52898 0L1 6.52898L1.17809 6.70707L1.17805 6.70711L7.52897 13.058L8.23608 12.3509L2.41418 6.52902L8.23609 0.707107L7.52898 0Z' fill='black'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -126,19 +200,7 @@ router.afterEach((to, from, failure) => {
|
||||
white-space: nowrap;
|
||||
|
||||
.icon {
|
||||
width: 17px;
|
||||
height: 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50%;
|
||||
margin-right: 10px;
|
||||
|
||||
&.menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='14' height='11' viewBox='0 0 14 11' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_1_2)'%3E%3Crect width='14' height='11' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0H14V1H0V0ZM0 5H14V6H0V5ZM14 10H0V11H14V10Z' fill='black'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_1_2'%3E%3Crect width='14' height='11' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
&.arrow {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='9' height='14' viewBox='0 0 9 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='9' height='13' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M7.52898 0L1 6.52898L1.17809 6.70707L1.17805 6.70711L7.52897 13.058L8.23608 12.3509L2.41418 6.52902L8.23609 0.707107L7.52898 0Z' fill='black'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
@@ -147,6 +209,7 @@ router.afterEach((to, from, failure) => {
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
position: relative;
|
||||
flex: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -158,24 +221,69 @@ router.afterEach((to, from, failure) => {
|
||||
height: 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
.item {
|
||||
padding: 0 20px;
|
||||
color: #757575;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active {
|
||||
color: #2a2e2e;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
&.search-btn {
|
||||
margin-left: auto;
|
||||
padding: 0 20px;
|
||||
color: #757575;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
border-left: 1px solid #eceff2;
|
||||
|
||||
&::after {
|
||||
display: inline-block;
|
||||
content: '';
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='15' height='15' viewBox='0 0 15 15' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9.89266 3.70572C8.18412 1.99718 5.41402 1.99718 3.70548 3.70572C1.99694 5.41427 1.99694 8.18436 3.70548 9.89291C5.41402 11.6015 8.18412 11.6015 9.89266 9.89291C11.6012 8.18436 11.6012 5.41427 9.89266 3.70572ZM2.8216 2.82184C5.0183 0.625142 8.57985 0.625142 10.7765 2.82184C12.8239 4.86916 12.9631 8.10202 11.1942 10.3106L13.4282 12.5446L12.5443 13.4284L10.3103 11.1945C8.10177 12.9633 4.86892 12.8241 2.8216 10.7768C0.624897 8.58009 0.624897 5.01854 2.8216 2.82184Z' fill='%23757575'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-layer {
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.back-btn {
|
||||
padding: 0 20px;
|
||||
border-right: 1px solid #eceff2;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: none;
|
||||
padding: 0 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active {
|
||||
color: #2a2e2e;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f4f4f4;
|
||||
}
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { artalk, bootParams } from '../global'
|
||||
import type { UserData, UserDataForAdmin } from 'artalk/types/artalk-data'
|
||||
|
||||
interface IUserEditData {
|
||||
name: string
|
||||
email: string
|
||||
link: string
|
||||
password: string
|
||||
badge_name: string
|
||||
badge_color: string
|
||||
is_admin: boolean
|
||||
site_names_raw: string
|
||||
receive_email: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
user?: IUserEditData
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(evt: 'close'): void
|
||||
(evt: 'update', user: UserDataForAdmin): void
|
||||
}>()
|
||||
|
||||
const user = ref<IUserEditData>()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isCreateMode = ref(false)
|
||||
|
||||
const showFullDetails = ref(false)
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (!props.user) {
|
||||
isCreateMode.value = true
|
||||
user.value = {
|
||||
name: '',
|
||||
email: '',
|
||||
link: '',
|
||||
password: '',
|
||||
badge_name: '',
|
||||
badge_color: '',
|
||||
is_admin: false,
|
||||
site_names_raw: '',
|
||||
receive_email: true,
|
||||
}
|
||||
} else {
|
||||
user.value = props.user
|
||||
}
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function submit() {
|
||||
// 执行保存
|
||||
isLoading.value = true
|
||||
|
||||
// 默认标签颜色
|
||||
if (user.value!.badge_name !== '' && user.value!.badge_color === '') {
|
||||
user.value!.badge_color = '#0083ff'
|
||||
}
|
||||
|
||||
if (isCreateMode.value) {
|
||||
artalk!.ctx.getApi().user.userAdd(user.value!, user.value!.password)
|
||||
.then((respUser) => {
|
||||
emit('update', respUser)
|
||||
}).catch((e) => {
|
||||
alert('用户创建错误:'+e.msg)
|
||||
}).finally(() => {
|
||||
isLoading.value = false
|
||||
})
|
||||
} else {
|
||||
artalk!.ctx.getApi().user.userEdit(user.value!, user.value!.password)
|
||||
.then((respUser) => {
|
||||
emit('update', respUser)
|
||||
}).catch((e) => {
|
||||
alert('用户保存错误:'+e.msg)
|
||||
}).finally(() => {
|
||||
isLoading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-editor-layer">
|
||||
<div class="header">
|
||||
<div class="title">用户{{ isCreateMode ? '创建' : '编辑' }}</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>评论数</span>{{(user as any).comment_count}}</div>
|
||||
<div><span>近期 IP</span>{{(user as any).last_ip || '无'}}</div>
|
||||
<div><span>近期 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)">查看</span></template>
|
||||
</div>
|
||||
</div>
|
||||
<form v-if="user" class="atk-form" @submit.prevent="submit()">
|
||||
<div class="atk-label required">名字</div>
|
||||
<input v-model="user.name" type="text" placeholder="" autocomplete="off">
|
||||
<div class="atk-label required">邮箱</div>
|
||||
<input v-model="user.email" type="text" placeholder="" autocomplete="off">
|
||||
<div class="atk-label">链接</div>
|
||||
<input v-model="user.link" type="text" placeholder="" autocomplete="off">
|
||||
<div class="atk-label">徽章文字</div>
|
||||
<input v-model="user.badge_name" type="text" placeholder="" autocomplete="off">
|
||||
<div class="atk-label">徽章颜色 (Color Hex)</div>
|
||||
<input v-model="user.badge_color" type="text" placeholder="" autocomplete="off">
|
||||
<div class="atk-label required">身份角色</div>
|
||||
<select v-model="user.is_admin">
|
||||
<option :value="false">普通用户</option>
|
||||
<option :value="true">管理员</option>
|
||||
</select>
|
||||
<template v-if="user.is_admin">
|
||||
<div class="atk-label required">密码</div>
|
||||
<input v-model="user.password" type="text" :placeholder="isCreateMode ? '' : '(留空不修改密码)'" autocomplete="off">
|
||||
<div class="atk-label">所属站点</div>
|
||||
<input v-model="user.site_names_raw" type="text" placeholder="(留空无站点范围限制)" autocomplete="off">
|
||||
</template>
|
||||
<div class="atk-label required">邮件通知</div>
|
||||
<select v-model="user.receive_email">
|
||||
<option :value="true">开启</option>
|
||||
<option :value="false">关闭</option>
|
||||
</select>
|
||||
<button type="submit" class="atk-btn">保存</button>
|
||||
</form>
|
||||
<LoadingLayer v-if="isLoading" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-editor-layer {
|
||||
z-index: 201;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 20px 20px 10px 20px;
|
||||
background: rgba(255, 255, 255, 0.884);
|
||||
|
||||
.title {
|
||||
margin-left: 10px;
|
||||
flex: 1;
|
||||
font-size: 19px;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover i {
|
||||
background-color: var(--at-color-red);
|
||||
}
|
||||
}
|
||||
|
||||
.user-log {
|
||||
margin: 0 30px;
|
||||
padding: 15px 25px;
|
||||
border-radius: 2px;
|
||||
font-size: 13px;
|
||||
background: var(--at-color-bg-grey);
|
||||
line-height: 1.8;
|
||||
|
||||
& > div > span:first-child {
|
||||
display: inline-block;
|
||||
width: 5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import Artalk from 'artalk'
|
||||
import type { LocalUser } from 'artalk/types/artalk-config'
|
||||
import { useUserStore } from './stores/user'
|
||||
|
||||
export let artalk: Artalk|null = null
|
||||
|
||||
@@ -26,19 +27,34 @@ export function createArtalkInstance() {
|
||||
Artalk.DisabledComponents = ['list']
|
||||
return new Artalk({
|
||||
el: artalkEl,
|
||||
server: (import.meta.env.DEV) ? 'http://localhost:23366' : '/',
|
||||
pageKey: 'https://artalk.js.org/guide/intro.html',
|
||||
server: (import.meta.env.DEV) ? 'http://localhost:23366' : '../',
|
||||
pageKey: bootParams.pageKey,
|
||||
site: bootParams.site,
|
||||
darkMode: bootParams.darkMode,
|
||||
useBackendConf: true
|
||||
}) as unknown as Promise<Artalk>
|
||||
})
|
||||
}
|
||||
|
||||
export function importUserDataFromArtalkInstance() {
|
||||
if (!artalk) throw Error("the artalk instance is not exist")
|
||||
if (!artalk.ctx.user.data.email) throw Error("the user data in artalk instance is invalid")
|
||||
|
||||
const userData = artalk.ctx.user.data
|
||||
useUserStore().$patch((state) => {
|
||||
state.site = '__ATK_SITE_ALL'
|
||||
state.name = userData.nick
|
||||
state.email = userData.email
|
||||
state.isAdmin = userData.isAdmin
|
||||
state.token = userData.token
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
createArtalkInstance,
|
||||
getArtalk: () => artalk,
|
||||
getArtalk: () => artalk!,
|
||||
setArtalk: (artalkInstance: Artalk) => {
|
||||
artalk = artalkInstance
|
||||
},
|
||||
getBootParams: () => bootParams
|
||||
getBootParams: () => bootParams,
|
||||
importUserDataFromArtalkInstance,
|
||||
}
|
||||
@@ -1,77 +1,86 @@
|
||||
import YAML from 'yaml'
|
||||
import confTemplate from '../assets/artalk-go.example.yml?raw'
|
||||
|
||||
const customs = shallowRef<YAML.Document.Parsed<YAML.ParsedNode>>()
|
||||
|
||||
const yamlDocTpl = YAML.parseDocument(confTemplate)
|
||||
|
||||
type YAMLPair = {
|
||||
key?: { value: string, commentBefore: string, comment: string },
|
||||
value?: { commentBefore: string, comment: string, value?: any, items?: YAMLPair[] }
|
||||
}
|
||||
|
||||
export const comments: { [path: string]: string } = {}
|
||||
export const defaultValues: { [path: string]: string } = {}
|
||||
export function createSettings() {
|
||||
const customs = shallowRef<YAML.Document.Parsed<YAML.ParsedNode>>()
|
||||
|
||||
function loop(pairs: YAMLPair[], parent?: YAMLPair, path?: string[]) {
|
||||
pairs.forEach((item, index: number) => {
|
||||
const key = item?.key?.value
|
||||
if (!key) return
|
||||
const yamlDocTpl = YAML.parseDocument(confTemplate)
|
||||
|
||||
const itemPath = (!path) ? [key] : [...path, key]
|
||||
const comments: { [path: string]: string } = {}
|
||||
const defaultValues: { [path: string]: string } = {}
|
||||
|
||||
let comment = ''
|
||||
if (index === 0 && parent) comment = parent?.value?.commentBefore || ''
|
||||
else comment = item?.key?.commentBefore || ''
|
||||
loop((yamlDocTpl as any).contents.items)
|
||||
|
||||
const pathStr = itemPath.join('.')
|
||||
comments[pathStr] = comment.trim()
|
||||
defaultValues[pathStr] = item?.value?.value
|
||||
function loop(pairs: YAMLPair[], parent?: YAMLPair, path?: string[]) {
|
||||
pairs.forEach((item, index: number) => {
|
||||
const key = item?.key?.value
|
||||
if (!key) return
|
||||
|
||||
// 继续迭代
|
||||
if (item?.value?.items) {
|
||||
loop(item.value.items, item, itemPath)
|
||||
const itemPath = (!path) ? [key] : [...path, key]
|
||||
|
||||
let comment = ''
|
||||
if (index === 0 && parent) comment = parent?.value?.commentBefore || ''
|
||||
else comment = item?.key?.commentBefore || ''
|
||||
|
||||
const pathStr = itemPath.join('.')
|
||||
comments[pathStr] = comment.trim()
|
||||
defaultValues[pathStr] = item?.value?.value
|
||||
|
||||
// 继续迭代
|
||||
if (item?.value?.items) {
|
||||
loop(item.value.items, item, itemPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function extractItemDescFromComment(nodePath: string|(string|number)[]) {
|
||||
if (Array.isArray(nodePath)) nodePath = nodePath.join('.')
|
||||
const nodeName = nodePath.split('.').slice(-1)[0]
|
||||
const comment = (comments[nodePath] || '').trim()
|
||||
|
||||
let title = ''
|
||||
let subTitle = ''
|
||||
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) : ''
|
||||
if (!title) {
|
||||
const commonDict: any = { 'enabled': '启用' }
|
||||
title = commonDict[nodeName] || snakeToCamel(nodeName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
loop((yamlDocTpl as any).contents.items)
|
||||
const optReg = /\[.*?\]/gm
|
||||
const optFind = optReg.exec(title)
|
||||
if (optFind) {
|
||||
try { opts = JSON.parse(optFind[0]) } catch (err) { console.error(err) }
|
||||
title = title.replace(optReg, '').trim()
|
||||
}
|
||||
|
||||
export function extractItemDescFromComment(nodePath: string|(string|number)[]) {
|
||||
if (Array.isArray(nodePath)) nodePath = nodePath.join('.')
|
||||
const nodeName = nodePath.split('.').slice(-1)[0]
|
||||
const comment = (comments[nodePath] || '').trim()
|
||||
|
||||
let title = ''
|
||||
let subTitle = ''
|
||||
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) : ''
|
||||
if (!title) {
|
||||
const commonDict: any = { 'enabled': '启用' }
|
||||
title = commonDict[nodeName] || snakeToCamel(nodeName)
|
||||
return {
|
||||
title, subTitle, opts
|
||||
}
|
||||
}
|
||||
|
||||
const optReg = /\[.*?\]/gm
|
||||
const optFind = optReg.exec(title)
|
||||
if (optFind) {
|
||||
try { opts = JSON.parse(optFind[0]) } catch (err) { console.error(err) }
|
||||
title = title.replace(optReg, '').trim()
|
||||
function snakeToCamel(str: string) {
|
||||
return str.toLowerCase()
|
||||
.replace(/([_][a-z]|^[a-z])/g, (group) =>
|
||||
group.slice(-1).toUpperCase()
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
title, subTitle, opts
|
||||
}
|
||||
return { comments, customs, defaultValues, extractItemDescFromComment }
|
||||
}
|
||||
|
||||
function snakeToCamel(str: string) {
|
||||
return str.toLowerCase()
|
||||
.replace(/([_][a-z]|^[a-z])/g, (group) =>
|
||||
group.slice(-1).toUpperCase()
|
||||
)
|
||||
}
|
||||
let instance: ReturnType<(typeof createSettings)>
|
||||
|
||||
export default { comments, customs, defaultValues, extractItemDescFromComment }
|
||||
export default {
|
||||
init: () => instance = createSettings(),
|
||||
get: () => instance
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import { createRouter, createWebHashHistory } from 'vue-router/auto'
|
||||
import 'artalk/dist/Artalk.css'
|
||||
import './style.scss'
|
||||
import App from './App.vue'
|
||||
import global, { createArtalkInstance, bootParams } from './global'
|
||||
|
||||
// 初始化 Artalk
|
||||
global.setArtalk(createArtalkInstance())
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ const nav = useNavStore()
|
||||
const { curtTab } = storeToRefs(nav)
|
||||
const { site: curtSite } = storeToRefs(user)
|
||||
|
||||
const search = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化导航条
|
||||
if (user.isAdmin) {
|
||||
@@ -56,6 +58,7 @@ onMounted(() => {
|
||||
list.paramsEditor = (params) => {
|
||||
params.type = curtTab.value // 列表数据类型
|
||||
params.site_name = curtSite.value // 站点名
|
||||
if (search.value) params.search = search.value
|
||||
}
|
||||
artalk!.on('list-inserted', (data) => {
|
||||
wrapEl.value!.scrollTo(0, 0)
|
||||
@@ -64,6 +67,16 @@ onMounted(() => {
|
||||
list.reload()
|
||||
|
||||
listEl.value?.append(list.$el)
|
||||
|
||||
// 搜索功能
|
||||
nav.enableSearch((value: string) => {
|
||||
search.value = value
|
||||
list.reload()
|
||||
}, () => {
|
||||
if (search.value === '') return
|
||||
search.value = ''
|
||||
list.reload()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,12 +10,6 @@
|
||||
import { bootParams } from '../global'
|
||||
|
||||
const router = useRouter()
|
||||
const LinkMap: {[key:string]:string} = {
|
||||
comments: '/comments',
|
||||
pages: '/pages',
|
||||
sites: '/sites',
|
||||
settings: '/settings'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (bootParams.view) {
|
||||
@@ -24,6 +18,12 @@ onMounted(() => {
|
||||
if (splitted[1]) bootParams.viewParams = JSON.parse(splitted[1])
|
||||
}
|
||||
|
||||
const LinkMap: {[key:string]:string} = {
|
||||
comments: '/comments',
|
||||
pages: '/pages',
|
||||
sites: '/sites',
|
||||
settings: '/settings'
|
||||
}
|
||||
router.replace(LinkMap[bootParams.view] || '/comments')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<route lang="json">
|
||||
{
|
||||
"meta": {
|
||||
"title": "Login - Artalk Sidebar"
|
||||
}
|
||||
}
|
||||
</route>
|
||||
|
||||
<script setup lang="ts">
|
||||
import global, { bootParams, createArtalkInstance } from '../global'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
let userForm = ref({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
let version = ref('')
|
||||
let buildHash = ref('')
|
||||
let loginErr = ref('')
|
||||
let userSelector = ref<string[]|null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
global.getArtalk()!.ctx.getApi().system.version().then((v) => {
|
||||
version.value = v.version
|
||||
buildHash.value = v.commit_hash
|
||||
})
|
||||
})
|
||||
|
||||
function onFocus() {
|
||||
loginErr.value = ''
|
||||
}
|
||||
|
||||
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.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 || '登陆发生异常'
|
||||
}
|
||||
})
|
||||
}
|
||||
</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" />
|
||||
</a>
|
||||
<form class="login-form" @submit.prevent="login()">
|
||||
<input type="text" placeholder="邮箱" v-model="userForm.email" @focus="onFocus">
|
||||
<input type="password" placeholder="密码" v-model="userForm.password" @focus="onFocus">
|
||||
<div v-if="!!loginErr" class="err-msg atk-fade-in">{{loginErr}}</div>
|
||||
<button type="submit">登陆</button>
|
||||
</form>
|
||||
<div class="copyright">
|
||||
Powered by <a href="https://artalk.js.org" target="_blank">ArtalkGo</a> (v{{version}} / {{buildHash}})
|
||||
</div>
|
||||
|
||||
<div v-if="userSelector" class="layer">
|
||||
<div class="user-selector atk-fade-in">
|
||||
<div class="text">请选择您要登陆的账户:</div>
|
||||
<div class="user-list">
|
||||
<div v-for="u in userSelector" class="item" @click="userSelector = null; login(u)">{{u}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-dialog {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.logo {
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-bottom: 50px;
|
||||
margin-top: -50px;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 13px;
|
||||
color: var(--at-color-meta);
|
||||
position: fixed;
|
||||
height: 30px;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 280px;
|
||||
|
||||
input {
|
||||
display: block;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 0 20px;
|
||||
line-height: 40px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
color: #fff;
|
||||
background: #6e8392;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
line-height: 35px;
|
||||
padding: 0 20px;
|
||||
margin-top: 20px;
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
opacity: .95;
|
||||
}
|
||||
}
|
||||
|
||||
.err-msg {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin: 0 10px;
|
||||
margin-top: 5px;
|
||||
padding: 0 10px;
|
||||
color: var(--at-color-red);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
height: 19px;
|
||||
width: 19px;
|
||||
margin-right: 6px;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='19' height='19' viewBox='0 0 19 19' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9.5 15.8332C12.9978 15.8332 15.8333 12.9976 15.8333 9.49984C15.8333 6.00203 12.9978 3.1665 9.50001 3.1665C6.0022 3.1665 3.16667 6.00203 3.16667 9.49984C3.16667 12.9976 6.0022 15.8332 9.5 15.8332ZM17.4167 9.49984C17.4167 13.8721 13.8723 17.4165 9.5 17.4165C5.12775 17.4165 1.58334 13.8721 1.58334 9.49984C1.58334 5.12758 5.12775 1.58317 9.50001 1.58317C13.8723 1.58317 17.4167 5.12758 17.4167 9.49984ZM8.70834 13.4581L8.70834 11.8748L10.2917 11.8748L10.2917 13.4581L8.70834 13.4581ZM10.2917 5.54148L10.2917 11.0831L8.70834 11.0831L8.70834 5.54148L10.2917 5.54148Z' fill='%23F53F3F'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--at-color-bg-transl);
|
||||
}
|
||||
|
||||
.user-selector {
|
||||
z-index: 999;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 43.5%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #fff;
|
||||
border: 1px solid var(--at-color-border);
|
||||
padding-bottom: 10px;
|
||||
width: 280px;
|
||||
|
||||
& > .text {
|
||||
font-size: 15px;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
& > .item {
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
transition: border .3s ease;
|
||||
border-left: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
border-left: 2px solid #6e8392;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -14,7 +14,6 @@ const curtEditPageID = ref<number|null>(null)
|
||||
const pageSize = ref(20)
|
||||
const pageTotal = ref(0)
|
||||
const pagination = ref<InstanceType<typeof Pagination>>()
|
||||
const isLoading = ref(false)
|
||||
const showActBarBorder = ref(false)
|
||||
const refreshBtn = ref({
|
||||
isRun: false,
|
||||
@@ -58,14 +57,14 @@ function editPage(page: PageData) {
|
||||
}
|
||||
|
||||
function reqPages(offset: number) {
|
||||
isLoading.value = true
|
||||
nav.setPageLoading(true)
|
||||
artalk?.ctx.getApi().page.pageGet(curtSite.value, offset, pageSize.value)
|
||||
.then(data => {
|
||||
pageTotal.value = data.total
|
||||
pages.value = data.pages
|
||||
nav.scrollToTop()
|
||||
nav.scrollPageToTop()
|
||||
}).finally(() => {
|
||||
isLoading.value = false
|
||||
nav.setPageLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,7 +173,7 @@ function cacheWarm() {
|
||||
ref="pagination"
|
||||
:pageSize="pageSize"
|
||||
:total="pageTotal"
|
||||
:is-loading="isLoading"
|
||||
:disabled="nav.isPageLoading"
|
||||
@change="onChangePage"
|
||||
/>
|
||||
</div>
|
||||
@@ -190,7 +189,7 @@ function cacheWarm() {
|
||||
overflow: hidden;
|
||||
padding: 10px 15px 0 15px;
|
||||
background: #fff;
|
||||
z-index: 999;
|
||||
z-index: 10;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: .3s ease-out padding;
|
||||
|
||||
|
||||
@@ -13,24 +13,30 @@ const { curtTab } = storeToRefs(nav)
|
||||
const settingsTpl = YAML.parseDocument(confTemplate)
|
||||
const isLoading = ref(false)
|
||||
|
||||
onBeforeMount(() => {
|
||||
settings.init()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
nav.updateTabs({
|
||||
'sites': '站点',
|
||||
'transfer': '迁移',
|
||||
})
|
||||
|
||||
watch(curtTab, (tab) => {
|
||||
if (tab === 'transfer') router.replace('/transfer')
|
||||
if (tab === 'sites') router.replace('/sites')
|
||||
else if (tab === 'transfer') router.replace('/transfer')
|
||||
})
|
||||
|
||||
artalk!.ctx.getApi().system.getSettings().then((yamlStr) => {
|
||||
settings.customs.value = YAML.parseDocument(yamlStr)
|
||||
settings.get().customs.value = YAML.parseDocument(yamlStr)
|
||||
})
|
||||
})
|
||||
|
||||
function save() {
|
||||
let yamlStr = ''
|
||||
try {
|
||||
yamlStr = settings.customs.value?.toString() || ''
|
||||
yamlStr = settings.get().customs.value?.toString() || ''
|
||||
} catch (err) {
|
||||
alert('配置文件生成失败:'+err)
|
||||
console.error(err)
|
||||
|
||||
@@ -8,18 +8,17 @@ const sites = ref<SiteData[]>([])
|
||||
const curtEditSite = ref<SiteData|null>(null)
|
||||
const showSiteCreate = ref(false)
|
||||
const siteCreateInitVal = ref()
|
||||
const isLoading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
nav.updateTabs({
|
||||
|
||||
}, '')
|
||||
}, '站点')
|
||||
|
||||
isLoading.value = true
|
||||
nav.setPageLoading(true)
|
||||
artalk?.ctx.getApi().site.siteGet().then(gotSites => {
|
||||
sites.value = gotSites
|
||||
}).finally(() => {
|
||||
isLoading.value = false
|
||||
nav.setPageLoading(false)
|
||||
})
|
||||
|
||||
// 通过启动参数打开站点创建
|
||||
@@ -122,7 +121,6 @@ function onSiteItemRemove(id: number) {
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<LoadingLayer v-if="isLoading" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { useNavStore } from '../stores/nav'
|
||||
import { artalk, bootParams } from '../global'
|
||||
import Pagination from '../components/Pagination.vue'
|
||||
import type { UserDataForAdmin } from 'artalk/types/artalk-data'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const nav = useNavStore()
|
||||
const router = useRouter()
|
||||
const { curtTab } = storeToRefs(nav)
|
||||
const users = ref<UserDataForAdmin[]>([])
|
||||
|
||||
const pageSize = ref(30)
|
||||
const pageTotal = ref(0)
|
||||
const pagination = ref<InstanceType<typeof Pagination>>()
|
||||
const curtType = ref('all')
|
||||
|
||||
const addingUser = ref(false)
|
||||
const editingUser = ref<UserDataForAdmin|undefined>()
|
||||
|
||||
onMounted(() => {
|
||||
nav.updateTabs({
|
||||
'all': '全部',
|
||||
'admin': '管理员',
|
||||
'create': '新增'
|
||||
}, 'all')
|
||||
|
||||
watch(curtTab, (tab) => {
|
||||
if (tab === 'create') {
|
||||
if (editingUser.value !== undefined) {
|
||||
editingUser.value = undefined
|
||||
nextTick(() => {
|
||||
addingUser.value = true
|
||||
})
|
||||
} else {
|
||||
addingUser.value = true
|
||||
}
|
||||
} else {
|
||||
addingUser.value = false
|
||||
editingUser.value = undefined
|
||||
|
||||
curtType.value = tab
|
||||
pagination.value?.reset()
|
||||
reqUsers(0)
|
||||
}
|
||||
})
|
||||
|
||||
reqUsers(0)
|
||||
})
|
||||
|
||||
function reqUsers(offset: number) {
|
||||
nav.setPageLoading(true)
|
||||
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(() => {
|
||||
nav.setPageLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
function onChangePage(offset: number) {
|
||||
reqUsers(offset)
|
||||
}
|
||||
|
||||
function editUser(user: UserDataForAdmin) {
|
||||
if (user.is_in_conf) {
|
||||
alert('暂不支持在线编辑配置文件中的用户,请手动修改配置文件')
|
||||
return
|
||||
}
|
||||
|
||||
addingUser.value = false
|
||||
editingUser.value = user
|
||||
}
|
||||
|
||||
function updateUser(user: UserDataForAdmin) {
|
||||
const index = users.value.findIndex(u => u.id === user.id)
|
||||
if (index != -1) {
|
||||
// 修改用户
|
||||
const orgUser = users.value[index]
|
||||
Object.keys(users).forEach(key => {
|
||||
;(orgUser as any)[key] = (users as any)[key]
|
||||
})
|
||||
} else {
|
||||
// 创建用户
|
||||
pagination.value!.reset()
|
||||
reqUsers(0)
|
||||
}
|
||||
|
||||
closeEditUser()
|
||||
}
|
||||
|
||||
function closeEditUser() {
|
||||
addingUser.value = false
|
||||
editingUser.value = undefined
|
||||
}
|
||||
|
||||
function delUser(user: UserDataForAdmin) {
|
||||
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)
|
||||
users.value.splice(index, 1)
|
||||
|
||||
if (user.is_in_conf) {
|
||||
alert('用户已从数据库删除,请手动编辑配置文件并删除用户')
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
alert('删除失败:'+e.msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-list-wrap">
|
||||
<div class="user-list">
|
||||
<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="该用户具有管理员权限">管理员</span>
|
||||
<span v-if="user.is_in_conf" class="badge in-conf" title="该用户存在于配置文件中">配置文件</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="sub">{{ user.email }}</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<span @click="editUser(user)">编辑</span>
|
||||
<span>评论 ({{user.comment_count}})</span>
|
||||
<span @click="delUser(user)">删除</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
ref="pagination"
|
||||
:pageSize="pageSize"
|
||||
:total="pageTotal"
|
||||
:disabled="nav.isPageLoading"
|
||||
@change="onChangePage"
|
||||
/>
|
||||
|
||||
<UserEditor
|
||||
v-if="addingUser || editingUser !== undefined"
|
||||
:user="editingUser"
|
||||
@update="updateUser"
|
||||
@close="closeEditUser"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-list-wrap {
|
||||
.user-list {
|
||||
.user-item {
|
||||
padding: 15px 30px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--at-color-border);
|
||||
}
|
||||
|
||||
.user-main {
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
color: var(--at-color-deep);
|
||||
font-size: 20px;
|
||||
|
||||
.badge-grp {
|
||||
display: flex;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--at-color-meta);
|
||||
background: var(--at-color-bg-grey);
|
||||
padding: 0 6px;
|
||||
line-height: 17px;
|
||||
border-radius: 2px;
|
||||
color: #fff;
|
||||
|
||||
&.admin {
|
||||
background: #ff6c00;
|
||||
}
|
||||
|
||||
&.in-conf {
|
||||
background: #297bff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: var(--at-color-sub);
|
||||
font-size: 15px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
margin-top: 10px;
|
||||
|
||||
& > span {
|
||||
cursor: pointer;
|
||||
color: var(--at-color-meta);
|
||||
font-size: 13px;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--at-color-deep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,11 +5,18 @@ import type { SiteData } from 'artalk/types/artalk-data'
|
||||
type TabsObj = {[name: string]: string}
|
||||
|
||||
export const useNavStore = defineStore('nav', () => {
|
||||
const sites = ref<SiteData[]>([])
|
||||
const curtPage = ref('comments')
|
||||
const curtTab = ref('')
|
||||
const tabs = ref<TabsObj>({})
|
||||
|
||||
const curtPage = ref('comments')
|
||||
const sites = ref<SiteData[]>([])
|
||||
const siteSwitcherShow = ref(false)
|
||||
|
||||
const isSearchEnabled = ref(false)
|
||||
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 updateTabs = (aTabs: TabsObj, activeTab?: string) => {
|
||||
@@ -33,25 +40,36 @@ export const useNavStore = defineStore('nav', () => {
|
||||
siteSwitcherShow.value = !siteSwitcherShow.value
|
||||
}
|
||||
|
||||
const scrollToTop = () => {
|
||||
const scrollPageToTop = () => {
|
||||
scrollableArea.value?.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
const scrollToEl = (el: HTMLElement) => {
|
||||
const scrollPageToEl = (el: HTMLElement) => {
|
||||
scrollableArea.value?.scrollTo(0, el.offsetTop)
|
||||
}
|
||||
|
||||
const setPageLoading = (pageLoading: boolean) => {
|
||||
isPageLoading.value = pageLoading
|
||||
}
|
||||
|
||||
const refreshSites = () => {
|
||||
artalk?.ctx.getApi().site.siteGet().then((respSites) => {
|
||||
sites.value = respSites
|
||||
})
|
||||
}
|
||||
|
||||
const enableSearch = (searchEvt: ((val: string) => void), searchResetEvt: () => void) => {
|
||||
isSearchEnabled.value = true
|
||||
searchEvent.value = searchEvt
|
||||
searchResetEvent.value = searchResetEvt
|
||||
}
|
||||
|
||||
return {
|
||||
sites, curtPage, curtTab, tabs, siteSwitcherShow, scrollableArea,
|
||||
sites, curtPage, curtTab, tabs, siteSwitcherShow, scrollableArea, isPageLoading,
|
||||
updateTabs, setTabActive,
|
||||
showSiteSwitcher, hideSiteSwitcher, toggleSiteSwitcher,
|
||||
scrollToTop, scrollToEl,
|
||||
scrollPageToTop, scrollPageToEl, setPageLoading,
|
||||
refreshSites,
|
||||
isSearchEnabled, searchEvent, searchResetEvent, enableSearch,
|
||||
}
|
||||
})
|
||||
@@ -8,5 +8,13 @@ export const useUserStore = defineStore('user', () => {
|
||||
const isAdmin = ref(bootParams.user.isAdmin || false)
|
||||
const token = ref(bootParams.user.token || '')
|
||||
|
||||
return { site, name, email, isAdmin, token }
|
||||
const logout = () => {
|
||||
site.value = ''
|
||||
name.value = ''
|
||||
email.value = ''
|
||||
isAdmin.value = false
|
||||
token.value = ''
|
||||
}
|
||||
|
||||
return { site, name, email, isAdmin, token, logout }
|
||||
})
|
||||
@@ -13,7 +13,6 @@ body {
|
||||
|
||||
.atk-sidebar {
|
||||
.atk-sidebar-inner {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.atk-sidebar-no-content {
|
||||
@@ -39,6 +38,13 @@ body {
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--at-color-font);
|
||||
|
||||
&.required::after {
|
||||
content: '*';
|
||||
color: var(--at-color-red);
|
||||
margin-left: 5px;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.atk-desc {
|
||||
|
||||
+13
@@ -10,6 +10,9 @@ import type {
|
||||
RouteLocationResolvedTypedList,
|
||||
RouteLocationNormalizedTypedList,
|
||||
RouteLocationNormalizedLoadedTypedList,
|
||||
RouteLocationAsString,
|
||||
RouteLocationAsRelativeTypedList,
|
||||
RouteLocationAsPathTypedList,
|
||||
|
||||
// helper types
|
||||
// route definitions
|
||||
@@ -34,10 +37,12 @@ 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>>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +74,14 @@ declare module 'vue-router/auto' {
|
||||
*/
|
||||
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> =
|
||||
| RouteLocationAsString<RouteNamedMap>
|
||||
| RouteLocationAsRelativeTypedList<RouteNamedMap>[Name]
|
||||
| RouteLocationAsPathTypedList<RouteNamedMap>[Name]
|
||||
|
||||
/**
|
||||
* Generate a type safe params for a route location. Requires the name of the route to be passed as a generic.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "artalk",
|
||||
"version": "2.4.2",
|
||||
"backendMinVersion": "2.2.0",
|
||||
"version": "2.4.4",
|
||||
"backendMinVersion": "2.3.0",
|
||||
"license": "LGPL-3.0",
|
||||
"description": "A Selfhosted Comment System",
|
||||
"keywords": [
|
||||
@@ -42,6 +42,6 @@
|
||||
"hanabi": "^0.4.0",
|
||||
"insane": "^2.6.2",
|
||||
"marked": "^4.0.19",
|
||||
"abortcontroller-polyfill": "^1.7.3"
|
||||
"abortcontroller-polyfill": "^1.7.5"
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,12 @@ const ApiComponents = {
|
||||
|
||||
class Api {
|
||||
protected ctx: Context
|
||||
public baseURL: string
|
||||
public get baseURL() {
|
||||
return `${this.ctx.conf.server}/api`
|
||||
}
|
||||
|
||||
constructor (ctx: Context) {
|
||||
this.ctx = ctx
|
||||
this.baseURL = `${ctx.conf.server}/api`
|
||||
|
||||
Object.entries(ApiComponents).forEach(([key, ApiComponent]) => {
|
||||
this[key] = new ApiComponent(this, this.ctx)
|
||||
|
||||
@@ -36,4 +36,11 @@ export default class SystemApi extends ApiBase {
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取 API 版本信息 */
|
||||
public async version() {
|
||||
const resp = await fetch(`${this.api.baseURL}/version`, { method: 'POST' })
|
||||
const data = await resp.json()
|
||||
return data as { app: string, version: string, commit_hash: string, fe_min_version: string }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserData, NotifyData } from '~/types/artalk-data'
|
||||
import { UserData, NotifyData, UserDataForAdmin } from '~/types/artalk-data'
|
||||
import ApiBase from './api-base'
|
||||
import { ToFormData } from './request'
|
||||
|
||||
@@ -15,7 +15,7 @@ export default class UserApi extends ApiBase {
|
||||
if (this.ctx.conf.site) params.site_name = this.ctx.conf.site
|
||||
|
||||
const data = await this.POST<any>('/login', params)
|
||||
return (data.token as string)
|
||||
return data as { token: string, user: UserData }
|
||||
}
|
||||
|
||||
/** 用户 · 获取 */
|
||||
@@ -72,4 +72,61 @@ export default class UserApi extends ApiBase {
|
||||
|
||||
return this.POST(`/mark-read`, params)
|
||||
}
|
||||
|
||||
/** 用户 · 列表 */
|
||||
public async userList(offset?: number, limit?: number, type?: 'all'|'admin'|'in_conf') {
|
||||
const params: any = {
|
||||
offset: offset || 0,
|
||||
limit: limit || 15,
|
||||
}
|
||||
|
||||
if (type) params.type = type
|
||||
|
||||
const d = await this.POST<any>('/admin/user-get', params)
|
||||
return (d as { users: UserDataForAdmin[], total: number })
|
||||
}
|
||||
|
||||
/** 用户 · 新增 */
|
||||
public async userAdd(user: Partial<UserDataForAdmin>, password?: string) {
|
||||
const params: any = {
|
||||
name: user.name || '',
|
||||
email: user.email || '',
|
||||
password: password || '',
|
||||
link: user.link || '',
|
||||
is_admin: user.is_admin || false,
|
||||
site_names: user.site_names_raw || '',
|
||||
receive_email: user.receive_email || true,
|
||||
badge_name: user.badge_name || '',
|
||||
badge_color: user.badge_color || '',
|
||||
}
|
||||
|
||||
const d = await this.POST<any>('/admin/user-add', params)
|
||||
return (d.user as UserDataForAdmin)
|
||||
}
|
||||
|
||||
/** 用户 · 修改 */
|
||||
public async userEdit(user: Partial<UserDataForAdmin>, password?: string) {
|
||||
const params: any = {
|
||||
id: user.id,
|
||||
name: user.name || '',
|
||||
email: user.email || '',
|
||||
password: password || '',
|
||||
link: user.link || '',
|
||||
is_admin: user.is_admin || false,
|
||||
site_names: user.site_names_raw || '',
|
||||
receive_email: user.receive_email || true,
|
||||
badge_name: user.badge_name || '',
|
||||
badge_color: user.badge_color || '',
|
||||
}
|
||||
|
||||
const d = await this.POST<any>('/admin/user-edit', params)
|
||||
return (d.user as UserDataForAdmin)
|
||||
}
|
||||
|
||||
/** 用户 · 删除 */
|
||||
public userDel(userID: number) {
|
||||
return this.POST('/admin/user-del', {
|
||||
id: String(userID)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,6 @@ export default class Artalk {
|
||||
/* 初始化 Context */
|
||||
this.ctx = new ConcreteContext(this.conf, this.$root)
|
||||
|
||||
// @ts-ignore 远程加载引用后端的配置
|
||||
if (this.conf.useBackendConf) return this.loadConfRemoteAndInitComponents()
|
||||
|
||||
/* 初始化组件 */
|
||||
this.initComponents()
|
||||
}
|
||||
@@ -59,7 +56,6 @@ export default class Artalk {
|
||||
private initComponents() {
|
||||
this.initLocale()
|
||||
this.initLayer()
|
||||
this.initDarkMode()
|
||||
Utils.initMarked(this.ctx)
|
||||
|
||||
const Components: { [name: string]: () => void } = {
|
||||
@@ -91,7 +87,6 @@ export default class Artalk {
|
||||
sidebarLayer: () => {
|
||||
const sidebarLayer = new SidebarLayer(this.ctx)
|
||||
this.ctx.setSidebarLayer(sidebarLayer)
|
||||
this.$root.appendChild(sidebarLayer.$el)
|
||||
},
|
||||
|
||||
// 默认事件绑定
|
||||
@@ -148,24 +143,6 @@ export default class Artalk {
|
||||
return conf
|
||||
}
|
||||
|
||||
/** 初始化组件根据远程加载的配置 */
|
||||
private async loadConfRemoteAndInitComponents() {
|
||||
await this.loadConfRemote()
|
||||
this.initComponents()
|
||||
return this
|
||||
}
|
||||
|
||||
/** 远程加载配置 */
|
||||
private async loadConfRemote() {
|
||||
Ui.showLoading(this.$root)
|
||||
let backendConf = {}
|
||||
try {
|
||||
backendConf = await this.ctx.getApi().system.conf()
|
||||
} catch (err) { console.error("Load config from remote err", err) }
|
||||
this.ctx.conf = Utils.mergeDeep(this.ctx.conf, backendConf)
|
||||
Ui.hideLoading(this.$root)
|
||||
}
|
||||
|
||||
/** 事件绑定初始化 */
|
||||
private initEventBind() {
|
||||
// 锚点快速跳转评论
|
||||
@@ -194,18 +171,6 @@ export default class Artalk {
|
||||
Layer.BodyOrgPaddingRight = document.body.style.paddingRight
|
||||
}
|
||||
|
||||
/** 暗黑模式初始化 */
|
||||
private initDarkMode() {
|
||||
if (this.conf.darkMode === 'auto') {
|
||||
// 自动切换暗黑模式,事件监听
|
||||
const darkModeMedia = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
darkModeMedia.addEventListener('change', (e) => { this.setDarkMode(e.matches) })
|
||||
this.setDarkMode(darkModeMedia.matches)
|
||||
} else {
|
||||
this.setDarkMode(this.conf.darkMode || false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听事件 */
|
||||
public on<K extends keyof EventPayloadMap>(name: K, handler: Handler<EventPayloadMap[K]>) {
|
||||
this.ctx.on(name, handler, 'external')
|
||||
|
||||
@@ -58,7 +58,8 @@ export default class CommentRender {
|
||||
private renderAvatar() {
|
||||
const $avatar = this.$el.querySelector<HTMLElement>('.atk-avatar')!
|
||||
const $avatarImg = Utils.createElement<HTMLImageElement>('<img />')
|
||||
$avatarImg.src = this.comment.getGravatarURL()
|
||||
const avatarURLBuilder = this.ctx.conf.avatarURLBuilder
|
||||
$avatarImg.src = avatarURLBuilder ? avatarURLBuilder(this.data) : this.comment.getGravatarURL()
|
||||
if (this.data.link) {
|
||||
const $avatarA = Utils.createElement<HTMLLinkElement>('<a target="_blank" rel="noreferrer noopener nofollow"></a>')
|
||||
$avatarA.href = Utils.isValidURL(this.data.link) ? this.data.link : `https://${this.data.link}`
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CommentData, NotifyData } from '~/types/artalk-data'
|
||||
import { Event } from '~/types/event'
|
||||
import getI18n, { I18n } from './i18n'
|
||||
import User from './lib/user'
|
||||
import * as Utils from './lib/utils'
|
||||
import ContextApi from '../types/context'
|
||||
import Editor from './editor'
|
||||
import Comment from './comment'
|
||||
@@ -49,6 +50,10 @@ export default class Context implements ContextApi {
|
||||
this.$root.innerHTML = ''
|
||||
|
||||
this.api = new Api(this)
|
||||
|
||||
this.on('conf-loaded', () => {
|
||||
this.refreshDarkModeConf()
|
||||
})
|
||||
}
|
||||
|
||||
/* 设置持有的同事类 */
|
||||
@@ -261,16 +266,39 @@ export default class Context implements ContextApi {
|
||||
const darkModeClassName = 'atk-dark-mode'
|
||||
|
||||
this.conf.darkMode = darkMode
|
||||
this.trigger('conf-updated')
|
||||
|
||||
if (this.conf.darkMode) this.$root.classList.add(darkModeClassName)
|
||||
if (darkMode) this.$root.classList.add(darkModeClassName)
|
||||
else this.$root.classList.remove(darkModeClassName)
|
||||
|
||||
// for Layer
|
||||
const { $wrap: $layerWrap } = GetLayerWrap(this)
|
||||
if ($layerWrap) {
|
||||
if (this.conf.darkMode) $layerWrap.classList.add(darkModeClassName)
|
||||
if (darkMode) $layerWrap.classList.add(darkModeClassName)
|
||||
else $layerWrap.classList.remove(darkModeClassName)
|
||||
}
|
||||
}
|
||||
|
||||
private darkModeMedia = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
private darkModeAutoFunc?: (evt: MediaQueryListEvent) => void
|
||||
private refreshDarkModeConf(): void {
|
||||
if (this.conf.darkMode === 'auto') {
|
||||
// 自动切换暗黑模式,事件监听
|
||||
this.setDarkMode(this.darkModeMedia.matches)
|
||||
if (!this.darkModeAutoFunc) {
|
||||
this.darkModeAutoFunc = (evt) => { this.setDarkMode(evt.matches) }
|
||||
this.darkModeMedia.addEventListener('change', this.darkModeAutoFunc)
|
||||
}
|
||||
} else {
|
||||
if (this.darkModeAutoFunc) {
|
||||
// 解除事件监听绑定
|
||||
this.darkModeMedia.removeEventListener('change', this.darkModeAutoFunc)
|
||||
}
|
||||
this.setDarkMode(this.conf.darkMode || false)
|
||||
}
|
||||
}
|
||||
|
||||
public updateConf(conf: Partial<ArtalkConfig>): void {
|
||||
this.conf = Utils.mergeDeep(this.conf, conf)
|
||||
this.trigger('conf-loaded')
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ const defaults: ArtalkConfig = {
|
||||
|
||||
gravatar: {
|
||||
default: 'mp',
|
||||
mirror: 'https://sdn.geekzu.org/avatar/',
|
||||
mirror: 'https://cravatar.cn/avatar/',
|
||||
},
|
||||
|
||||
pagination: {
|
||||
@@ -46,7 +46,7 @@ const defaults: ArtalkConfig = {
|
||||
imgUpload: true,
|
||||
reqTimeout: 15000,
|
||||
versionCheck: true,
|
||||
useBackendConf: false,
|
||||
useBackendConf: true,
|
||||
|
||||
locale: 'zh-CN',
|
||||
}
|
||||
|
||||
@@ -68,15 +68,15 @@ export default class Editor extends Component {
|
||||
this.$plugBtnWrap = this.$el.querySelector('.atk-plug-btn-wrap')!
|
||||
this.$plugPanelWrap = this.$el.querySelector('.atk-plug-panel-wrap')!
|
||||
|
||||
// 执行初始化
|
||||
this.initLocalStorage()
|
||||
this.initHeader()
|
||||
this.initTextarea()
|
||||
this.initPlugs()
|
||||
this.initSubmitBtn()
|
||||
|
||||
// 监听事件
|
||||
this.ctx.on('conf-updated', () => {})
|
||||
this.ctx.on('conf-loaded', () => {
|
||||
// 执行初始化
|
||||
this.initLocalStorage()
|
||||
this.initHeader()
|
||||
this.initTextarea()
|
||||
this.initSubmitBtn()
|
||||
this.initPlugs()
|
||||
})
|
||||
}
|
||||
|
||||
private initLocalStorage() {
|
||||
@@ -433,6 +433,7 @@ export default class Editor extends Component {
|
||||
this.$plugBtnWrap.innerHTML = ''
|
||||
|
||||
const disabledPlugs: string[] = []
|
||||
if (!this.conf.imgUpload) disabledPlugs.push('upload')
|
||||
if (!this.conf.emoticons) disabledPlugs.push('emoticons')
|
||||
if (!this.conf.preview) disabledPlugs.push('preview')
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export default class UploadPlug extends EditorPlug {
|
||||
$input.click() // 显示选择图片对话框
|
||||
}
|
||||
|
||||
this.ctx.on('conf-updated', () => {
|
||||
this.ctx.on('conf-loaded', () => {
|
||||
if (!this.ctx.conf.imgUpload) {
|
||||
this.getBtn()!.setAttribute('atk-only-admin-show', '')
|
||||
this.ctx.checkAdminShowEl()
|
||||
|
||||
@@ -53,11 +53,6 @@ export default class SidebarLayer extends Component {
|
||||
// viewWrap 滚动条归位
|
||||
// this.$viewWrap.scrollTo(0, 0)
|
||||
|
||||
// 执行动画
|
||||
setTimeout(() => {
|
||||
this.$el.style.transform = 'translate(0, 0)'
|
||||
}, 20)
|
||||
|
||||
// 管理员身份验证 (若身份失效,弹出验证窗口)
|
||||
;(async () => {
|
||||
const resp = await this.ctx.getApi().user.loginStatus()
|
||||
@@ -76,9 +71,6 @@ export default class SidebarLayer extends Component {
|
||||
}
|
||||
})()
|
||||
|
||||
// 清空 unread
|
||||
this.ctx.updateNotifies([])
|
||||
|
||||
// 第一次加载
|
||||
if (this.firstShow) {
|
||||
this.$iframeWrap.innerHTML = ''
|
||||
@@ -114,6 +106,16 @@ export default class SidebarLayer extends Component {
|
||||
this.iframeLoad(this.$iframe!.src.replace('&darkMode=1', ''))
|
||||
}
|
||||
|
||||
// 执行滑动显示动画
|
||||
setTimeout(() => {
|
||||
this.$el.style.transform = 'translate(0, 0)'
|
||||
}, 100)
|
||||
|
||||
// 清空 unread
|
||||
setTimeout(() => {
|
||||
this.ctx.updateNotifies([])
|
||||
}, 0)
|
||||
|
||||
this.ctx.trigger('sidebar-show')
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ const AdminChecker: Checker = {
|
||||
password: inputVal
|
||||
}
|
||||
|
||||
return that.ctx.getApi().user.login(data.name, data.email, data.password)
|
||||
return (async () => {
|
||||
const resp = await that.ctx.getApi().user.login(data.name, data.email, data.password)
|
||||
return resp.token
|
||||
})()
|
||||
},
|
||||
|
||||
body(that, ctx) {
|
||||
|
||||
@@ -77,6 +77,7 @@ const CaptchaChecker: Checker = {
|
||||
|
||||
onError(that, ctx, err, inputVal, formEl) {
|
||||
formEl.querySelector<HTMLElement>('.atk-captcha-img')!.click() // 刷新验证码
|
||||
formEl.querySelector<HTMLInputElement>('input[type="text"]')!.value = '' // 清空输入框输入
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,15 @@ export interface CountConf {
|
||||
}
|
||||
|
||||
export const PvCountWidget: ArtalkPlug = (ctx: ContextApi) => {
|
||||
initCountWidget({ ctx, pvAdd: true })
|
||||
if (!ctx.conf.useBackendConf) {
|
||||
// 不使用后端配置,在 Artalk 实例被创建后,立刻初始化
|
||||
initCountWidget({ ctx, pvAdd: true })
|
||||
} else {
|
||||
// 若使用后端配置,需待配置成功获取后 (来自后端设定的 pvEl 等),再初始化
|
||||
ctx.on('list-loaded', () => {
|
||||
initCountWidget({ ctx, pvAdd: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化评论数和 PV 数量展示元素 */
|
||||
|
||||
@@ -204,7 +204,14 @@ export function marked(ctx: Context, src: string): string {
|
||||
// @link https://gist.github.com/lionel-rowe/bb384465ba4e4c81a9c8dada84167225
|
||||
let dest = insane(markedContent, {
|
||||
allowedClasses: {},
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
// @refer CVE-2018-8495
|
||||
// @link https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-8495
|
||||
// @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 (安全性有待考虑)
|
||||
],
|
||||
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',
|
||||
|
||||
@@ -15,17 +15,56 @@ export default class ListLite extends Component {
|
||||
protected data?: ListData
|
||||
protected isLoading: boolean = false
|
||||
|
||||
public noCommentText: string // 无评论时显示
|
||||
public noCommentText?: string // 无评论时显示
|
||||
|
||||
/** 嵌套模式下的排序方式 */
|
||||
private _nestSortBy?: ListNest.SortByType
|
||||
public get nestSortBy() {
|
||||
if (this._nestSortBy !== undefined) return this._nestSortBy
|
||||
return this.ctx.conf.nestSort || 'DATE_ASC'
|
||||
}
|
||||
public set nestSortBy(val: ListNest.SortByType) {
|
||||
this._nestSortBy = val
|
||||
}
|
||||
|
||||
/** 平铺模式 */
|
||||
public flatMode = false
|
||||
private _flatMode?:boolean
|
||||
public get flatMode() {
|
||||
if (this._flatMode !== undefined)
|
||||
return this._flatMode
|
||||
|
||||
/** 嵌套模式 */
|
||||
public nestSortBy!: ListNest.SortByType
|
||||
// 配置开启平铺模式
|
||||
if (this.ctx.conf.flatMode === true || Number(this.ctx.conf.nestMax) <= 1)
|
||||
return true
|
||||
|
||||
// 自动判断启用平铺模式
|
||||
if (this.ctx.conf.flatMode === 'auto' && window.matchMedia("(max-width: 768px)").matches)
|
||||
return true
|
||||
|
||||
return false
|
||||
}
|
||||
public set flatMode(val: boolean) {
|
||||
this._flatMode = val
|
||||
}
|
||||
|
||||
/** 分页方式 */
|
||||
public _pageMode?: 'pagination'|'read-more'
|
||||
public get pageMode() {
|
||||
return this._pageMode || (this.conf.pagination.readMore ? 'read-more' : 'pagination')
|
||||
}
|
||||
public set pageMode(val: 'pagination'|'read-more') {
|
||||
this._pageMode = val
|
||||
}
|
||||
|
||||
/** 每页数量 (每次请求获取量) */
|
||||
private _pageSize?: number
|
||||
public get pageSize() {
|
||||
return this._pageSize || this.conf.pagination.pageSize
|
||||
}
|
||||
public set pageSize(val: number) {
|
||||
this._pageSize = val
|
||||
}
|
||||
|
||||
/** 分页 */
|
||||
public pageMode: 'pagination'|'read-more' = 'pagination'
|
||||
public pageSize: number = 20 // 每次请求获取量
|
||||
public scrollListenerAt?: HTMLElement // 监听指定元素上的滚动
|
||||
public repositionAt?: HTMLElement // 翻页归位到指定元素
|
||||
|
||||
@@ -51,12 +90,6 @@ export default class ListLite extends Component {
|
||||
</div>`)
|
||||
this.$commentsWrap = this.$el.querySelector('.atk-list-comments-wrap')!
|
||||
|
||||
// 评论为空时显示字符
|
||||
this.noCommentText = ctx.conf.noComment || ctx.$t('noComment')
|
||||
|
||||
// 嵌套排序方式
|
||||
this.nestSortBy = this.ctx.conf.nestSort || 'DATE_ASC'
|
||||
|
||||
// 评论时间自动更新
|
||||
window.setInterval(() => {
|
||||
this.$el.querySelectorAll<HTMLElement>('[data-atk-comment-date]').forEach(el => {
|
||||
@@ -64,6 +97,10 @@ export default class ListLite extends Component {
|
||||
el.innerText = Utils.timeAgo(new Date(Number(date)), this.ctx)
|
||||
})
|
||||
}, 30 * 1000) // 30s 更新一次
|
||||
|
||||
// 事件监听
|
||||
this.ctx.on('conf-loaded', () => {
|
||||
})
|
||||
}
|
||||
|
||||
public getData() {
|
||||
@@ -99,8 +136,8 @@ export default class ListLite extends Component {
|
||||
// 事件通知(开始加载评论)
|
||||
this.ctx.trigger('list-load')
|
||||
|
||||
// 清空评论(加载按钮)
|
||||
if (isFirstLoad && this.pageMode === 'read-more') {
|
||||
// 清空评论(按钮加载更多的第一页、每次加载分页页面)
|
||||
if ((this.pageMode === 'read-more' && isFirstLoad) || this.pageMode === 'pagination') {
|
||||
this.ctx.clearAllComments()
|
||||
}
|
||||
|
||||
@@ -130,22 +167,25 @@ export default class ListLite extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
protected onLoad(data: ListData, offset: number) {
|
||||
// 清空评论(分页条)
|
||||
if (this.pageMode === 'pagination') { this.ctx.clearAllComments() }
|
||||
private confLoaded = false
|
||||
|
||||
protected onLoad(data: ListData, offset: number) {
|
||||
this.data = data
|
||||
|
||||
// 装载后端提供的配置
|
||||
if (!this.confLoaded) {
|
||||
if (this.conf.useBackendConf)
|
||||
this.ctx.updateConf(data.conf.frontend_conf)
|
||||
else
|
||||
this.ctx.updateConf({})
|
||||
this.confLoaded = true
|
||||
}
|
||||
|
||||
// 版本检测
|
||||
const feMinVersion = data.api_version?.fe_min_version || '0.0.0'
|
||||
if (this.ctx.conf.versionCheck && this.versionCheck('frontend', feMinVersion, ARTALK_VERSION)) return
|
||||
if (this.ctx.conf.versionCheck && this.versionCheck('backend', backendMinVersion, data.api_version?.version)) return
|
||||
|
||||
// 图片上传功能
|
||||
if (data.conf && typeof data.conf.img_upload === "boolean") {
|
||||
this.ctx.conf.imgUpload = data.conf.img_upload
|
||||
}
|
||||
|
||||
// 导入数据
|
||||
this.importComments(data.comments)
|
||||
|
||||
@@ -157,7 +197,6 @@ export default class ListLite extends Component {
|
||||
|
||||
this.ctx.updateNotifies(data.unread || [])
|
||||
this.ctx.trigger('list-loaded')
|
||||
this.ctx.trigger('conf-updated')
|
||||
|
||||
if (this.onAfterLoad) this.onAfterLoad(data)
|
||||
}
|
||||
@@ -238,6 +277,10 @@ export default class ListLite extends Component {
|
||||
|
||||
/** 错误处理 */
|
||||
protected onError(msg: any, offset: number, errData?: any) {
|
||||
if (!this.confLoaded) {
|
||||
this.ctx.updateConf({})
|
||||
}
|
||||
|
||||
msg = String(msg)
|
||||
console.error(msg)
|
||||
|
||||
@@ -286,7 +329,7 @@ export default class ListLite extends Component {
|
||||
if (isNoComment) {
|
||||
if (!$noComment) {
|
||||
$noComment = Utils.createElement('<div class="atk-list-no-comment"></div>')
|
||||
$noComment.innerHTML = this.noCommentText
|
||||
$noComment.innerHTML = this.noCommentText || this.ctx.conf.noComment || this.ctx.$t('noComment')
|
||||
this.$commentsWrap.appendChild($noComment)
|
||||
}
|
||||
} else {
|
||||
@@ -473,7 +516,6 @@ export default class ListLite extends Component {
|
||||
ignoreBtn.onclick = () => {
|
||||
Ui.setError(this.$el.parentElement!, null)
|
||||
this.ctx.conf.versionCheck = false
|
||||
this.ctx.trigger('conf-updated')
|
||||
this.fetchComments(0)
|
||||
}
|
||||
errEl.append(ignoreBtn)
|
||||
|
||||
@@ -23,20 +23,7 @@ export default class List extends ListLite {
|
||||
el.querySelector('.atk-list-body')!.append(this.$el)
|
||||
this.$el = el
|
||||
|
||||
// 平铺模式
|
||||
if (this.ctx.conf.flatMode === true) {
|
||||
this.flatMode = true // 遵循配置总是开启平铺模式
|
||||
} else if (this.conf.nestMax && this.conf.nestMax <= 1) {
|
||||
this.flatMode = true // 嵌套层数值无效时,强制开启平铺模式
|
||||
} else if (this.ctx.conf.flatMode === 'auto') {
|
||||
// 自动判断启用平铺模式
|
||||
if (window.matchMedia("(max-width: 768px)").matches)
|
||||
this.flatMode = true
|
||||
}
|
||||
|
||||
// 分页模式
|
||||
this.pageMode = this.conf.pagination.readMore ? 'read-more' : 'pagination'
|
||||
this.pageSize = this.conf.pagination.pageSize || 20
|
||||
this.repositionAt = this.$el
|
||||
|
||||
// 操作按钮
|
||||
@@ -48,11 +35,6 @@ export default class List extends ListLite {
|
||||
})
|
||||
this.$commentCountNum = this.$commentCount.querySelector('.atk-comment-count-num')!
|
||||
|
||||
// 评论列表排序 Dropdown 下拉选择层
|
||||
if (this.ctx.conf.listSort) {
|
||||
this.initDropdown()
|
||||
}
|
||||
|
||||
// copyright
|
||||
this.$el.querySelector<HTMLElement>('.atk-copyright')!.innerHTML = `Powered By <a href="https://artalk.js.org" target="_blank" title="Artalk v${ARTALK_VERSION}">Artalk</a>`
|
||||
|
||||
@@ -104,6 +86,11 @@ export default class List extends ListLite {
|
||||
this.ctx.editorOpen()
|
||||
this.$closeCommentBtn.innerHTML = this.$t('closeComment')
|
||||
}
|
||||
|
||||
// 评论列表排序 Dropdown 下拉选择层
|
||||
if (this.ctx.conf.listSort) {
|
||||
this.initDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
protected onLoad(data: ListData, offset: number) {
|
||||
@@ -197,8 +184,13 @@ export default class List extends ListLite {
|
||||
}
|
||||
}
|
||||
|
||||
private dropdownLoaded = false
|
||||
|
||||
/** 初始化选择下拉层 */
|
||||
protected initDropdown() {
|
||||
if (this.dropdownLoaded) return
|
||||
this.dropdownLoaded = true
|
||||
|
||||
this.$dropdownWrap = this.$commentCount
|
||||
this.$commentCount.classList.add('atk-dropdown-wrap')
|
||||
|
||||
|
||||
+5
-1
@@ -1,4 +1,5 @@
|
||||
import { I18n } from '~/src/i18n'
|
||||
import { CommentData } from './artalk-data'
|
||||
|
||||
export default interface ArtalkConfig {
|
||||
/** 装载元素 */
|
||||
@@ -39,6 +40,9 @@ export default interface ArtalkConfig {
|
||||
default: string
|
||||
}
|
||||
|
||||
/** 头像链接生成器 */
|
||||
avatarURLBuilder?: (comment: CommentData) => string,
|
||||
|
||||
/** 分页配置 */
|
||||
pagination: {
|
||||
/** 每次请求获取数量 */
|
||||
@@ -103,7 +107,7 @@ export default interface ArtalkConfig {
|
||||
/** 版本检测 */
|
||||
versionCheck: boolean
|
||||
|
||||
/** 应用后端配置 */
|
||||
/** 引用后端配置 */
|
||||
useBackendConf: boolean
|
||||
|
||||
/** 语言本地化 */
|
||||
|
||||
+23
@@ -159,6 +159,29 @@ export interface UserData {
|
||||
|
||||
/** 是否属于管理员 */
|
||||
is_admin: boolean
|
||||
|
||||
/** 所属站点名 */
|
||||
site_names: string[]
|
||||
|
||||
/** 所属站点名 (字符串) */
|
||||
site_names_raw: string,
|
||||
|
||||
/** 是否允许接收邮件通知 */
|
||||
receive_email: boolean
|
||||
}
|
||||
|
||||
export interface UserDataForAdmin extends UserData {
|
||||
/** 最后一次操作 IP */
|
||||
last_ip: string
|
||||
|
||||
/** 最后一次操作 UA */
|
||||
last_ua: string
|
||||
|
||||
/** 是否存在于配置文件中 */
|
||||
is_in_conf: boolean
|
||||
|
||||
/** 评论数 */
|
||||
comment_count: number
|
||||
}
|
||||
|
||||
export interface NotifyData {
|
||||
|
||||
Vendored
+3
@@ -143,4 +143,7 @@ export default interface ContextApi {
|
||||
|
||||
/** 设置暗黑模式 */
|
||||
setDarkMode(darkMode: boolean): void
|
||||
|
||||
/** 更新配置 */
|
||||
updateConf(conf: Partial<ArtalkConfig>): void
|
||||
}
|
||||
Vendored
+1
-1
@@ -9,7 +9,7 @@ export interface EventPayloadMap {
|
||||
'editor-submit': undefined // 编辑器提交时
|
||||
'editor-submitted': undefined // 编辑器提交后
|
||||
'user-changed': LocalUser // 本地用户数据变更时
|
||||
'conf-updated': undefined // Artalk 配置变更时
|
||||
'conf-loaded': undefined // Artalk 配置变更时
|
||||
'sidebar-show': undefined // 侧边栏显示
|
||||
'sidebar-hide': undefined // 侧边栏隐藏
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"artalk": "workspace: *",
|
||||
"katex": "^0.16.2"
|
||||
"katex": "^0.16.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/katex": "0.14.0"
|
||||
"@types/katex": "0.16.0"
|
||||
}
|
||||
}
|
||||
Generated
+381
-318
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user