Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbfcb01b17 | ||
|
|
edd3636ece | ||
|
|
8dfb40662c | ||
|
|
11234c8e29 | ||
|
|
9f7cdac674 | ||
|
|
7ff3c9e646 | ||
|
|
d24946fc55 | ||
|
|
491dbb4fd9 | ||
|
|
d2990ba8d8 | ||
|
|
8c3dfa902c | ||
|
|
22d80e3af2 | ||
|
|
7b91cbae3f | ||
|
|
79f070fc26 | ||
|
|
2042a3fd4c | ||
|
|
069ff30f47 | ||
|
|
0065a47b78 | ||
|
|
3b343464ae | ||
|
|
87922e0809 | ||
|
|
9bf8041860 | ||
|
|
096da47032 | ||
|
|
3273fb4d34 | ||
|
|
7f77fdd889 | ||
|
|
8538bc6abe | ||
|
|
b73608af79 | ||
|
|
45db27d3c7 | ||
|
|
aae8bd2f15 | ||
|
|
1244210bfe | ||
|
|
3976aa79ed | ||
|
|
e27955b8b5 | ||
|
|
faf272c8a7 | ||
|
|
c45825922a | ||
|
|
4b9c01b0c3 | ||
|
|
928ec4a41e | ||
|
|
824e073ba2 | ||
|
|
de3712a7ca | ||
|
|
fb9e0418c7 | ||
|
|
5a54f0cdaf | ||
|
|
cefca66d76 | ||
|
|
99d3b567da | ||
|
|
132d2a9f71 | ||
|
|
baa64f3dc5 | ||
|
|
48154b2be9 | ||
|
|
06e5481574 | ||
|
|
8b91fa2b98 | ||
|
|
a0d1bf4742 | ||
|
|
75938141f0 | ||
|
|
187d45e6d9 | ||
|
|
6d7c7b78d9 | ||
|
|
b00ae596ef | ||
|
|
e6299a03e8 | ||
|
|
e77c26c792 | ||
|
|
a426adde65 | ||
|
|
df29586b7d | ||
|
|
d5cd7f9f7e |
@@ -20,9 +20,12 @@ plugins/
|
||||
# runtime data
|
||||
data/*
|
||||
!data/config.example.ini
|
||||
!data/loader.html
|
||||
!data/custom_public/
|
||||
data/custom_public/*
|
||||
!data/custom_public/README.txt
|
||||
!data/custom_public/templates/
|
||||
data/custom_public/templates/*
|
||||
!data/emoticons/
|
||||
data/emoticons/*
|
||||
!data/plugins/
|
||||
|
||||
1
.gitignore
vendored
@@ -107,3 +107,4 @@ venv.bak/
|
||||
.idea/
|
||||
data/
|
||||
log/
|
||||
.vercel
|
||||
|
||||
13
Dockerfile
@@ -8,7 +8,7 @@ WORKDIR "${BASE_PATH}/frontend"
|
||||
|
||||
# 前端依赖
|
||||
COPY frontend/package.json ./
|
||||
RUN npm i
|
||||
RUN npm i --registry=https://registry.npmmirror.com
|
||||
|
||||
# 编译前端
|
||||
COPY frontend ./
|
||||
@@ -18,7 +18,7 @@ RUN npm run build
|
||||
# 准备后端
|
||||
#
|
||||
|
||||
FROM python:3.8.12-bullseye
|
||||
FROM python:3.12.10-bookworm
|
||||
ARG BASE_PATH='/root/blivechat'
|
||||
ARG EXT_DATA_PATH='/mnt/data'
|
||||
WORKDIR "${BASE_PATH}"
|
||||
@@ -30,18 +30,17 @@ RUN pip3 install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple -r req
|
||||
|
||||
# 数据目录
|
||||
COPY . ./
|
||||
RUN mkdir -p "${EXT_DATA_PATH}/frontend/dist" \
|
||||
RUN sed 's/^host =.*$/host = 0.0.0.0/; s/^loader_url =.*$/loader_url =/' data/config.example.ini >> data/config.ini
|
||||
RUN mkdir -p "${EXT_DATA_PATH}" \
|
||||
&& mv data "${EXT_DATA_PATH}/data" \
|
||||
&& ln -s "${EXT_DATA_PATH}/data" data \
|
||||
&& mv log "${EXT_DATA_PATH}/log" \
|
||||
&& ln -s "${EXT_DATA_PATH}/log" log \
|
||||
&& ln -s "${EXT_DATA_PATH}/frontend/dist" frontend/dist
|
||||
&& ln -s "${EXT_DATA_PATH}/log" log
|
||||
|
||||
# 编译好的前端
|
||||
COPY --from=builder "${BASE_PATH}/frontend/dist" "${EXT_DATA_PATH}/frontend/dist/"
|
||||
COPY --from=builder "${BASE_PATH}/frontend/dist" frontend/dist
|
||||
|
||||
# 运行
|
||||
VOLUME "${EXT_DATA_PATH}"
|
||||
EXPOSE 12450
|
||||
ENTRYPOINT ["python3", "main.py"]
|
||||
CMD ["--host", "0.0.0.0", "--port", "12450"]
|
||||
|
||||
73
README.md
@@ -11,46 +11,44 @@
|
||||
## 特性
|
||||
|
||||
* 兼容YouTube直播评论栏的样式
|
||||
* 高亮舰队、 ~~房管~~ 、主播的用户名
|
||||
* 高亮舰队、房管、主播的用户名
|
||||
* 自带两种样式生成器,经典YouTube风格和仿微信风格
|
||||
* 支持屏蔽弹幕、合并礼物等设置
|
||||
* 支持前端直连B站服务器或者通过后端转发
|
||||
* 支持自动翻译弹幕、醒目留言到日语,可以在后台配置翻译目标语言
|
||||
* 支持标注打赏用户名的读音,可选拼音或日文假名
|
||||
* 支持配置自定义表情,不需要开通B站官方表情
|
||||
* 支持插件开发
|
||||
* 支持[自定义HTML模板](https://github.com/xfgryujk/blivechat/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89HTML%E6%A8%A1%E6%9D%BF)
|
||||
* 支持[插件开发](https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F)
|
||||
|
||||
## 使用方法
|
||||
|
||||
以下几种方式任选一种即可
|
||||
以下几种方式任选一种即可。**正式使用之前记得看[注意事项](https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98)**
|
||||
|
||||
### 一、本地使用
|
||||
推荐的方式:如果你需要使用插件、翻译等高级特性,则在本地使用;否则推荐直接通过公共服务器在线使用。因为本地使用时不会自动升级版本,有时候出了问题不能及时解决;但在线使用时会禁用部分高级特性,如果你有需要,只能本地使用了
|
||||
|
||||
### 一、在线使用
|
||||
|
||||
1. 这些是作者维护的公共服务器,根据情况随便选一个,直接用浏览器打开
|
||||
|
||||
* [blive.chat](https://blive.chat/):自动节点,一般等于cn.blive.chat,如果不可用了会进行切换,但切换需要一段时间
|
||||
* [cn.blive.chat](https://cn.blive.chat/):墙内专用节点,不容易被墙,但如果受到攻击会变得不可用
|
||||
* [cloudflare.blive.chat](https://cloudflare.blive.chat/):Cloudflare美国节点,不容易被攻击,但容易被墙
|
||||
* [vercel.blive.chat](https://vercel.blive.chat/):Vercel美国节点,不容易被攻击,但容易被墙
|
||||
|
||||
2. 输入主播在开始直播时获得的身份码,复制房间URL
|
||||
3. 用样式生成器生成样式,复制CSS
|
||||
4. 在OBS中添加浏览器源,输入URL和自定义CSS
|
||||
|
||||
### 二、本地使用
|
||||
|
||||
1. 下载[本地分发版](https://github.com/xfgryujk/blivechat/releases)(仅提供x64 Windows版)。也可以在[B站商店](https://play-live.bilibili.com/details/1694397161340)下载
|
||||
2. 双击`blivechat.exe`运行服务器。或者用命令行可以指定host和端口号:
|
||||
2. 双击`blivechat.exe`(或者`start.exe`)运行服务器
|
||||
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),剩下的步骤和在线使用时是一样的
|
||||
|
||||
```sh
|
||||
blivechat.exe --host 127.0.0.1 --port 12450
|
||||
```
|
||||
### 三、从源码运行
|
||||
|
||||
或者也可以在配置文件里指定host和端口号
|
||||
|
||||
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),输入主播在开始直播时获得的身份码,复制房间URL
|
||||
4. 用样式生成器生成样式,复制CSS
|
||||
5. 在OBS中添加浏览器源,输入URL和自定义CSS
|
||||
|
||||
**注意事项:**
|
||||
|
||||
* 本地使用时不要关闭blivechat.exe那个黑框,否则不能继续获取弹幕
|
||||
* 如果需要使用翻译功能,建议看[配置官方翻译接口教程](https://github.com/xfgryujk/blivechat/wiki/%E9%85%8D%E7%BD%AE%E5%AE%98%E6%96%B9%E7%BF%BB%E8%AF%91%E6%8E%A5%E5%8F%A3)
|
||||
|
||||
### 二、公共服务器
|
||||
|
||||
请优先在本地使用,因为公共服务器会禁用部分特性
|
||||
|
||||
* [公共服务器](http://chat.bilisc.com/)
|
||||
|
||||
### 三、源代码版(自建服务器或在Windows以外平台)
|
||||
此方式适用于自建服务器或者在Windows以外的平台运行
|
||||
|
||||
0. 由于使用了git子模块,clone时需要加上`--recursive`参数:
|
||||
|
||||
@@ -72,22 +70,29 @@
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. 运行服务器(需要Python3.8以上版本):
|
||||
2. 安装服务器依赖(需要Python 3.12以上版本):
|
||||
|
||||
```sh
|
||||
pip3 install -r requirements.txt
|
||||
python3 main.py
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. 运行服务器:
|
||||
|
||||
```sh
|
||||
python main.py
|
||||
```
|
||||
|
||||
或者可以指定host和端口号:
|
||||
|
||||
```sh
|
||||
python3 main.py --host 127.0.0.1 --port 12450
|
||||
python main.py --host 127.0.0.1 --port 12450
|
||||
```
|
||||
|
||||
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
|
||||
4. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
|
||||
|
||||
### 四、Docker(自建服务器)
|
||||
### 四、Docker
|
||||
|
||||
此方式适用于自建服务器。示例的运行参数只是最基本的,可以根据需要修改
|
||||
|
||||
1. ```sh
|
||||
docker run --name blivechat -d -p 12450:12450 \
|
||||
@@ -99,9 +104,9 @@
|
||||
|
||||
## 服务器配置
|
||||
|
||||
服务器配置在`data/config.ini`,可以配置数据库和允许自动翻译等,编辑后要重启生效
|
||||
服务器配置文件在`data/config.ini`,可以配置数据库和允许自动翻译等,编辑后要重启生效
|
||||
|
||||
**自建服务器时强烈建议不使用加载器**,否则可能因为混合HTTP和HTTPS等原因加载不出来
|
||||
**自建服务器时注意要删除loader_url配置**,否则加载不了房间页面
|
||||
|
||||
## 常用链接
|
||||
|
||||
|
||||
21
api/base.py
@@ -4,18 +4,37 @@ from typing import *
|
||||
|
||||
import tornado.web
|
||||
|
||||
import config
|
||||
|
||||
|
||||
class ApiHandler(tornado.web.RequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.json_args: Optional[dict] = None
|
||||
|
||||
def prepare(self):
|
||||
def set_default_headers(self):
|
||||
self.set_header('Cache-Control', 'no-cache')
|
||||
|
||||
self.add_header('Vary', 'Origin')
|
||||
origin = self.request.headers.get('Origin', None)
|
||||
if origin is None:
|
||||
return
|
||||
cfg = config.get_config()
|
||||
if not cfg.is_allowed_cors_origin(origin):
|
||||
return
|
||||
|
||||
self.set_header('Access-Control-Allow-Origin', origin)
|
||||
self.set_header('Access-Control-Allow-Methods', '*')
|
||||
self.set_header('Access-Control-Allow-Headers', '*')
|
||||
self.set_header('Access-Control-Max-Age', '3600')
|
||||
|
||||
def prepare(self):
|
||||
if not self.request.headers.get('Content-Type', '').startswith('application/json'):
|
||||
return
|
||||
try:
|
||||
self.json_args = json.loads(self.request.body)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
async def options(self, *_args, **_kwargs):
|
||||
self.set_status(204)
|
||||
|
||||
13
api/chat.py
@@ -223,12 +223,13 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
|
||||
|
||||
self._refresh_receive_timeout_timer()
|
||||
|
||||
# 跨域测试用
|
||||
def check_origin(self, origin):
|
||||
cfg = config.get_config()
|
||||
if cfg.debug:
|
||||
return True
|
||||
return super().check_origin(origin)
|
||||
return (
|
||||
cfg.debug # 开发时前端localhost直连
|
||||
or cfg.is_allowed_cors_origin(origin)
|
||||
or super().check_origin(origin) # 和Host相同
|
||||
)
|
||||
|
||||
@property
|
||||
def has_joined_room(self):
|
||||
@@ -374,8 +375,8 @@ class RoomInfoHandler(api.base.ApiHandler):
|
||||
logger.warning('room=%d _get_room_info failed: %s', room_id, data['message'])
|
||||
return room_id, 0
|
||||
|
||||
room_info = data['data']['room_info']
|
||||
return room_info['room_id'], room_info['uid']
|
||||
data = data['data']
|
||||
return data['room_id'], data['uid']
|
||||
|
||||
async def _get_server_host_list_and_token(self, room_id) -> Tuple[dict, Optional[str]]:
|
||||
try:
|
||||
|
||||
96
api/main.py
@@ -1,10 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import cachetools
|
||||
import tornado.web
|
||||
import yarl
|
||||
|
||||
import api.base
|
||||
import config
|
||||
@@ -15,9 +18,13 @@ logger = logging.getLogger(__name__)
|
||||
EMOTICON_UPLOAD_PATH = os.path.join(config.DATA_PATH, 'emoticons')
|
||||
EMOTICON_BASE_URL = '/emoticons'
|
||||
CUSTOM_PUBLIC_PATH = os.path.join(config.DATA_PATH, 'custom_public')
|
||||
TEMPLATE_PATH = os.path.join(CUSTOM_PUBLIC_PATH, 'templates')
|
||||
TEMPLATE_BASE_URL = '/custom_public/templates'
|
||||
|
||||
_templates_cache = cachetools.TTLCache(1, 10)
|
||||
|
||||
|
||||
class MainHandler(tornado.web.StaticFileHandler):
|
||||
class StaticHandler(tornado.web.StaticFileHandler):
|
||||
"""为了使用Vue Router的history模式,把不存在的文件请求转发到index.html"""
|
||||
async def get(self, path, include_body=True):
|
||||
if path == '':
|
||||
@@ -52,6 +59,19 @@ class ServerInfoHandler(api.base.ApiHandler):
|
||||
})
|
||||
|
||||
|
||||
class ServiceDiscoveryHandler(api.base.ApiHandler):
|
||||
async def get(self):
|
||||
cfg = config.get_config()
|
||||
self.write({
|
||||
'endpoints': cfg.registered_endpoints,
|
||||
})
|
||||
|
||||
|
||||
class PingHandler(api.base.ApiHandler):
|
||||
async def get(self):
|
||||
self.set_status(204)
|
||||
|
||||
|
||||
class UploadEmoticonHandler(api.base.ApiHandler):
|
||||
async def post(self):
|
||||
cfg = config.get_config()
|
||||
@@ -87,6 +107,75 @@ class UploadEmoticonHandler(api.base.ApiHandler):
|
||||
return f'{EMOTICON_BASE_URL}/{filename}'
|
||||
|
||||
|
||||
class TemplatesHandler(api.base.ApiHandler):
|
||||
async def get(self):
|
||||
templates = _templates_cache.get('templates', None)
|
||||
if templates is None:
|
||||
templates = await asyncio.get_running_loop().run_in_executor(None, self._get_templates)
|
||||
_templates_cache['templates'] = templates
|
||||
|
||||
self.set_header('Cache-Control', 'private, max-age=10')
|
||||
self.write({'templates': templates})
|
||||
|
||||
def _get_templates(self):
|
||||
template_ids = []
|
||||
try:
|
||||
with os.scandir(TEMPLATE_PATH) as it:
|
||||
for entry in it:
|
||||
if entry.is_dir() and os.path.isfile(os.path.join(entry.path, 'template.json')):
|
||||
template_ids.append(entry.name)
|
||||
except OSError:
|
||||
logger.exception('Failed to discover templates:')
|
||||
return []
|
||||
if not template_ids:
|
||||
return []
|
||||
|
||||
templates = []
|
||||
for template_id in template_ids:
|
||||
try:
|
||||
template = self._load_template_config(template_id)
|
||||
templates.append(template)
|
||||
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
||||
logger.exception('template_id=%s failed to load config:', template_id)
|
||||
return templates
|
||||
|
||||
@staticmethod
|
||||
def _load_template_config(template_id):
|
||||
config_path = os.path.join(TEMPLATE_PATH, template_id, 'template.json')
|
||||
with open(config_path, encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
if not isinstance(cfg, dict):
|
||||
raise TypeError(f'Config type error, type={type(cfg)}')
|
||||
|
||||
# 相对于模板目录
|
||||
base_url = yarl.URL(f'{TEMPLATE_BASE_URL}/{template_id}/')
|
||||
|
||||
def ensure_abs_url(url_str_):
|
||||
url_ = yarl.URL(url_str_)
|
||||
if not url_.absolute:
|
||||
url_ = base_url.join(url_)
|
||||
url_str_ = str(url_)
|
||||
return url_str_
|
||||
|
||||
thumbnail_url_str = str(cfg.get('thumbnail', ''))
|
||||
if thumbnail_url_str != '':
|
||||
thumbnail_url_str = ensure_abs_url(thumbnail_url_str)
|
||||
|
||||
url_str = str(cfg.get('url', ''))
|
||||
url_str = ensure_abs_url(url_str)
|
||||
|
||||
template = {
|
||||
'id': template_id,
|
||||
'name': str(cfg.get('name', '')),
|
||||
'version': str(cfg.get('version', '')),
|
||||
'author': str(cfg.get('author', '')),
|
||||
'description': str(cfg.get('description', '')),
|
||||
'thumbnail': thumbnail_url_str,
|
||||
'url': url_str,
|
||||
}
|
||||
return template
|
||||
|
||||
|
||||
class NoCacheStaticFileHandler(tornado.web.StaticFileHandler):
|
||||
def set_extra_headers(self, path):
|
||||
self.set_header('Cache-Control', 'no-cache')
|
||||
@@ -94,12 +183,15 @@ class NoCacheStaticFileHandler(tornado.web.StaticFileHandler):
|
||||
|
||||
ROUTES = [
|
||||
(r'/api/server_info', ServerInfoHandler),
|
||||
(r'/api/endpoints', ServiceDiscoveryHandler),
|
||||
(r'/api/ping', PingHandler),
|
||||
(r'/api/emoticon', UploadEmoticonHandler),
|
||||
(r'/api/templates', TemplatesHandler),
|
||||
]
|
||||
# 通配的放在最后
|
||||
LAST_ROUTES = [
|
||||
(rf'{EMOTICON_BASE_URL}/(.*)', tornado.web.StaticFileHandler, {'path': EMOTICON_UPLOAD_PATH}),
|
||||
# 这个目录不保证文件内容不会变,还是不用缓存了
|
||||
(r'/custom_public/(.*)', NoCacheStaticFileHandler, {'path': CUSTOM_PUBLIC_PATH}),
|
||||
(r'/(.*)', MainHandler, {'path': config.WEB_ROOT}),
|
||||
(r'/(.*)', StaticHandler, {'path': config.WEB_ROOT}),
|
||||
]
|
||||
|
||||
103
api/open_live.py
@@ -27,12 +27,13 @@ END_GAME_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/end'
|
||||
GAME_HEARTBEAT_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/heartbeat'
|
||||
GAME_BATCH_HEARTBEAT_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/batchHeartbeat'
|
||||
|
||||
COMMON_SERVER_BASE_URL = 'https://chat.bilisc.com'
|
||||
START_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/start_game'
|
||||
END_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/end_game'
|
||||
GAME_HEARTBEAT_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/game_heartbeat'
|
||||
START_GAME_COMMON_SERVER_URL = '/api/internal/open_live/start_game'
|
||||
END_GAME_COMMON_SERVER_URL = '/api/internal/open_live/end_game'
|
||||
GAME_HEARTBEAT_COMMON_SERVER_URL = '/api/internal/open_live/game_heartbeat'
|
||||
|
||||
_error_auth_code_cache = cachetools.LRUCache(256)
|
||||
# 应B站要求,抓一下刷请求的人,不会用于其他用途
|
||||
auth_code_room_id_cache = cachetools.LRUCache(256)
|
||||
# 用于限制请求开放平台的频率
|
||||
_open_live_rate_limiter = utils.rate_limit.TokenBucket(8, 8)
|
||||
|
||||
@@ -52,24 +53,15 @@ class BusinessError(Exception):
|
||||
return self.data['code']
|
||||
|
||||
|
||||
async def request_open_live_or_common_server(open_live_url, common_server_url, body: dict) -> dict:
|
||||
async def request_open_live_or_common_server(open_live_url, common_server_url, body: dict, **kwargs) -> dict:
|
||||
"""如果配置了开放平台,则直接请求,否则转发请求到公共服务器的内部接口"""
|
||||
cfg = config.get_config()
|
||||
if cfg.is_open_live_configured:
|
||||
return await request_open_live(open_live_url, body)
|
||||
|
||||
try:
|
||||
req_ctx_mgr = utils.request.http_session.post(common_server_url, json=body)
|
||||
return await _read_response(req_ctx_mgr)
|
||||
except TransportError:
|
||||
logger.exception('Request common server failed:')
|
||||
raise
|
||||
except BusinessError as e:
|
||||
logger.warning('Request common server failed: %s', e)
|
||||
raise
|
||||
return await request_open_live(open_live_url, body, **kwargs)
|
||||
return await request_common_server(common_server_url, body, **kwargs)
|
||||
|
||||
|
||||
async def request_open_live(url, body: dict, *, ignore_rate_limit=False) -> dict:
|
||||
async def request_open_live(url, body: dict, *, ignore_rate_limit=False, **kwargs) -> dict:
|
||||
cfg = config.get_config()
|
||||
assert cfg.is_open_live_configured
|
||||
|
||||
@@ -82,7 +74,7 @@ async def request_open_live(url, body: dict, *, ignore_rate_limit=False) -> dict
|
||||
|
||||
# 频率限制,防止触发B站风控被下架
|
||||
if not _open_live_rate_limiter.try_decrease_token() and not ignore_rate_limit:
|
||||
raise BusinessError({'code': 4009, 'message': '接口访问限制', 'request_id': '0', 'data': None})
|
||||
raise BusinessError({'code': 4009, 'message': 'BLC接口访问限制', 'request_id': '0', 'data': None})
|
||||
|
||||
body_bytes = json.dumps(body).encode('utf-8')
|
||||
headers = {
|
||||
@@ -107,25 +99,56 @@ async def request_open_live(url, body: dict, *, ignore_rate_limit=False) -> dict
|
||||
headers['Accept'] = 'application/json'
|
||||
|
||||
try:
|
||||
req_ctx_mgr = utils.request.http_session.post(url, headers=headers, data=body_bytes)
|
||||
req_ctx_mgr = utils.request.http_session.post(url, headers=headers, data=body_bytes, **kwargs)
|
||||
return await _read_response(req_ctx_mgr)
|
||||
except TransportError:
|
||||
logger.exception('Request open live failed:')
|
||||
raise
|
||||
except BusinessError as e:
|
||||
logger.warning('Request open live failed: %s', e)
|
||||
msg = str(e)
|
||||
if e.code == 7010:
|
||||
# 新版本日志可以截断,避免日志太长了
|
||||
msg = msg[:30] + '...'
|
||||
logger.warning('Request open live failed: %s', msg)
|
||||
|
||||
if e.code == 7007:
|
||||
_error_auth_code_cache[auth_code] = True
|
||||
raise
|
||||
|
||||
|
||||
async def _read_response(req_ctx_mgr: AsyncContextManager[aiohttp.ClientResponse]) -> dict:
|
||||
async def request_common_server(rel_url, body: dict, **kwargs) -> dict:
|
||||
base_url, breaker = utils.request.get_common_server_base_url_and_circuit_breaker()
|
||||
if base_url is None:
|
||||
logger.error('No available common server endpoint')
|
||||
raise TransportError('No available common server endpoint')
|
||||
url = base_url + rel_url
|
||||
|
||||
with breaker:
|
||||
try:
|
||||
req_ctx_mgr = utils.request.http_session.post(url, json=body, **kwargs)
|
||||
return await _read_response(req_ctx_mgr, is_common_server=True)
|
||||
except TransportError:
|
||||
logger.exception('Request common server failed:')
|
||||
raise
|
||||
except BusinessError as e:
|
||||
logger.warning('Request common server failed: %s', e)
|
||||
raise
|
||||
|
||||
|
||||
async def _read_response(req_ctx_mgr: AsyncContextManager[aiohttp.ClientResponse], is_common_server=False) -> dict:
|
||||
try:
|
||||
async with req_ctx_mgr as r:
|
||||
r.raise_for_status()
|
||||
data = await r.json()
|
||||
code = data['code']
|
||||
if code != 0:
|
||||
if code == 7010 and not is_common_server:
|
||||
data['message'] += (
|
||||
' 解决方法:https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5'
|
||||
'%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98#%E6%8A%A5%E9%94%997010-%E8%B6%85%E8%BF%87%E4%B8%8'
|
||||
'A%E9%99%90%E5%90%8C%E4%B8%80%E4%B8%AA%E5%BA%94%E7%94%A8%E5%8D%95%E4%B8%AA%E7%9B%B4%E6%92%AD%'
|
||||
'E9%97%B4%E6%9C%80%E5%A4%9A%E5%90%8C%E6%97%B6%E6%89%93%E5%BC%805%E4%B8%AA'
|
||||
)
|
||||
raise BusinessError(data)
|
||||
return data
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
||||
@@ -140,7 +163,7 @@ def _validate_auth_code(auth_code):
|
||||
):
|
||||
raise BusinessError({
|
||||
'code': 7007,
|
||||
'message': 'oi!oi!oi!你的身份码错误了!别再重试了!!!!!!!!!!',
|
||||
'message': 'CNM!你的身份码错误了!别再重试了!!!!!!!!!!',
|
||||
'request_id': '0',
|
||||
'data': None
|
||||
})
|
||||
@@ -155,6 +178,9 @@ class _OpenLiveHandlerBase(api.base.ApiHandler):
|
||||
|
||||
def prepare(self):
|
||||
super().prepare()
|
||||
if self.request.method == 'OPTIONS':
|
||||
return
|
||||
|
||||
if not isinstance(self.json_args, dict):
|
||||
raise tornado.web.MissingArgumentError('body')
|
||||
|
||||
@@ -210,26 +236,31 @@ class _StartGameMixin(_OpenLiveHandlerBase):
|
||||
if self.res is None:
|
||||
return
|
||||
|
||||
auth_code = self.json_args.get('code', None)
|
||||
try:
|
||||
room_id = self.res['data']['anchor_info']['room_id']
|
||||
except (TypeError, KeyError):
|
||||
room_id = None
|
||||
room_id = auth_code_room_id_cache.get(auth_code, None)
|
||||
else:
|
||||
auth_code_room_id_cache[auth_code] = room_id
|
||||
try:
|
||||
game_id = self.res['data']['game_info']['game_id']
|
||||
except (TypeError, KeyError):
|
||||
game_id = None
|
||||
|
||||
code = self.res['code']
|
||||
msg = self.res['message']
|
||||
if code == 7010:
|
||||
# 新版本日志可以截断,避免日志太长了
|
||||
msg = msg[:10] + '...'
|
||||
logger.info(
|
||||
'client=%s room_id=%s start game res: %s %s, game_id=%s', self.request.remote_ip, room_id,
|
||||
code, self.res['message'], game_id
|
||||
code, msg, game_id
|
||||
)
|
||||
if code == 7007:
|
||||
# 身份码错误
|
||||
# 让我看看是哪个混蛋把房间ID、UID当做身份码
|
||||
logger.info(
|
||||
'client=%s auth code error! auth_code=%s', self.request.remote_ip,
|
||||
self.json_args.get('code', None)
|
||||
)
|
||||
logger.info('client=%s auth code error! auth_code=%s', self.request.remote_ip, auth_code)
|
||||
|
||||
|
||||
class StartGamePublicHandler(_StartGameMixin, _PublicHandlerBase):
|
||||
@@ -244,6 +275,17 @@ class EndGamePublicHandler(_PublicHandlerBase):
|
||||
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
|
||||
_COMMON_SERVER_URL = END_GAME_COMMON_SERVER_URL
|
||||
|
||||
def set_default_headers(self):
|
||||
super().set_default_headers()
|
||||
|
||||
if self._headers.get('Access-Control-Allow-Origin', None) is None:
|
||||
return
|
||||
|
||||
# 前端sendBeacon跨域发送JSON时会带凭证
|
||||
self.set_header('Access-Control-Allow-Credentials', 'true')
|
||||
self.set_header('Access-Control-Allow-Methods', 'POST')
|
||||
self.set_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
|
||||
|
||||
class EndGamePrivateHandler(_PrivateHandlerBase):
|
||||
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
|
||||
@@ -276,9 +318,8 @@ async def send_game_heartbeat_by_service_or_common_server(game_id):
|
||||
cfg = config.get_config()
|
||||
if cfg.is_open_live_configured:
|
||||
return await services.open_live.send_game_heartbeat(game_id)
|
||||
# 这里GAME_HEARTBEAT_OPEN_LIVE_URL没用,因为一定是请求公共服务器
|
||||
return await request_open_live_or_common_server(
|
||||
GAME_HEARTBEAT_OPEN_LIVE_URL, GAME_HEARTBEAT_COMMON_SERVER_URL, {'game_id': game_id}
|
||||
return await request_common_server(
|
||||
GAME_HEARTBEAT_COMMON_SERVER_URL, {'game_id': game_id}, timeout=aiohttp.ClientTimeout(total=15)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ class _AdminHandlerBase(api.base.ApiHandler):
|
||||
if not cfg.enable_admin_plugins:
|
||||
raise tornado.web.HTTPError(403)
|
||||
|
||||
logger.info('client=%s requesting admin plugin, cls=%s', self.request.remote_ip, type(self).__name__)
|
||||
if self.request.method != 'OPTIONS':
|
||||
logger.info('client=%s requesting admin plugin, cls=%s', self.request.remote_ip, type(self).__name__)
|
||||
|
||||
super().prepare()
|
||||
|
||||
|
||||
2
blivedm
86
config.py
@@ -2,6 +2,7 @@
|
||||
import configparser
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import *
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,7 +31,7 @@ def init(cmd_args):
|
||||
_config = config
|
||||
|
||||
|
||||
def reload(cmd_args):
|
||||
def reload(cmd_args=None):
|
||||
config_path = ''
|
||||
for path in CONFIG_PATH_LIST:
|
||||
if os.path.exists(path):
|
||||
@@ -73,12 +74,15 @@ class AppConfig:
|
||||
self.open_live_app_id = 0
|
||||
|
||||
self.enable_translate = True
|
||||
self.allow_translate_rooms = set()
|
||||
self.allow_translate_rooms: Set[int] = set()
|
||||
self.translate_max_queue_size = 10
|
||||
self.translation_cache_size = 50000
|
||||
self.translator_configs = []
|
||||
self.translator_configs: List[dict] = []
|
||||
|
||||
self.text_emoticons = []
|
||||
self.text_emoticons: List[dict] = []
|
||||
|
||||
self.registered_endpoints: List[str] = []
|
||||
self.cors_origins: List[re.Pattern[str]] = []
|
||||
|
||||
@property
|
||||
def is_open_live_configured(self):
|
||||
@@ -86,7 +90,9 @@ class AppConfig:
|
||||
self.open_live_access_key_id != '' and self.open_live_access_key_secret != '' and self.open_live_app_id != 0
|
||||
)
|
||||
|
||||
def load_cmd_args(self, args):
|
||||
def load_cmd_args(self, args=None):
|
||||
if args is None:
|
||||
return
|
||||
if args.host is not None:
|
||||
self.host = args.host
|
||||
if args.port is not None:
|
||||
@@ -101,6 +107,8 @@ class AppConfig:
|
||||
self._load_app_config(config)
|
||||
self._load_translator_configs(config)
|
||||
self._load_text_emoticons(config)
|
||||
self._load_registered_endpoints(config)
|
||||
self._load_cors_origins(config)
|
||||
except Exception: # noqa
|
||||
logger.exception('Failed to load config:')
|
||||
return False
|
||||
@@ -113,6 +121,8 @@ class AppConfig:
|
||||
self.database_url = app_section.get('database_url', self.database_url)
|
||||
self.tornado_xheaders = app_section.getboolean('tornado_xheaders', self.tornado_xheaders)
|
||||
self.loader_url = app_section.get('loader_url', self.loader_url)
|
||||
if self.loader_url == '{local_loader}':
|
||||
self.loader_url = self._get_local_loader_url()
|
||||
self.open_browser_at_startup = app_section.getboolean('open_browser_at_startup', self.open_browser_at_startup)
|
||||
self.enable_upload_file = app_section.getboolean('enable_upload_file', self.enable_upload_file)
|
||||
self.enable_admin_plugins = app_section.getboolean('enable_admin_plugins', self.enable_admin_plugins)
|
||||
@@ -133,8 +143,20 @@ class AppConfig:
|
||||
self.translate_max_queue_size = app_section.getint('translate_max_queue_size', self.translate_max_queue_size)
|
||||
self.translation_cache_size = app_section.getint('translation_cache_size', self.translation_cache_size)
|
||||
|
||||
@staticmethod
|
||||
def _get_local_loader_url():
|
||||
url = os.path.abspath(os.path.join(DATA_PATH, 'loader.html'))
|
||||
url = url.replace('\\', '/')
|
||||
if not url.startswith('/'): # Windows
|
||||
url = '/' + url
|
||||
url = 'file://' + url
|
||||
return url
|
||||
|
||||
def _load_translator_configs(self, config: configparser.ConfigParser):
|
||||
app_section = config['app']
|
||||
try:
|
||||
app_section = config['app']
|
||||
except KeyError:
|
||||
return
|
||||
section_names = _str_to_list(app_section.get('translator_configs', ''))
|
||||
translator_configs = []
|
||||
for section_name in section_names:
|
||||
@@ -146,11 +168,12 @@ class AppConfig:
|
||||
'type': type_,
|
||||
'query_interval': section.getfloat('query_interval'),
|
||||
}
|
||||
if type_ == 'TencentTranslateFree':
|
||||
translator_config['source_language'] = section['source_language']
|
||||
translator_config['target_language'] = section['target_language']
|
||||
elif type_ == 'BilibiliTranslateFree':
|
||||
pass
|
||||
if type_ in ('TencentTranslateFree', 'BilibiliTranslateFree'):
|
||||
doc_url = (
|
||||
'https://github.com/xfgryujk/blivechat/wiki/%E9%85%8D%E7%BD%AE%E5%AE%98%E6%96%B9'
|
||||
'%E7%BF%BB%E8%AF%91%E6%8E%A5%E5%8F%A3'
|
||||
)
|
||||
logger.warning('%s is deprecated, please see %s', type_, doc_url)
|
||||
elif type_ == 'TencentTranslate':
|
||||
translator_config['source_language'] = section['source_language']
|
||||
translator_config['target_language'] = section['target_language']
|
||||
@@ -163,11 +186,16 @@ class AppConfig:
|
||||
translator_config['app_id'] = section['app_id']
|
||||
translator_config['secret'] = section['secret']
|
||||
elif type_ == 'GeminiTranslate':
|
||||
translator_config['proxy'] = section['proxy']
|
||||
logger.warning('%s is deprecated, please migrate to OpenAiApi', type_)
|
||||
elif type_ == 'OpenAiApi':
|
||||
translator_config['api_key'] = section['api_key']
|
||||
translator_config['model_code'] = section['model_code']
|
||||
translator_config['base_url'] = section['base_url']
|
||||
translator_config['proxy'] = section['proxy']
|
||||
translator_config['model'] = section['model']
|
||||
translator_config['prompt'] = section['prompt'].replace('\n', ' ').replace('\\n', '\n')
|
||||
translator_config['max_tokens'] = section.getint('max_tokens')
|
||||
translator_config['temperature'] = section.getfloat('temperature')
|
||||
translator_config['top_p'] = section.getfloat('top_p')
|
||||
else:
|
||||
raise ValueError(f'Invalid translator type: {type_}')
|
||||
except Exception: # noqa
|
||||
@@ -178,13 +206,41 @@ class AppConfig:
|
||||
self.translator_configs = translator_configs
|
||||
|
||||
def _load_text_emoticons(self, config: configparser.ConfigParser):
|
||||
mappings_section = config['text_emoticon_mappings']
|
||||
try:
|
||||
mappings_section = config['text_emoticon_mappings']
|
||||
except KeyError:
|
||||
return
|
||||
text_emoticons = []
|
||||
for value in mappings_section.values():
|
||||
keyword, _, url = value.partition(',')
|
||||
text_emoticons.append({'keyword': keyword, 'url': url})
|
||||
self.text_emoticons = text_emoticons
|
||||
|
||||
def _load_registered_endpoints(self, config: configparser.ConfigParser):
|
||||
try:
|
||||
registered_endpoints_section = config['registered_endpoints']
|
||||
except KeyError:
|
||||
return
|
||||
registered_endpoints = list(registered_endpoints_section.values())
|
||||
self.registered_endpoints = registered_endpoints
|
||||
|
||||
def _load_cors_origins(self, config: configparser.ConfigParser):
|
||||
try:
|
||||
cors_origins_section = config['cors_origins']
|
||||
except KeyError:
|
||||
return
|
||||
cors_origins = [
|
||||
re.compile(origin, re.IGNORECASE)
|
||||
for origin in cors_origins_section.values()
|
||||
]
|
||||
self.cors_origins = cors_origins
|
||||
|
||||
def is_allowed_cors_origin(self, origin):
|
||||
return any(
|
||||
pattern.fullmatch(origin) is not None
|
||||
for pattern in self.cors_origins
|
||||
)
|
||||
|
||||
|
||||
def _str_to_list(value, item_type: Type = str, container_type: Type = list):
|
||||
value = value.strip()
|
||||
@@ -193,5 +249,5 @@ def _str_to_list(value, item_type: Type = str, container_type: Type = list):
|
||||
items = value.split(',')
|
||||
items = map(lambda item: item.strip(), items)
|
||||
if item_type is not str:
|
||||
items = map(lambda item: item_type(item), items)
|
||||
items = map(item_type, items)
|
||||
return container_type(items)
|
||||
|
||||
@@ -17,8 +17,9 @@ tornado_xheaders = false
|
||||
|
||||
# 加载器URL,本地使用时加载器可以让你先运行OBS再运行blivechat。如果为空,不使用加载器
|
||||
# **自建服务器时强烈建议不使用加载器**,否则可能因为混合HTTP和HTTPS等原因加载不出来
|
||||
# “{local_loader}”表示使用本地加载器文件URL
|
||||
# Use a loader so that you can run OBS before blivechat. If empty, no loader is used
|
||||
loader_url = https://xfgryujk.sinacloud.net/blivechat/loader.html
|
||||
loader_url = {local_loader}
|
||||
|
||||
# 启动时打开浏览器
|
||||
# Open browser at startup
|
||||
@@ -76,22 +77,8 @@ open_live_app_id = 0
|
||||
# 翻译器配置,索引到下面的配置节。可以以逗号分隔配置多个翻译器,翻译时会自动负载均衡
|
||||
# 配置多个翻译器可以增加额度、增加QPS、容灾
|
||||
# 不同配置可以使用同一个类型,但要使用不同的账号,否则还是会遇到额度、调用频率限制
|
||||
translator_configs = tencent_translate_free
|
||||
|
||||
|
||||
[tencent_translate_free]
|
||||
# 类型:腾讯翻译白嫖版。使用了网页版的接口,**将来可能失效**
|
||||
type = TencentTranslateFree
|
||||
|
||||
# 请求间隔时间(秒),等于 1 / QPS
|
||||
query_interval = 1
|
||||
|
||||
# 自动:auto;中文:zh;日语:jp;英语:en;韩语:kr
|
||||
# 完整语言列表见文档:https://cloud.tencent.com/document/product/551/15619
|
||||
# 源语言
|
||||
source_language = zh
|
||||
# 目标语言
|
||||
target_language = jp
|
||||
# Example: translator_configs = tencent_translate,baidu_translate
|
||||
translator_configs =
|
||||
|
||||
|
||||
[tencent_translate]
|
||||
@@ -151,48 +138,36 @@ app_id =
|
||||
secret =
|
||||
|
||||
|
||||
[gemini_translate]
|
||||
# 文档:https://ai.google.dev/docs
|
||||
# 定价:https://ai.google.dev/pricing
|
||||
# * 目前只有免费版,最高QPS为1
|
||||
# * 收费版 输入 $0.000125 / 1000 个字符;输出 $0.000375 / 1000 个字符
|
||||
# 模型规格:https://ai.google.dev/models/gemini
|
||||
# * Gemini Pro 最高QPS为1
|
||||
[openai_api]
|
||||
# 大语言模型对话,支持任何兼容OpenAI API的平台,参考各平台的文档吧
|
||||
|
||||
# 类型:Gemini翻译
|
||||
type = GeminiTranslate
|
||||
# 类型:OpenAI API
|
||||
type = OpenAiApi
|
||||
|
||||
# 请求间隔时间(秒),等于 1 / QPS
|
||||
query_interval = 1.1
|
||||
|
||||
# 代理地址,如果为空,不使用代理。注意必须要在指定地区才能使用:https://ai.google.dev/available_regions
|
||||
proxy = http://127.0.0.1:7890
|
||||
|
||||
# API密钥
|
||||
api_key =
|
||||
# API端点,“/chat/completions”之前的部分
|
||||
base_url = https://api.deepseek.com/v1
|
||||
# 代理地址,如果为空,不使用代理
|
||||
# Example: proxy = http://127.0.0.1:7890
|
||||
proxy =
|
||||
|
||||
# 模型代码
|
||||
model_code = models/gemini-1.0-pro
|
||||
# 提示词
|
||||
prompt = As an advanced translation software, your role is to provide accurate translations that respect
|
||||
the original content's meaning and tone.
|
||||
Ensure that texts between square brackets "[]" are kept untranslated.
|
||||
Aim to deliver the translated text in a polite and gentle tone, as per the preferences of the audience.\n\n
|
||||
Below are some examples to guide you:\n\n
|
||||
Input1:\n来拉(莱拉)设置一下吧\nOutput1:\nライラちゃん、設定してみましょうか\n\n
|
||||
Input2:\n我来拉一下各位进队伍\nOutput2:\n皆さんをチームに入れさせていただきます\n\n
|
||||
Input3:\n死了w\nOutput3:\n死んじゃったわw\n\n
|
||||
Input4:\n每天来宝这里打个卡[比心]\nOutput4:\n毎日、ここに来て宝さんにチェックインしま[比心]\n\n
|
||||
\n
|
||||
Please keep in mind that accurate translation is not just about linguistic conversion;
|
||||
it is about conveying the same sentiment and meaning as the original input.
|
||||
Pay special attention to internet vernaculars and colloquial expressions to maintain the nuances of the
|
||||
source language.\n\n
|
||||
Now, proceed with the translation of the following Chinese comment text into Japanese, ensuring accuracy
|
||||
and preservation of the original message:\n
|
||||
Input:\n{original_text}\nOutput ONLY:\n
|
||||
# 温度,用于控制令牌选择的随机性,较低的温度适合需要更具确定性或不够开放的回答的提示,而较高的温度可以产生更加多样化或更具创意的结果
|
||||
temperature = 0.4
|
||||
# 模型
|
||||
model = deepseek-chat
|
||||
# 提示词,换行符会替换为空格,“\n”会替换为换行符
|
||||
prompt = 你是一个日本主播的翻译助手,精通中日文翻译。
|
||||
用户会输入直播观众的聊天内容,请翻译成日文,并确保符合日文语言习惯。
|
||||
你可以调整语气和风格,并考虑到某些词语的文化内涵和地区差异。
|
||||
只回答翻译结果,不需要解释。
|
||||
# 最大输出token数
|
||||
max_tokens = 200
|
||||
# 采样温度,用于控制令牌选择的随机性。较低的温度适合需要更具确定性或不够开放的回答的提示,而较高的温度可以产生更加多样化或更具创意的结果
|
||||
temperature = 1.3
|
||||
# 核采样概率阈值,温度采样的替代方案。模型会考虑概率质量在top_p内的token结果
|
||||
top_p = 1.0
|
||||
|
||||
|
||||
# 傻逼B站,获取表情都要登录,开放平台也不发文本表情的URL,我服了
|
||||
@@ -279,3 +254,13 @@ temperature = 0.4
|
||||
80 = [抱拳],http://i0.hdslb.com/bfs/live/3f170894dd08827ee293afcb5a3d2b60aecdb5b1.png
|
||||
81 = [给力],http://i0.hdslb.com/bfs/live/d1ba5f4c54332a21ed2ca0dcecaedd2add587839.png
|
||||
82 = [耶],http://i0.hdslb.com/bfs/live/eb2d84ba623e2335a48f73fb5bef87bcf53c1239.png
|
||||
|
||||
|
||||
# 用于服务发现返回的后端端点
|
||||
[registered_endpoints]
|
||||
1 = http://localhost:12450
|
||||
|
||||
|
||||
# 允许跨域的源,正则表达式
|
||||
[cors_origins]
|
||||
1 = http://localhost(:\d+)
|
||||
|
||||
0
data/custom_public/templates/.gitkeep
Normal file
39
data/loader.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>blivechat</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<p>Loading... Please run blivechat</p>
|
||||
|
||||
<script>
|
||||
function main() {
|
||||
let params = new URLSearchParams(window.location.search)
|
||||
let url = params.get('url')
|
||||
if (!url) {
|
||||
let element = document.createElement('p')
|
||||
element.innerText = 'No url specified'
|
||||
document.body.appendChild(element)
|
||||
return
|
||||
}
|
||||
|
||||
let timerId = null
|
||||
function poll() {
|
||||
window.fetch(url, {mode: 'no-cors'}).then(
|
||||
() => {
|
||||
window.clearInterval(timerId)
|
||||
window.location.href = url
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
timerId = window.setInterval(poll, 1000)
|
||||
poll()
|
||||
}
|
||||
|
||||
main()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
6
frontend/.env
Normal file
@@ -0,0 +1,6 @@
|
||||
# 第三方库用CDN引入
|
||||
LIB_USE_CDN=false
|
||||
# production环境生成source map
|
||||
PROD_SOURCE_MAP=true
|
||||
# 动态发现后端endpoint
|
||||
BACKEND_DISCOVERY=false
|
||||
4
frontend/.env.common_server
Normal file
@@ -0,0 +1,4 @@
|
||||
NODE_ENV=production
|
||||
LIB_USE_CDN=true
|
||||
PROD_SOURCE_MAP=false
|
||||
BACKEND_DISCOVERY=true
|
||||
@@ -1,2 +1,3 @@
|
||||
brotli_decode.js
|
||||
pronunciation/dict*.js
|
||||
blcsdk.js
|
||||
|
||||
1
frontend/.gitignore
vendored
@@ -5,6 +5,7 @@ node_modules
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "blivechat",
|
||||
"version": "1.9.1",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"build_common_server": "vue-cli-service build --mode common_server",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -14,6 +15,7 @@
|
||||
"downloadjs": "^1.4.7",
|
||||
"element-ui": "^2.15.13",
|
||||
"lodash": "^4.17.21",
|
||||
"opossum": "^8.3.0",
|
||||
"pako": "^2.1.0",
|
||||
"vue": "^2.7.14",
|
||||
"vue-i18n": "^8.28.2",
|
||||
|
||||
@@ -11,23 +11,29 @@
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title>blivechat</title>
|
||||
|
||||
<% if (process.env.NODE_ENV === 'production') { %>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.14/vue.runtime.min.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue-router/3.6.5/vue-router.min.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.min.js"></script>
|
||||
<link href="https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.13/index.min.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/pako/2.1.0/pako_inflate.min.js"></script>
|
||||
<%
|
||||
if (process.env.LIB_USE_CDN) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
%>
|
||||
<script src="https://s4.zstatic.net/ajax/libs/lodash.js/4.17.21/lodash.min.js" integrity="sha384-H6KKS1H1WwuERMSm+54dYLzjg0fKqRK5ZRyASdbrI/lwrCc6bXEmtGYr5SwvP1pZ" crossorigin="anonymous"></script>
|
||||
<script src="https://s4.zstatic.net/ajax/libs/vue/2.7.14/vue.runtime.min.js" integrity="sha384-OuPzRFb9+YLr+ulVAQiUKWAYfQdbHQI3IvQ86+ApeLgPgTI2EJ/ySfV9UjvFFhxN" crossorigin="anonymous"></script>
|
||||
<script src="https://s4.zstatic.net/ajax/libs/vue-router/3.6.5/vue-router.min.js" integrity="sha384-/NExOjjk+Jjk5ZvMGKDUq/QxHVEJxhJXBBEb9AVSjc1zeQUYhG9cLvz1pY1/QrQF" crossorigin="anonymous"></script>
|
||||
<script src="https://s4.zstatic.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.min.js" integrity="sha384-hcSOWoIpDyRLasgG/oDA+pDX1b7k6FX+8BkVxPvKXZ/w1ntszakmyGEJXgMeuWCU" crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.min.css" integrity="sha384-I0kZgnELdQIeth/GMZEO2WQ1Po7jSQEkkqnh7bT+ofoJ9FVqFDhxeu7I10K5DyuV" crossorigin="anonymous">
|
||||
<script src="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/index.min.js" integrity="sha384-Wzk9c33au7LnMJ233iNheTpEpVPeseLtoqnJElShswILwuk4JaiHjpj6fKD2yhoV" crossorigin="anonymous"></script>
|
||||
<script src="https://s4.zstatic.net/ajax/libs/pako/2.1.0/pako_inflate.min.js" integrity="sha384-taEjHL+GUvC8IhXeCaTNUxz3O8ItsajGFLDFj4v/VJvM24HU36qP/6sRpuAGGfeT" crossorigin="anonymous"></script>
|
||||
<% } else { %>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.14/vue.runtime.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue-router/3.6.5/vue-router.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.js"></script>
|
||||
<link href="https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.css" rel="stylesheet">
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.13/index.js"></script>
|
||||
<script src="https://cdn.bootcdn.net/ajax/libs/pako/2.1.0/pako_inflate.js"></script>
|
||||
<% } %>
|
||||
<script src="https://s4.zstatic.net/ajax/libs/lodash.js/4.17.21/lodash.js" integrity="sha384-l3ZPesZ3gDMDOrzjEodAMRyQlQnAR6KFZN2hnIr+h8Y80fuKlD1jsjdxJpKr9XgP" crossorigin="anonymous"></script>
|
||||
<script src="https://s4.zstatic.net/ajax/libs/vue/2.7.14/vue.runtime.js" integrity="sha384-7Eh7MYtBZnl23Wa8pdx0AXtu35xsvTaEW6ZXBaFGIND87X3ZvFyqUffgUXa2D+HM" crossorigin="anonymous"></script>
|
||||
<script src="https://s4.zstatic.net/ajax/libs/vue-router/3.6.5/vue-router.js" integrity="sha384-IkMvPbEBTaZXtDP8XKR0vzQv6h12mn1Tsm0YRzlJ5yZQtQ0b0fI2upc3EB5erSWp" crossorigin="anonymous"></script>
|
||||
<script src="https://s4.zstatic.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.js" integrity="sha384-6JdClPjeS6N/byLmSMRUkfj8qIW6PR8InDu0hwcpUGithNg6/3+h6QpYNqB9LXXD" crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.css" integrity="sha384-i3Zi0wHs7p1SlIuNOWAJupwQHyh8Vg54HE50dXe6SiJwHglCpbLr5Nd9wiswohGS" crossorigin="anonymous">
|
||||
<script src="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/index.js" integrity="sha384-xJNO3hLLbOGPVKrsu1WIty+KkzNuJKdRr4blB8iY29iXAqkeGJWBtHZ0DGCYi/OE" crossorigin="anonymous"></script>
|
||||
<script src="https://s4.zstatic.net/ajax/libs/pako/2.1.0/pako_inflate.js" integrity="sha384-uHURdoyk6Dqn/gSq38WU6iUc3W0A1rnHIKEp5sjIz8w4yz1eKIM8N7WvNwKMzALi" crossorigin="anonymous"></script>
|
||||
<%
|
||||
}
|
||||
}
|
||||
%>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
|
||||
BIN
frontend/public/static/img/tutorial/tutorial-1.jpg
Normal file
|
After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
BIN
frontend/public/static/img/tutorial/tutorial-2.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 98 KiB |
BIN
frontend/public/static/img/tutorial/tutorial-3.jpg
Normal file
|
After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 264 KiB |
BIN
frontend/public/static/img/tutorial/tutorial-4.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 14 KiB |
BIN
frontend/public/static/img/tutorial/tutorial-5.jpg
Normal file
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 86 KiB |
171
frontend/src/api/base.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import axios from 'axios'
|
||||
import _ from 'lodash'
|
||||
import CircuitBreaker from 'opossum'
|
||||
|
||||
axios.defaults.timeout = 10 * 1000
|
||||
|
||||
export const apiClient = axios.create({
|
||||
timeout: 10 * 1000,
|
||||
})
|
||||
|
||||
export let init
|
||||
export let getBaseUrl
|
||||
if (!process.env.BACKEND_DISCOVERY) {
|
||||
init = async function() {}
|
||||
|
||||
const onRequest = config => {
|
||||
config.baseURL = getBaseUrl()
|
||||
return config
|
||||
}
|
||||
|
||||
const onRequestError = e => {
|
||||
throw e
|
||||
}
|
||||
|
||||
apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true })
|
||||
|
||||
getBaseUrl = function() {
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
} else {
|
||||
init = async function() {
|
||||
return updateBaseUrls()
|
||||
}
|
||||
|
||||
const onRequest = config => {
|
||||
let baseUrl = getBaseUrl()
|
||||
if (baseUrl === null) {
|
||||
throw new Error('No available endpoint')
|
||||
}
|
||||
config.baseURL = baseUrl
|
||||
return config
|
||||
}
|
||||
|
||||
const onRequestError = e => {
|
||||
throw e
|
||||
}
|
||||
|
||||
const onResponse = response => {
|
||||
let promise = Promise.resolve(response)
|
||||
let baseUrl = response.config.baseURL
|
||||
let breaker = getOrAddCircuitBreaker(baseUrl)
|
||||
breaker.fire(promise).catch(() => {})
|
||||
return response
|
||||
}
|
||||
|
||||
const onResponseError = e => {
|
||||
let promise = Promise.reject(e)
|
||||
if (!e.response || (500 <= e.response.status && e.response.status < 600)) {
|
||||
let baseUrl = e.config.baseURL
|
||||
let breaker = getOrAddCircuitBreaker(baseUrl)
|
||||
breaker.fire(promise).catch(() => {})
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true })
|
||||
apiClient.interceptors.response.use(onResponse, onResponseError)
|
||||
|
||||
const DISCOVERY_URLS = process.env.NODE_ENV === 'production' ? [
|
||||
// 只有公共服务器会开BACKEND_DISCOVERY,这里可以直接跨域访问
|
||||
'https://api1.blive.chat/api/endpoints',
|
||||
'https://api2.blive.chat/api/endpoints',
|
||||
] : [
|
||||
`${window.location.origin}/api/endpoints`,
|
||||
'http://localhost:12450/api/endpoints',
|
||||
]
|
||||
let baseUrls = process.env.NODE_ENV === 'production' ? [
|
||||
'https://api1.blive.chat',
|
||||
'https://api2.blive.chat',
|
||||
] : [
|
||||
window.location.origin,
|
||||
'http://localhost:12450',
|
||||
]
|
||||
let curBaseUrl = null
|
||||
let baseUrlToCircuitBreaker = new Map()
|
||||
|
||||
const doUpdateBaseUrls = async() => {
|
||||
async function requestGetUrls(discoveryUrl) {
|
||||
try {
|
||||
return (await axios.get(discoveryUrl)).data.endpoints
|
||||
} catch (e) {
|
||||
console.warn('Failed to discover server endpoints from one source:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
let _baseUrls = []
|
||||
try {
|
||||
let promises = DISCOVERY_URLS.map(requestGetUrls)
|
||||
_baseUrls = await Promise.any(promises)
|
||||
} catch {
|
||||
}
|
||||
if (_baseUrls.length === 0) {
|
||||
console.error('Failed to discover server endpoints from any source')
|
||||
return
|
||||
}
|
||||
|
||||
// 按响应时间排序
|
||||
let sortedBaseUrls = []
|
||||
let errorBaseUrls = []
|
||||
|
||||
async function testEndpoint(baseUrl) {
|
||||
try {
|
||||
let url = `${baseUrl}/api/ping`
|
||||
await axios.get(url, { timeout: 3 * 1000 })
|
||||
sortedBaseUrls.push(baseUrl)
|
||||
} catch {
|
||||
errorBaseUrls.push(baseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(_baseUrls.map(testEndpoint))
|
||||
sortedBaseUrls = sortedBaseUrls.concat(errorBaseUrls)
|
||||
|
||||
baseUrls = sortedBaseUrls
|
||||
if (baseUrls.indexOf(curBaseUrl) === -1) {
|
||||
curBaseUrl = null
|
||||
}
|
||||
|
||||
console.log('Found server endpoints:', baseUrls)
|
||||
}
|
||||
const updateBaseUrls = _.throttle(doUpdateBaseUrls, 3 * 60 * 1000)
|
||||
|
||||
getBaseUrl = function() {
|
||||
updateBaseUrls()
|
||||
|
||||
if (curBaseUrl !== null) {
|
||||
let breaker = getOrAddCircuitBreaker(curBaseUrl)
|
||||
if (!breaker.opened) {
|
||||
return curBaseUrl
|
||||
}
|
||||
curBaseUrl = null
|
||||
}
|
||||
|
||||
// 找第一个未熔断的
|
||||
for (let baseUrl of baseUrls) {
|
||||
let breaker = getOrAddCircuitBreaker(baseUrl)
|
||||
if (!breaker.opened) {
|
||||
curBaseUrl = baseUrl
|
||||
console.log('Switch server endpoint to', curBaseUrl)
|
||||
return curBaseUrl
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getOrAddCircuitBreaker = baseUrl => {
|
||||
let breaker = baseUrlToCircuitBreaker.get(baseUrl)
|
||||
if (breaker === undefined) {
|
||||
breaker = new CircuitBreaker(promise => promise, {
|
||||
timeout: false,
|
||||
rollingCountTimeout: 60 * 1000,
|
||||
errorThresholdPercentage: 70,
|
||||
resetTimeout: 60 * 1000,
|
||||
})
|
||||
baseUrlToCircuitBreaker.set(baseUrl, breaker)
|
||||
}
|
||||
return breaker
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { apiClient as axios, getBaseUrl } from '@/api/base'
|
||||
import * as chat from '.'
|
||||
import * as chatModels from './models'
|
||||
import * as base from './ChatClientOfficialBase'
|
||||
@@ -14,6 +13,8 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
|
||||
|
||||
this.roomOwnerAuthCode = roomOwnerAuthCode
|
||||
|
||||
this.boundEndGameBeforeUnload = this.endGameBeforeUnload.bind(this)
|
||||
|
||||
// 调用initRoom后初始化
|
||||
this.roomOwnerOpenId = null
|
||||
this.hostServerUrlList = []
|
||||
@@ -23,7 +24,15 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
|
||||
this.gameHeartbeatTimerId = null
|
||||
}
|
||||
|
||||
start() {
|
||||
super.start()
|
||||
|
||||
window.addEventListener('beforeunload', this.boundEndGameBeforeUnload)
|
||||
}
|
||||
|
||||
stop() {
|
||||
window.removeEventListener('beforeunload', this.boundEndGameBeforeUnload)
|
||||
|
||||
this.endGame()
|
||||
|
||||
super.stop()
|
||||
@@ -100,6 +109,29 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
|
||||
return true
|
||||
}
|
||||
|
||||
endGameBeforeUnload() {
|
||||
let baseUrl = getBaseUrl()
|
||||
if (baseUrl === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.needInitRoom = true
|
||||
if (!this.gameId) {
|
||||
return
|
||||
}
|
||||
let gameId = this.gameId
|
||||
// 直接丢弃将要关闭的gameId
|
||||
this.gameId = null
|
||||
|
||||
let url = `${baseUrl}/api/open_live/end_game`
|
||||
let body = {
|
||||
app_id: 0,
|
||||
game_id: gameId
|
||||
}
|
||||
body = new Blob([JSON.stringify(body)], { type: 'application/json' })
|
||||
window.navigator.sendBeacon(url, body)
|
||||
}
|
||||
|
||||
onSendGameHeartbeat() {
|
||||
// 加上随机延迟,减少同时请求的概率
|
||||
let sleepTime = GAME_HEARTBEAT_INTERVAL - (2 * 1000) + (Math.random() * 3 * 1000)
|
||||
@@ -182,18 +214,38 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
|
||||
super.delayReconnect()
|
||||
}
|
||||
|
||||
async dmCallback(command) {
|
||||
interactionEndCallback(command) {
|
||||
if (command.data.game_id !== this.gameId) {
|
||||
return
|
||||
}
|
||||
// 服务器主动停止推送,可能是心跳超时,需要重新开启项目
|
||||
console.error(`Open Live session end by server, gameId=${this.gameId}`)
|
||||
this.addDebugMsg('Open Live session end by server')
|
||||
|
||||
this.gameId = null
|
||||
this.needInitRoom = true
|
||||
this.discardWebsocket()
|
||||
}
|
||||
|
||||
dmCallback(command) {
|
||||
let data = command.data
|
||||
|
||||
let authorType
|
||||
if (data.open_id === this.roomOwnerOpenId) {
|
||||
authorType = 3
|
||||
} else if (data.is_admin) {
|
||||
authorType = 2
|
||||
} else if (data.guard_level !== 0) {
|
||||
authorType = 1
|
||||
} else {
|
||||
authorType = 0
|
||||
}
|
||||
|
||||
let showContent = data.msg
|
||||
if (data.reply_uname !== '') {
|
||||
showContent = `@${data.reply_uname} ${showContent}`
|
||||
}
|
||||
|
||||
let emoticon = null
|
||||
if (data.dm_type === 1) {
|
||||
emoticon = data.emoji_img_url
|
||||
@@ -204,19 +256,22 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
|
||||
timestamp: data.timestamp,
|
||||
authorName: data.uname,
|
||||
authorType: authorType,
|
||||
content: data.msg,
|
||||
content: showContent,
|
||||
privilegeType: data.guard_level,
|
||||
isGiftDanmaku: chat.isGiftDanmakuByContent(data.msg),
|
||||
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
|
||||
id: data.msg_id,
|
||||
emoticon: emoticon,
|
||||
// 给模板用的字段
|
||||
uid: data.open_id,
|
||||
medalName: data.fans_medal_wearing_status ? data.fans_medal_name : '',
|
||||
})
|
||||
this.msgHandler.onAddText(data)
|
||||
}
|
||||
|
||||
sendGiftCallback(command) {
|
||||
let data = command.data
|
||||
let totalCoin = data.price * data.gift_num
|
||||
let totalCoin = data.r_price * data.gift_num
|
||||
data = new chatModels.AddGiftMsg({
|
||||
id: data.msg_id,
|
||||
avatarUrl: chat.processAvatarUrl(data.uface),
|
||||
@@ -225,19 +280,33 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
|
||||
totalCoin: data.paid ? totalCoin : 0,
|
||||
totalFreeCoin: !data.paid ? totalCoin : 0,
|
||||
giftName: data.gift_name,
|
||||
num: data.gift_num
|
||||
num: data.gift_num,
|
||||
// 给模板用的字段
|
||||
giftId: data.gift_id,
|
||||
giftIconUrl: data.gift_icon,
|
||||
uid: data.open_id,
|
||||
privilegeType: data.guard_level,
|
||||
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
|
||||
medalName: data.fans_medal_wearing_status ? data.fans_medal_name : '',
|
||||
})
|
||||
this.msgHandler.onAddGift(data)
|
||||
}
|
||||
|
||||
async guardCallback(command) {
|
||||
guardCallback(command) {
|
||||
let data = command.data
|
||||
data = new chatModels.AddMemberMsg({
|
||||
id: data.msg_id,
|
||||
avatarUrl: chat.processAvatarUrl(data.user_info.uface),
|
||||
timestamp: data.timestamp,
|
||||
authorName: data.user_info.uname,
|
||||
privilegeType: data.guard_level
|
||||
privilegeType: data.guard_level,
|
||||
// 给模板用的字段
|
||||
num: data.guard_num,
|
||||
unit: data.guard_unit,
|
||||
total_coin: data.price * data.guard_num,
|
||||
uid: data.user_info.open_id,
|
||||
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
|
||||
medalName: data.fans_medal_wearing_status ? data.fans_medal_name : '',
|
||||
})
|
||||
this.msgHandler.onAddMember(data)
|
||||
}
|
||||
@@ -251,6 +320,11 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
|
||||
authorName: data.uname,
|
||||
price: data.rmb,
|
||||
content: data.message,
|
||||
// 给模板用的字段
|
||||
uid: data.open_id,
|
||||
privilegeType: data.guard_level,
|
||||
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
|
||||
medalName: data.fans_medal_wearing_status ? data.fans_medal_name : '',
|
||||
})
|
||||
this.msgHandler.onAddSuperChat(data)
|
||||
}
|
||||
@@ -266,6 +340,7 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
|
||||
}
|
||||
|
||||
const CMD_CALLBACK_MAP = {
|
||||
LIVE_OPEN_PLATFORM_INTERACTION_END: ChatClientDirectOpenLive.prototype.interactionEndCallback,
|
||||
LIVE_OPEN_PLATFORM_DM: ChatClientDirectOpenLive.prototype.dmCallback,
|
||||
LIVE_OPEN_PLATFORM_SEND_GIFT: ChatClientDirectOpenLive.prototype.sendGiftCallback,
|
||||
LIVE_OPEN_PLATFORM_GUARD: ChatClientDirectOpenLive.prototype.guardCallback,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { apiClient as axios } from '@/api/base'
|
||||
import * as chat from '.'
|
||||
import * as chatModels from './models'
|
||||
import * as base from './ChatClientOfficialBase'
|
||||
@@ -71,12 +70,20 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
|
||||
async danmuMsgCallback(command) {
|
||||
let info = command.info
|
||||
|
||||
let roomId, medalLevel
|
||||
let modeInfo = info[0][15]
|
||||
let avatarUrl = modeInfo?.user?.base?.face ?? ''
|
||||
if (!avatarUrl) {
|
||||
avatarUrl = await chat.getAvatarUrl(uid, authorName)
|
||||
}
|
||||
|
||||
let medalRoomId, medalLevel, medalName
|
||||
if (info[3]) {
|
||||
roomId = info[3][3]
|
||||
medalRoomId = info[3][3]
|
||||
medalLevel = info[3][0]
|
||||
medalName = info[3][1]
|
||||
} else {
|
||||
roomId = medalLevel = 0
|
||||
medalRoomId = medalLevel = 0
|
||||
medalName = ''
|
||||
}
|
||||
|
||||
let uid = info[2][0]
|
||||
@@ -93,21 +100,40 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
|
||||
authorType = 0
|
||||
}
|
||||
|
||||
let authorName = info[2][1]
|
||||
let extra
|
||||
try {
|
||||
extra = modeInfo.extra
|
||||
if (typeof extra !== 'object') {
|
||||
extra = JSON.parse(extra)
|
||||
}
|
||||
} catch {
|
||||
extra = {}
|
||||
}
|
||||
|
||||
let content = info[1]
|
||||
let replyUname = extra.reply_uname ?? ''
|
||||
let showContent = content
|
||||
if (replyUname !== '') {
|
||||
showContent = `@${replyUname} ${showContent}`
|
||||
}
|
||||
|
||||
let authorName = info[2][1]
|
||||
let data = new chatModels.AddTextMsg({
|
||||
avatarUrl: await chat.getAvatarUrl(uid, authorName),
|
||||
avatarUrl: avatarUrl,
|
||||
timestamp: info[0][4] / 1000,
|
||||
authorName: authorName,
|
||||
authorType: authorType,
|
||||
content: content,
|
||||
content: showContent,
|
||||
privilegeType: privilegeType,
|
||||
isGiftDanmaku: Boolean(info[0][9]) || chat.isGiftDanmakuByContent(content),
|
||||
authorLevel: info[4][0],
|
||||
isNewbie: info[2][5] < 10000,
|
||||
isMobileVerified: Boolean(info[2][6]),
|
||||
medalLevel: roomId === this.roomId ? medalLevel : 0,
|
||||
medalLevel: medalRoomId === this.roomId ? medalLevel : 0,
|
||||
emoticon: info[0][13].url || null,
|
||||
// 给模板用的字段
|
||||
uid: info[2][0] ? info[2][0].toString() : authorName,
|
||||
medalName: medalRoomId === this.roomId ? medalName : '',
|
||||
})
|
||||
this.msgHandler.onAddText(data)
|
||||
}
|
||||
@@ -115,6 +141,17 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
|
||||
sendGiftCallback(command) {
|
||||
let data = command.data
|
||||
let isPaidGift = data.coin_type === 'gold'
|
||||
|
||||
let medalRuid, medalLevel, medalName
|
||||
if (data.medal_info) {
|
||||
medalRuid = data.medal_info.target_id
|
||||
medalLevel = data.medal_info.medal_level
|
||||
medalName = data.medal_info.medal_name
|
||||
} else {
|
||||
medalRuid = medalLevel = 0
|
||||
medalName = ''
|
||||
}
|
||||
|
||||
data = new chatModels.AddGiftMsg({
|
||||
avatarUrl: chat.processAvatarUrl(data.face),
|
||||
timestamp: data.timestamp,
|
||||
@@ -122,24 +159,66 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
|
||||
totalCoin: isPaidGift ? data.total_coin : 0,
|
||||
totalFreeCoin: !isPaidGift ? data.total_coin : 0,
|
||||
giftName: data.giftName,
|
||||
num: data.num
|
||||
num: data.num,
|
||||
// 给模板用的字段
|
||||
giftId: data.giftId,
|
||||
giftIconUrl: data.gift_info.img_basic,
|
||||
uid: data.uid ? data.uid.toString() : data.uname,
|
||||
privilegeType: data.guard_level,
|
||||
medalLevel: medalRuid === this.roomOwnerUid ? medalLevel : 0,
|
||||
medalName: medalRuid === this.roomOwnerUid ? medalName : '',
|
||||
})
|
||||
this.msgHandler.onAddGift(data)
|
||||
}
|
||||
|
||||
async guardBuyCallback(command) {
|
||||
async userToastV2Callback(command) {
|
||||
let data = command.data
|
||||
let {
|
||||
sender_uinfo: {
|
||||
uid,
|
||||
base: {
|
||||
name: username,
|
||||
},
|
||||
},
|
||||
guard_info: {
|
||||
start_time: timestamp,
|
||||
guard_level: guardLevel,
|
||||
},
|
||||
pay_info: {
|
||||
num,
|
||||
unit,
|
||||
price,
|
||||
},
|
||||
} = data
|
||||
data = new chatModels.AddMemberMsg({
|
||||
avatarUrl: await chat.getAvatarUrl(data.uid, data.username),
|
||||
timestamp: data.start_time,
|
||||
authorName: data.username,
|
||||
privilegeType: data.guard_level
|
||||
avatarUrl: await chat.getAvatarUrl(uid, username),
|
||||
timestamp: timestamp,
|
||||
authorName: username,
|
||||
privilegeType: guardLevel,
|
||||
// 给模板用的字段
|
||||
num: num,
|
||||
unit: unit,
|
||||
total_coin: price * num,
|
||||
uid: uid ? uid.toString() : username,
|
||||
medalLevel: 0,
|
||||
medalName: '',
|
||||
})
|
||||
this.msgHandler.onAddMember(data)
|
||||
}
|
||||
|
||||
superChatMessageCallback(command) {
|
||||
let data = command.data
|
||||
|
||||
let medalRoomId, medalLevel, medalName
|
||||
if (data.medal_info) {
|
||||
medalRoomId = data.medal_info.anchor_roomid
|
||||
medalLevel = data.medal_info.medal_level
|
||||
medalName = data.medal_info.medal_name
|
||||
} else {
|
||||
medalRoomId = medalLevel = 0
|
||||
medalName = ''
|
||||
}
|
||||
|
||||
data = new chatModels.AddSuperChatMsg({
|
||||
id: data.id.toString(),
|
||||
avatarUrl: chat.processAvatarUrl(data.user_info.face),
|
||||
@@ -147,6 +226,11 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
|
||||
authorName: data.user_info.uname,
|
||||
price: data.price,
|
||||
content: data.message,
|
||||
// 给模板用的字段
|
||||
uid: data.uid ? data.uid.toString() : data.user_info.uname,
|
||||
privilegeType: data.user_info.guard_level,
|
||||
medalLevel: medalRoomId === this.roomId ? medalLevel : 0,
|
||||
medalName: medalRoomId === this.roomId ? medalName : '',
|
||||
})
|
||||
this.msgHandler.onAddSuperChat(data)
|
||||
}
|
||||
@@ -164,7 +248,7 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
|
||||
const CMD_CALLBACK_MAP = {
|
||||
DANMU_MSG: ChatClientDirectWeb.prototype.danmuMsgCallback,
|
||||
SEND_GIFT: ChatClientDirectWeb.prototype.sendGiftCallback,
|
||||
GUARD_BUY: ChatClientDirectWeb.prototype.guardBuyCallback,
|
||||
USER_TOAST_MSG_V2: ChatClientDirectWeb.prototype.userToastV2Callback,
|
||||
SUPER_CHAT_MESSAGE: ChatClientDirectWeb.prototype.superChatMessageCallback,
|
||||
SUPER_CHAT_MESSAGE_DELETE: ChatClientDirectWeb.prototype.superChatMessageDeleteCallback
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getBaseUrl } from '@/api/base'
|
||||
import * as chat from '.'
|
||||
import * as chatModels from './models'
|
||||
|
||||
@@ -52,8 +53,15 @@ export default class ChatClientRelay {
|
||||
|
||||
this.addDebugMsg('Connecting')
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const url = `${protocol}://${window.location.host}/api/chat`
|
||||
let baseUrl = getBaseUrl()
|
||||
if (baseUrl === null) {
|
||||
this.addDebugMsg('No available endpoint')
|
||||
window.setTimeout(() => this.onWsClose(), 0)
|
||||
return
|
||||
}
|
||||
let url = baseUrl.replace(/^http(s?):/, 'ws$1:')
|
||||
url += '/api/chat'
|
||||
|
||||
this.websocket = new WebSocket(url)
|
||||
this.websocket.onopen = this.onWsOpen.bind(this)
|
||||
this.websocket.onclose = this.onWsClose.bind(this)
|
||||
@@ -172,7 +180,9 @@ export default class ChatClientRelay {
|
||||
medalLevel: data[10],
|
||||
id: data[11],
|
||||
translation: data[12],
|
||||
emoticon: emoticon
|
||||
emoticon: emoticon,
|
||||
uid: data[16],
|
||||
medalName: data[17],
|
||||
})
|
||||
this.msgHandler.onAddText(data)
|
||||
break
|
||||
|
||||
@@ -3,59 +3,61 @@ import * as chat from '.'
|
||||
import * as chatModels from './models'
|
||||
|
||||
const NAMES = [
|
||||
'光羊',
|
||||
'黑炎',
|
||||
'孤梦星影',
|
||||
'成龙',
|
||||
'杨戬',
|
||||
'孙悟空',
|
||||
'哈基米',
|
||||
'大张伟',
|
||||
'周冠宇',
|
||||
'五条悟',
|
||||
'博丽灵梦',
|
||||
'雾雨魔理沙',
|
||||
'御剑侍伶',
|
||||
'田所浩二',
|
||||
'小岛秀夫',
|
||||
'長崎そよ',
|
||||
'柚木つばめ',
|
||||
'空條承太郎',
|
||||
'みもりあいの',
|
||||
'ディオ・ブランドー',
|
||||
'ジョセフ・ジョースター',
|
||||
'ジョナサン・ジョースター',
|
||||
'Simon',
|
||||
'Misty',
|
||||
'Kinori',
|
||||
'shugen',
|
||||
'3Shain',
|
||||
'yuyuyzl',
|
||||
'Dante',
|
||||
'xQcOW',
|
||||
'Makarov',
|
||||
'xfgryujk',
|
||||
'Il Harper',
|
||||
'Jim Hacker',
|
||||
'Rick Astley',
|
||||
'Tifa Lockhart',
|
||||
'Arthur Morgan',
|
||||
]
|
||||
|
||||
const CONTENTS = [
|
||||
'草',
|
||||
'会赢的',
|
||||
'让我看看',
|
||||
'不要停下来啊',
|
||||
'卑鄙的外乡人',
|
||||
'我不做人了,JOJO',
|
||||
'想吃广东菜✋😭✋',
|
||||
'已经没有什么好怕的了',
|
||||
'我柜子动了,我不玩了',
|
||||
'老板大气,老板身体健康',
|
||||
'我醉提酒游寒山,爽滑慢舔',
|
||||
'你这猴子,真令我欢喜',
|
||||
'[dog]文本[比心]表情[喝彩]',
|
||||
'阿祖,投降吧,外面全是警察',
|
||||
'無駄無駄無駄無駄無駄無駄無駄無駄',
|
||||
'欧啦欧啦欧啦欧啦欧啦欧啦欧啦欧啦',
|
||||
'所有没好全部康复呀,我的癌也全部康复呀',
|
||||
'嚯,朝我走过来了吗,没有选择逃跑而是主动接近我么',
|
||||
'有一说一,这件事大家懂的都懂,不懂的,说了你也不明白,不如不说',
|
||||
'如来来了吗?如来嘛~他真来了吗?如~来~到底来没来?如来~如来他真来了吗?如来~你看看,来没来?如~来~',
|
||||
'我衰咗三年,我等緊個機會,爭番口氣',
|
||||
'因为你的缘故,我的心中萌生了多余的情感',
|
||||
'迷えば、敗れる',
|
||||
'逃げるんだよォ!',
|
||||
'竜神の剣を喰らえ!',
|
||||
'竜が我が敌を喰らう!',
|
||||
'言いたいことがあるんだよ!',
|
||||
'知らず知らず隠してた 本当の声を響かせてよほら',
|
||||
'届かない恋をしていても',
|
||||
'なんで春日影やったの!?',
|
||||
'kksk',
|
||||
'Y.M.C.A.',
|
||||
'8888888888',
|
||||
'text[吃瓜]emoticon',
|
||||
'Remember... no Russian',
|
||||
'Never gonna give you up',
|
||||
'Never gonna let you down',
|
||||
'888888888888888888888888888888',
|
||||
'I am the storm that is approaching',
|
||||
"I can eat glass, it doesn't hurt me",
|
||||
'The quick brown fox jumps over the lazy dog',
|
||||
'DU↗DU→DU↗DU↓ Max Verstappen',
|
||||
'Farewell, ashen one. May the flame guide thee',
|
||||
'I am the bone of my sword. Steel is my body, and fire is my blood.',
|
||||
'Hey Vergil, your portal opening days are over. Give me the Yamato',
|
||||
'<script>alert("CHECK YOUR CODE")</script>',
|
||||
'<img src=1 onerror="alert(\'CHECK YOUR CODE\')">',
|
||||
]
|
||||
|
||||
const EMOTICONS = [
|
||||
@@ -64,7 +66,13 @@ const EMOTICONS = [
|
||||
'lipu',
|
||||
'huangdou_xihuan',
|
||||
'sakaban_jiayu_yutou',
|
||||
].map(name => `/static/img/emoticons/${name}.png`)
|
||||
].map(name => `${window.location.origin}/static/img/emoticons/${name}.png`)
|
||||
|
||||
const TRANSLATIONS = [
|
||||
'这是翻译',
|
||||
'これは翻訳です',
|
||||
'blah blah blah',
|
||||
]
|
||||
|
||||
const AUTHOR_TYPES = [
|
||||
{ weight: 10, value: constants.AUTHOR_TYPE_NORMAL },
|
||||
@@ -73,6 +81,10 @@ const AUTHOR_TYPES = [
|
||||
{ weight: 1, value: constants.AUTHOR_TYPE_OWNER }
|
||||
]
|
||||
|
||||
const GUARD_LEVEL_TO_PRICE = [
|
||||
0, 19998, 1998, 198
|
||||
]
|
||||
|
||||
function randGuardInfo() {
|
||||
let authorType = randomChoose(AUTHOR_TYPES)
|
||||
let privilegeType
|
||||
@@ -86,14 +98,28 @@ function randGuardInfo() {
|
||||
return { authorType, privilegeType }
|
||||
}
|
||||
|
||||
const MEDAL_NAMES = [
|
||||
'ikun',
|
||||
'K学姐',
|
||||
'小孤独',
|
||||
'Go学长',
|
||||
'不登校',
|
||||
]
|
||||
|
||||
function randMedalInfo() {
|
||||
let medalLevel = randInt(1, 4) <= 1 ? 0 : randInt(1, 40)
|
||||
let medalName = medalLevel === 0 ? '' : randomChoose(MEDAL_NAMES)
|
||||
return { medalLevel, medalName }
|
||||
}
|
||||
|
||||
const GIFT_INFO_LIST = [
|
||||
{ giftName: '辣条', totalFreeCoin: 1000, num: 10 },
|
||||
{ giftName: 'B坷垃', totalCoin: 9900 },
|
||||
{ giftName: '礼花', totalCoin: 28000 },
|
||||
{ giftName: '花式夸夸', totalCoin: 39000 },
|
||||
{ giftName: '天空之翼', totalCoin: 100000 },
|
||||
{ giftName: '摩天大楼', totalCoin: 450000 },
|
||||
{ giftName: '小电视飞船', totalCoin: 1245000 }
|
||||
{ giftId: 1, giftName: '粉丝团灯牌', totalFreeCoin: 200, num: 10, giftIconUrl: '//s1.hdslb.com/bfs/live/e051dfd4557678f8edcac4993ed00a0935cbd9cc.png' },
|
||||
{ giftId: 2, giftName: '可爱捏', totalCoin: 9900, giftIconUrl: '//s1.hdslb.com/bfs/live/6dab14826b531c731521345e00d6b56a6708a449.png' },
|
||||
{ giftId: 3, giftName: '花式夸夸', totalCoin: 29900, giftIconUrl: '//s1.hdslb.com/bfs/live/28186596880db45a7b843f17d6ebb70feeac06f9.png' },
|
||||
{ giftId: 4, giftName: '情书', totalCoin: 52000, num: 10, giftIconUrl: '//s1.hdslb.com/bfs/live/14dafbf217618f0931c08897e0b3eefc00d0da22.png' },
|
||||
{ giftId: 5, giftName: '极速超跑', totalCoin: 100000, giftIconUrl: '//s1.hdslb.com/bfs/live/27b9734d1a5f77ea6fc94957e3fcbeb55505c6b9.png' },
|
||||
{ giftId: 6, giftName: '为你摘星', totalCoin: 520000, giftIconUrl: '//s1.hdslb.com/bfs/live/5bd584b6fdfb03d66de56102e775582fb29ceab7.png' },
|
||||
{ giftId: 7, giftName: '次元之城', totalCoin: 1245000, giftIconUrl: '//s1.hdslb.com/bfs/live/cdae8136b1ee767609aeec688bca8124651d4d01.png' }
|
||||
]
|
||||
|
||||
const SC_PRICES = [
|
||||
@@ -109,13 +135,14 @@ const MESSAGE_GENERATORS = [
|
||||
type: constants.MESSAGE_TYPE_TEXT,
|
||||
message: new chatModels.AddTextMsg({
|
||||
...randGuardInfo(),
|
||||
...randMedalInfo(),
|
||||
authorName: randomChoose(NAMES),
|
||||
content: randomChoose(CONTENTS),
|
||||
isGiftDanmaku: randInt(1, 10) <= 1,
|
||||
authorLevel: randInt(1, 60),
|
||||
isNewbie: randInt(1, 10) <= 1,
|
||||
isMobileVerified: randInt(1, 10) <= 9,
|
||||
medalLevel: randInt(0, 40),
|
||||
translation: randInt(1, 10) <= 1 ? randomChoose(TRANSLATIONS) : '',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -128,11 +155,11 @@ const MESSAGE_GENERATORS = [
|
||||
type: constants.MESSAGE_TYPE_TEXT,
|
||||
message: new chatModels.AddTextMsg({
|
||||
...randGuardInfo(),
|
||||
...randMedalInfo(),
|
||||
authorName: randomChoose(NAMES),
|
||||
authorLevel: randInt(1, 60),
|
||||
isNewbie: randInt(1, 10) <= 1,
|
||||
isMobileVerified: randInt(1, 10) <= 9,
|
||||
medalLevel: randInt(0, 40),
|
||||
emoticon: randomChoose(EMOTICONS),
|
||||
})
|
||||
}
|
||||
@@ -145,8 +172,10 @@ const MESSAGE_GENERATORS = [
|
||||
return {
|
||||
type: constants.MESSAGE_TYPE_GIFT,
|
||||
message: new chatModels.AddGiftMsg({
|
||||
...randMedalInfo(),
|
||||
...randomChoose(GIFT_INFO_LIST),
|
||||
authorName: randomChoose(NAMES),
|
||||
privilegeType: randInt(0, 3),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -158,9 +187,12 @@ const MESSAGE_GENERATORS = [
|
||||
return {
|
||||
type: constants.MESSAGE_TYPE_SUPER_CHAT,
|
||||
message: new chatModels.AddSuperChatMsg({
|
||||
...randMedalInfo(),
|
||||
authorName: randomChoose(NAMES),
|
||||
price: randomChoose(SC_PRICES),
|
||||
content: randomChoose(CONTENTS),
|
||||
translation: randInt(1, 10) <= 1 ? randomChoose(TRANSLATIONS) : '',
|
||||
privilegeType: randInt(0, 3),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -169,11 +201,14 @@ const MESSAGE_GENERATORS = [
|
||||
{
|
||||
weight: 1,
|
||||
value() {
|
||||
let privilegeType = randInt(1, 3)
|
||||
return {
|
||||
type: constants.MESSAGE_TYPE_MEMBER,
|
||||
message: new chatModels.AddMemberMsg({
|
||||
...randMedalInfo(),
|
||||
authorName: randomChoose(NAMES),
|
||||
privilegeType: randInt(1, 3)
|
||||
privilegeType: privilegeType,
|
||||
total_coin: GUARD_LEVEL_TO_PRICE[privilegeType] * 1000,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -251,6 +286,7 @@ export default class ChatClientTest {
|
||||
switch (type) {
|
||||
case constants.MESSAGE_TYPE_TEXT:
|
||||
this.msgHandler.onAddText(message)
|
||||
this.maybeTranslate(message)
|
||||
break
|
||||
case constants.MESSAGE_TYPE_GIFT:
|
||||
this.msgHandler.onAddGift(message)
|
||||
@@ -260,7 +296,34 @@ export default class ChatClientTest {
|
||||
break
|
||||
case constants.MESSAGE_TYPE_SUPER_CHAT:
|
||||
this.msgHandler.onAddSuperChat(message)
|
||||
this.maybeTranslate(message)
|
||||
this.maybeDeleteSc(message)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
maybeTranslate(message) {
|
||||
if (message.translation || randInt(1, 4) <= 1) {
|
||||
return
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
let translateMessage = new chatModels.UpdateTranslationMsg({
|
||||
id: message.id,
|
||||
translation: randomChoose(TRANSLATIONS),
|
||||
})
|
||||
this.msgHandler.onUpdateTranslation(translateMessage)
|
||||
}, randInt(1000, 3000))
|
||||
}
|
||||
|
||||
maybeDeleteSc(message) {
|
||||
if (randInt(1, 5) <= 4) {
|
||||
return
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
let deleteMessage = new chatModels.DelSuperChatMsg({
|
||||
ids: [message.id],
|
||||
})
|
||||
this.msgHandler.onDelSuperChat(deleteMessage)
|
||||
}, randInt(1000, 3000))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import MD5 from 'crypto-js/md5'
|
||||
|
||||
import { apiClient as axios } from '@/api/base'
|
||||
|
||||
export function getDefaultMsgHandler() {
|
||||
let dummyFunc = () => {}
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,9 @@ export class AddTextMsg {
|
||||
id = getUuid4Hex(),
|
||||
translation = '',
|
||||
emoticon = null,
|
||||
// 给模板用的字段
|
||||
uid = '',
|
||||
medalName = '',
|
||||
} = {}) {
|
||||
this.avatarUrl = avatarUrl
|
||||
this.timestamp = timestamp
|
||||
@@ -33,6 +36,9 @@ export class AddTextMsg {
|
||||
this.id = id
|
||||
this.translation = translation
|
||||
this.emoticon = emoticon
|
||||
// 给模板用的字段
|
||||
this.uid = uid
|
||||
this.medalName = medalName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +52,13 @@ export class AddGiftMsg {
|
||||
totalFreeCoin = 0,
|
||||
giftName = '',
|
||||
num = 1,
|
||||
// 给模板用的字段
|
||||
giftId = 0,
|
||||
giftIconUrl = '',
|
||||
uid = '',
|
||||
privilegeType = 0,
|
||||
medalLevel = 0,
|
||||
medalName = '',
|
||||
} = {}) {
|
||||
this.id = id
|
||||
this.avatarUrl = avatarUrl
|
||||
@@ -55,6 +68,13 @@ export class AddGiftMsg {
|
||||
this.totalFreeCoin = totalFreeCoin
|
||||
this.giftName = giftName
|
||||
this.num = num
|
||||
// 给模板用的字段
|
||||
this.giftId = giftId
|
||||
this.giftIconUrl = giftIconUrl
|
||||
this.uid = uid
|
||||
this.privilegeType = privilegeType
|
||||
this.medalLevel = medalLevel
|
||||
this.medalName = medalName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,12 +85,26 @@ export class AddMemberMsg {
|
||||
timestamp = new Date().getTime() / 1000,
|
||||
authorName = '',
|
||||
privilegeType = 1,
|
||||
// 给模板用的字段
|
||||
num = 1,
|
||||
unit = '月',
|
||||
total_coin = 0,
|
||||
uid = '',
|
||||
medalLevel = 0,
|
||||
medalName = '',
|
||||
} = {}) {
|
||||
this.id = id
|
||||
this.avatarUrl = avatarUrl
|
||||
this.timestamp = timestamp
|
||||
this.authorName = authorName
|
||||
this.privilegeType = privilegeType
|
||||
// 给模板用的字段
|
||||
this.num = num
|
||||
this.unit = unit
|
||||
this.totalCoin = total_coin
|
||||
this.uid = uid
|
||||
this.medalLevel = medalLevel
|
||||
this.medalName = medalName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +117,11 @@ export class AddSuperChatMsg {
|
||||
price = 0,
|
||||
content = '',
|
||||
translation = '',
|
||||
// 给模板用的字段
|
||||
uid = '',
|
||||
privilegeType = 0,
|
||||
medalLevel = 0,
|
||||
medalName = '',
|
||||
} = {}) {
|
||||
this.id = id
|
||||
this.avatarUrl = avatarUrl
|
||||
@@ -91,6 +130,11 @@ export class AddSuperChatMsg {
|
||||
this.price = price
|
||||
this.content = content
|
||||
this.translation = translation
|
||||
// 给模板用的字段
|
||||
this.uid = uid
|
||||
this.privilegeType = privilegeType
|
||||
this.medalLevel = medalLevel
|
||||
this.medalName = medalName
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ export const DEFAULT_CONFIG = {
|
||||
giftUsernamePronunciation: '',
|
||||
importPresetCss: false,
|
||||
|
||||
emoticons: [] // [{ keyword: '', url: '' }, ...]
|
||||
emoticons: [], // [{ keyword: '', url: '' }, ...]
|
||||
|
||||
templateUrl: '',
|
||||
}
|
||||
|
||||
export function deepCloneDefaultConfig() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import { apiClient as axios } from './base'
|
||||
|
||||
export async function getServerInfo() {
|
||||
return (await axios.get('/api/server_info')).data
|
||||
@@ -9,3 +9,7 @@ export async function uploadEmoticon(file) {
|
||||
body.set('file', file)
|
||||
return (await axios.post('/api/emoticon', body)).data
|
||||
}
|
||||
|
||||
export async function getTemplates() {
|
||||
return (await axios.get('/api/templates')).data
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import { apiClient as axios } from './base'
|
||||
|
||||
export async function getPlugins() {
|
||||
return (await axios.get('/api/plugin/plugins')).data
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@layer yt {
|
||||
|
||||
html:not(.style-scope) {
|
||||
--yt-live-chat-background-color: hsl(0, 0%, 100%);
|
||||
--yt-live-chat-action-panel-background-color: hsla(0, 0%, 93.3%, .4);
|
||||
@@ -360,3 +362,5 @@ html:not(.style-scope) {
|
||||
--layout-fixed-left_-_left: 0;
|
||||
;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@layer yt {
|
||||
|
||||
canvas.yt-icon, caption.yt-icon, center.yt-icon, cite.yt-icon, code.yt-icon, dd.yt-icon, del.yt-icon, dfn.yt-icon, div.yt-icon, dl.yt-icon, dt.yt-icon, em.yt-icon, embed.yt-icon, fieldset.yt-icon, font.yt-icon, form.yt-icon, h1.yt-icon, h2.yt-icon, h3.yt-icon, h4.yt-icon, h5.yt-icon, h6.yt-icon, hr.yt-icon, i.yt-icon, iframe.yt-icon, img.yt-icon, ins.yt-icon, kbd.yt-icon, label.yt-icon, legend.yt-icon, li.yt-icon, menu.yt-icon, object.yt-icon, ol.yt-icon, p.yt-icon, pre.yt-icon, q.yt-icon, s.yt-icon, samp.yt-icon, small.yt-icon, span.yt-icon, strike.yt-icon, strong.yt-icon, sub.yt-icon, sup.yt-icon, table.yt-icon, tbody.yt-icon, td.yt-icon, tfoot.yt-icon, th.yt-icon, thead.yt-icon, tr.yt-icon, tt.yt-icon, u.yt-icon, ul.yt-icon, var.yt-icon {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -28,3 +30,5 @@ yt-icon, .yt-icon-container.yt-icon {
|
||||
yt-icon.external-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@layer yt {
|
||||
|
||||
canvas.yt-img-shadow, caption.yt-img-shadow, center.yt-img-shadow, cite.yt-img-shadow, code.yt-img-shadow, dd.yt-img-shadow, del.yt-img-shadow, dfn.yt-img-shadow, div.yt-img-shadow, dl.yt-img-shadow, dt.yt-img-shadow, em.yt-img-shadow, embed.yt-img-shadow, fieldset.yt-img-shadow, font.yt-img-shadow, form.yt-img-shadow, h1.yt-img-shadow, h2.yt-img-shadow, h3.yt-img-shadow, h4.yt-img-shadow, h5.yt-img-shadow, h6.yt-img-shadow, hr.yt-img-shadow, i.yt-img-shadow, iframe.yt-img-shadow, img.yt-img-shadow, ins.yt-img-shadow, kbd.yt-img-shadow, label.yt-img-shadow, legend.yt-img-shadow, li.yt-img-shadow, menu.yt-img-shadow, object.yt-img-shadow, ol.yt-img-shadow, p.yt-img-shadow, pre.yt-img-shadow, q.yt-img-shadow, s.yt-img-shadow, samp.yt-img-shadow, small.yt-img-shadow, span.yt-img-shadow, strike.yt-img-shadow, strong.yt-img-shadow, sub.yt-img-shadow, sup.yt-img-shadow, table.yt-img-shadow, tbody.yt-img-shadow, td.yt-img-shadow, tfoot.yt-img-shadow, th.yt-img-shadow, thead.yt-img-shadow, tr.yt-img-shadow, tt.yt-img-shadow, u.yt-img-shadow, ul.yt-img-shadow, var.yt-img-shadow {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -66,3 +68,5 @@ img.yt-img-shadow {
|
||||
max-width: 100%;
|
||||
border-radius: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@layer yt {
|
||||
|
||||
canvas.yt-live-chat-author-badge-renderer, caption.yt-live-chat-author-badge-renderer, center.yt-live-chat-author-badge-renderer, cite.yt-live-chat-author-badge-renderer, code.yt-live-chat-author-badge-renderer, dd.yt-live-chat-author-badge-renderer, del.yt-live-chat-author-badge-renderer, dfn.yt-live-chat-author-badge-renderer, div.yt-live-chat-author-badge-renderer, dl.yt-live-chat-author-badge-renderer, dt.yt-live-chat-author-badge-renderer, em.yt-live-chat-author-badge-renderer, embed.yt-live-chat-author-badge-renderer, fieldset.yt-live-chat-author-badge-renderer, font.yt-live-chat-author-badge-renderer, form.yt-live-chat-author-badge-renderer, h1.yt-live-chat-author-badge-renderer, h2.yt-live-chat-author-badge-renderer, h3.yt-live-chat-author-badge-renderer, h4.yt-live-chat-author-badge-renderer, h5.yt-live-chat-author-badge-renderer, h6.yt-live-chat-author-badge-renderer, hr.yt-live-chat-author-badge-renderer, i.yt-live-chat-author-badge-renderer, iframe.yt-live-chat-author-badge-renderer, img.yt-live-chat-author-badge-renderer, ins.yt-live-chat-author-badge-renderer, kbd.yt-live-chat-author-badge-renderer, label.yt-live-chat-author-badge-renderer, legend.yt-live-chat-author-badge-renderer, li.yt-live-chat-author-badge-renderer, menu.yt-live-chat-author-badge-renderer, object.yt-live-chat-author-badge-renderer, ol.yt-live-chat-author-badge-renderer, p.yt-live-chat-author-badge-renderer, pre.yt-live-chat-author-badge-renderer, q.yt-live-chat-author-badge-renderer, s.yt-live-chat-author-badge-renderer, samp.yt-live-chat-author-badge-renderer, small.yt-live-chat-author-badge-renderer, span.yt-live-chat-author-badge-renderer, strike.yt-live-chat-author-badge-renderer, strong.yt-live-chat-author-badge-renderer, sub.yt-live-chat-author-badge-renderer, sup.yt-live-chat-author-badge-renderer, table.yt-live-chat-author-badge-renderer, tbody.yt-live-chat-author-badge-renderer, td.yt-live-chat-author-badge-renderer, tfoot.yt-live-chat-author-badge-renderer, th.yt-live-chat-author-badge-renderer, thead.yt-live-chat-author-badge-renderer, tr.yt-live-chat-author-badge-renderer, tt.yt-live-chat-author-badge-renderer, u.yt-live-chat-author-badge-renderer, ul.yt-live-chat-author-badge-renderer, var.yt-live-chat-author-badge-renderer {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -34,3 +36,5 @@ img.yt-live-chat-author-badge-renderer, yt-icon.yt-live-chat-author-badge-render
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@layer yt {
|
||||
|
||||
canvas.yt-live-chat-author-chip, caption.yt-live-chat-author-chip, center.yt-live-chat-author-chip, cite.yt-live-chat-author-chip, code.yt-live-chat-author-chip, dd.yt-live-chat-author-chip, del.yt-live-chat-author-chip, dfn.yt-live-chat-author-chip, div.yt-live-chat-author-chip, dl.yt-live-chat-author-chip, dt.yt-live-chat-author-chip, em.yt-live-chat-author-chip, embed.yt-live-chat-author-chip, fieldset.yt-live-chat-author-chip, font.yt-live-chat-author-chip, form.yt-live-chat-author-chip, h1.yt-live-chat-author-chip, h2.yt-live-chat-author-chip, h3.yt-live-chat-author-chip, h4.yt-live-chat-author-chip, h5.yt-live-chat-author-chip, h6.yt-live-chat-author-chip, hr.yt-live-chat-author-chip, i.yt-live-chat-author-chip, iframe.yt-live-chat-author-chip, img.yt-live-chat-author-chip, ins.yt-live-chat-author-chip, kbd.yt-live-chat-author-chip, label.yt-live-chat-author-chip, legend.yt-live-chat-author-chip, li.yt-live-chat-author-chip, menu.yt-live-chat-author-chip, object.yt-live-chat-author-chip, ol.yt-live-chat-author-chip, p.yt-live-chat-author-chip, pre.yt-live-chat-author-chip, q.yt-live-chat-author-chip, s.yt-live-chat-author-chip, samp.yt-live-chat-author-chip, small.yt-live-chat-author-chip, span.yt-live-chat-author-chip, strike.yt-live-chat-author-chip, strong.yt-live-chat-author-chip, sub.yt-live-chat-author-chip, sup.yt-live-chat-author-chip, table.yt-live-chat-author-chip, tbody.yt-live-chat-author-chip, td.yt-live-chat-author-chip, tfoot.yt-live-chat-author-chip, th.yt-live-chat-author-chip, thead.yt-live-chat-author-chip, tr.yt-live-chat-author-chip, tt.yt-live-chat-author-chip, u.yt-live-chat-author-chip, ul.yt-live-chat-author-chip, var.yt-live-chat-author-chip {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -62,3 +64,5 @@ yt-live-chat-author-chip[is-highlighted] #chip-badges.yt-live-chat-author-chip y
|
||||
#chip-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer.yt-live-chat-author-chip:last-of-type {
|
||||
margin-right: -2px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@layer yt {
|
||||
|
||||
canvas.yt-live-chat-item-list-renderer, caption.yt-live-chat-item-list-renderer, center.yt-live-chat-item-list-renderer, cite.yt-live-chat-item-list-renderer, code.yt-live-chat-item-list-renderer, dd.yt-live-chat-item-list-renderer, del.yt-live-chat-item-list-renderer, dfn.yt-live-chat-item-list-renderer, div.yt-live-chat-item-list-renderer, dl.yt-live-chat-item-list-renderer, dt.yt-live-chat-item-list-renderer, em.yt-live-chat-item-list-renderer, embed.yt-live-chat-item-list-renderer, fieldset.yt-live-chat-item-list-renderer, font.yt-live-chat-item-list-renderer, form.yt-live-chat-item-list-renderer, h1.yt-live-chat-item-list-renderer, h2.yt-live-chat-item-list-renderer, h3.yt-live-chat-item-list-renderer, h4.yt-live-chat-item-list-renderer, h5.yt-live-chat-item-list-renderer, h6.yt-live-chat-item-list-renderer, hr.yt-live-chat-item-list-renderer, i.yt-live-chat-item-list-renderer, iframe.yt-live-chat-item-list-renderer, img.yt-live-chat-item-list-renderer, ins.yt-live-chat-item-list-renderer, kbd.yt-live-chat-item-list-renderer, label.yt-live-chat-item-list-renderer, legend.yt-live-chat-item-list-renderer, li.yt-live-chat-item-list-renderer, menu.yt-live-chat-item-list-renderer, object.yt-live-chat-item-list-renderer, ol.yt-live-chat-item-list-renderer, p.yt-live-chat-item-list-renderer, pre.yt-live-chat-item-list-renderer, q.yt-live-chat-item-list-renderer, s.yt-live-chat-item-list-renderer, samp.yt-live-chat-item-list-renderer, small.yt-live-chat-item-list-renderer, span.yt-live-chat-item-list-renderer, strike.yt-live-chat-item-list-renderer, strong.yt-live-chat-item-list-renderer, sub.yt-live-chat-item-list-renderer, sup.yt-live-chat-item-list-renderer, table.yt-live-chat-item-list-renderer, tbody.yt-live-chat-item-list-renderer, td.yt-live-chat-item-list-renderer, tfoot.yt-live-chat-item-list-renderer, th.yt-live-chat-item-list-renderer, thead.yt-live-chat-item-list-renderer, tr.yt-live-chat-item-list-renderer, tt.yt-live-chat-item-list-renderer, u.yt-live-chat-item-list-renderer, ul.yt-live-chat-item-list-renderer, var.yt-live-chat-item-list-renderer {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -138,3 +140,5 @@ yt-live-chat-paid-sticker-renderer.yt-live-chat-item-list-renderer {
|
||||
yt-live-chat-paid-sticker-renderer.yt-live-chat-item-list-renderer[dashboard-money-feed] {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@layer yt {
|
||||
|
||||
#timestamp.yt-live-chat-membership-item-renderer {
|
||||
display: var(--yt-live-chat-item-timestamp-display, inline);
|
||||
margin: var(--yt-live-chat-item-timestamp-margin, 0 8px 0 0);
|
||||
@@ -360,3 +362,5 @@ yt-live-chat-membership-item-renderer[dashboard-money-feed] #menu.yt-live-chat-m
|
||||
margin-top: 8px;
|
||||
background: linear-gradient(to right, transparent, var(--yt-live-chat-background-color) 40%);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@layer yt {
|
||||
|
||||
canvas.yt-live-chat-paid-message-renderer, caption.yt-live-chat-paid-message-renderer, center.yt-live-chat-paid-message-renderer, cite.yt-live-chat-paid-message-renderer, code.yt-live-chat-paid-message-renderer, dd.yt-live-chat-paid-message-renderer, del.yt-live-chat-paid-message-renderer, dfn.yt-live-chat-paid-message-renderer, div.yt-live-chat-paid-message-renderer, dl.yt-live-chat-paid-message-renderer, dt.yt-live-chat-paid-message-renderer, em.yt-live-chat-paid-message-renderer, embed.yt-live-chat-paid-message-renderer, fieldset.yt-live-chat-paid-message-renderer, font.yt-live-chat-paid-message-renderer, form.yt-live-chat-paid-message-renderer, h1.yt-live-chat-paid-message-renderer, h2.yt-live-chat-paid-message-renderer, h3.yt-live-chat-paid-message-renderer, h4.yt-live-chat-paid-message-renderer, h5.yt-live-chat-paid-message-renderer, h6.yt-live-chat-paid-message-renderer, hr.yt-live-chat-paid-message-renderer, i.yt-live-chat-paid-message-renderer, iframe.yt-live-chat-paid-message-renderer, img.yt-live-chat-paid-message-renderer, ins.yt-live-chat-paid-message-renderer, kbd.yt-live-chat-paid-message-renderer, label.yt-live-chat-paid-message-renderer, legend.yt-live-chat-paid-message-renderer, li.yt-live-chat-paid-message-renderer, menu.yt-live-chat-paid-message-renderer, object.yt-live-chat-paid-message-renderer, ol.yt-live-chat-paid-message-renderer, p.yt-live-chat-paid-message-renderer, pre.yt-live-chat-paid-message-renderer, q.yt-live-chat-paid-message-renderer, s.yt-live-chat-paid-message-renderer, samp.yt-live-chat-paid-message-renderer, small.yt-live-chat-paid-message-renderer, span.yt-live-chat-paid-message-renderer, strike.yt-live-chat-paid-message-renderer, strong.yt-live-chat-paid-message-renderer, sub.yt-live-chat-paid-message-renderer, sup.yt-live-chat-paid-message-renderer, table.yt-live-chat-paid-message-renderer, tbody.yt-live-chat-paid-message-renderer, td.yt-live-chat-paid-message-renderer, tfoot.yt-live-chat-paid-message-renderer, th.yt-live-chat-paid-message-renderer, thead.yt-live-chat-paid-message-renderer, tr.yt-live-chat-paid-message-renderer, tt.yt-live-chat-paid-message-renderer, u.yt-live-chat-paid-message-renderer, ul.yt-live-chat-paid-message-renderer, var.yt-live-chat-paid-message-renderer {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -378,3 +380,5 @@ yt-live-chat-paid-message-renderer[show-footer-divider] #footer.yt-live-chat-pai
|
||||
yt-live-chat-paid-message-renderer[is-user-editable] #footer.yt-live-chat-paid-message-renderer:not(:empty) {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@layer yt {
|
||||
|
||||
yt-live-chat-renderer, yt-live-chat-item-list-renderer #item-scroller {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -250,3 +252,5 @@ yt-live-chat-renderer[collapsed] .hide-on-collapse.yt-live-chat-renderer {
|
||||
background-color: rgba(0, 0, 0, 0.60);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@layer yt {
|
||||
|
||||
canvas.yt-live-chat-text-message-renderer, caption.yt-live-chat-text-message-renderer, center.yt-live-chat-text-message-renderer, cite.yt-live-chat-text-message-renderer, code.yt-live-chat-text-message-renderer, dd.yt-live-chat-text-message-renderer, del.yt-live-chat-text-message-renderer, dfn.yt-live-chat-text-message-renderer, div.yt-live-chat-text-message-renderer, dl.yt-live-chat-text-message-renderer, dt.yt-live-chat-text-message-renderer, em.yt-live-chat-text-message-renderer, embed.yt-live-chat-text-message-renderer, fieldset.yt-live-chat-text-message-renderer, font.yt-live-chat-text-message-renderer, form.yt-live-chat-text-message-renderer, h1.yt-live-chat-text-message-renderer, h2.yt-live-chat-text-message-renderer, h3.yt-live-chat-text-message-renderer, h4.yt-live-chat-text-message-renderer, h5.yt-live-chat-text-message-renderer, h6.yt-live-chat-text-message-renderer, hr.yt-live-chat-text-message-renderer, i.yt-live-chat-text-message-renderer, iframe.yt-live-chat-text-message-renderer, img.yt-live-chat-text-message-renderer, ins.yt-live-chat-text-message-renderer, kbd.yt-live-chat-text-message-renderer, label.yt-live-chat-text-message-renderer, legend.yt-live-chat-text-message-renderer, li.yt-live-chat-text-message-renderer, menu.yt-live-chat-text-message-renderer, object.yt-live-chat-text-message-renderer, ol.yt-live-chat-text-message-renderer, p.yt-live-chat-text-message-renderer, pre.yt-live-chat-text-message-renderer, q.yt-live-chat-text-message-renderer, s.yt-live-chat-text-message-renderer, samp.yt-live-chat-text-message-renderer, small.yt-live-chat-text-message-renderer, span.yt-live-chat-text-message-renderer, strike.yt-live-chat-text-message-renderer, strong.yt-live-chat-text-message-renderer, sub.yt-live-chat-text-message-renderer, sup.yt-live-chat-text-message-renderer, table.yt-live-chat-text-message-renderer, tbody.yt-live-chat-text-message-renderer, td.yt-live-chat-text-message-renderer, tfoot.yt-live-chat-text-message-renderer, th.yt-live-chat-text-message-renderer, thead.yt-live-chat-text-message-renderer, tr.yt-live-chat-text-message-renderer, tt.yt-live-chat-text-message-renderer, u.yt-live-chat-text-message-renderer, ul.yt-live-chat-text-message-renderer, var.yt-live-chat-text-message-renderer {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -230,3 +232,5 @@ yt-live-chat-text-message-renderer[is-dimmed] #message.yt-live-chat-text-message
|
||||
yt-live-chat-text-message-renderer[is-dimmed]::before {
|
||||
background: var(--yt-live-chat-error-message-color, #f44336);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@layer yt {
|
||||
|
||||
canvas.yt-live-chat-ticker-paid-message-item-renderer, caption.yt-live-chat-ticker-paid-message-item-renderer, center.yt-live-chat-ticker-paid-message-item-renderer, cite.yt-live-chat-ticker-paid-message-item-renderer, code.yt-live-chat-ticker-paid-message-item-renderer, dd.yt-live-chat-ticker-paid-message-item-renderer, del.yt-live-chat-ticker-paid-message-item-renderer, dfn.yt-live-chat-ticker-paid-message-item-renderer, div.yt-live-chat-ticker-paid-message-item-renderer, dl.yt-live-chat-ticker-paid-message-item-renderer, dt.yt-live-chat-ticker-paid-message-item-renderer, em.yt-live-chat-ticker-paid-message-item-renderer, embed.yt-live-chat-ticker-paid-message-item-renderer, fieldset.yt-live-chat-ticker-paid-message-item-renderer, font.yt-live-chat-ticker-paid-message-item-renderer, form.yt-live-chat-ticker-paid-message-item-renderer, h1.yt-live-chat-ticker-paid-message-item-renderer, h2.yt-live-chat-ticker-paid-message-item-renderer, h3.yt-live-chat-ticker-paid-message-item-renderer, h4.yt-live-chat-ticker-paid-message-item-renderer, h5.yt-live-chat-ticker-paid-message-item-renderer, h6.yt-live-chat-ticker-paid-message-item-renderer, hr.yt-live-chat-ticker-paid-message-item-renderer, i.yt-live-chat-ticker-paid-message-item-renderer, iframe.yt-live-chat-ticker-paid-message-item-renderer, img.yt-live-chat-ticker-paid-message-item-renderer, ins.yt-live-chat-ticker-paid-message-item-renderer, kbd.yt-live-chat-ticker-paid-message-item-renderer, label.yt-live-chat-ticker-paid-message-item-renderer, legend.yt-live-chat-ticker-paid-message-item-renderer, li.yt-live-chat-ticker-paid-message-item-renderer, menu.yt-live-chat-ticker-paid-message-item-renderer, object.yt-live-chat-ticker-paid-message-item-renderer, ol.yt-live-chat-ticker-paid-message-item-renderer, p.yt-live-chat-ticker-paid-message-item-renderer, pre.yt-live-chat-ticker-paid-message-item-renderer, q.yt-live-chat-ticker-paid-message-item-renderer, s.yt-live-chat-ticker-paid-message-item-renderer, samp.yt-live-chat-ticker-paid-message-item-renderer, small.yt-live-chat-ticker-paid-message-item-renderer, span.yt-live-chat-ticker-paid-message-item-renderer, strike.yt-live-chat-ticker-paid-message-item-renderer, strong.yt-live-chat-ticker-paid-message-item-renderer, sub.yt-live-chat-ticker-paid-message-item-renderer, sup.yt-live-chat-ticker-paid-message-item-renderer, table.yt-live-chat-ticker-paid-message-item-renderer, tbody.yt-live-chat-ticker-paid-message-item-renderer, td.yt-live-chat-ticker-paid-message-item-renderer, tfoot.yt-live-chat-ticker-paid-message-item-renderer, th.yt-live-chat-ticker-paid-message-item-renderer, thead.yt-live-chat-ticker-paid-message-item-renderer, tr.yt-live-chat-ticker-paid-message-item-renderer, tt.yt-live-chat-ticker-paid-message-item-renderer, u.yt-live-chat-ticker-paid-message-item-renderer, ul.yt-live-chat-ticker-paid-message-item-renderer, var.yt-live-chat-ticker-paid-message-item-renderer {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -78,3 +80,5 @@ yt-img-shadow.yt-live-chat-ticker-paid-message-item-renderer {
|
||||
yt-live-chat-ticker-paid-message-item-renderer[is-deleted] #author-photo.yt-live-chat-ticker-paid-message-item-renderer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@layer yt {
|
||||
|
||||
canvas.yt-live-chat-ticker-renderer, caption.yt-live-chat-ticker-renderer, center.yt-live-chat-ticker-renderer, cite.yt-live-chat-ticker-renderer, code.yt-live-chat-ticker-renderer, dd.yt-live-chat-ticker-renderer, del.yt-live-chat-ticker-renderer, dfn.yt-live-chat-ticker-renderer, div.yt-live-chat-ticker-renderer, dl.yt-live-chat-ticker-renderer, dt.yt-live-chat-ticker-renderer, em.yt-live-chat-ticker-renderer, embed.yt-live-chat-ticker-renderer, fieldset.yt-live-chat-ticker-renderer, font.yt-live-chat-ticker-renderer, form.yt-live-chat-ticker-renderer, h1.yt-live-chat-ticker-renderer, h2.yt-live-chat-ticker-renderer, h3.yt-live-chat-ticker-renderer, h4.yt-live-chat-ticker-renderer, h5.yt-live-chat-ticker-renderer, h6.yt-live-chat-ticker-renderer, hr.yt-live-chat-ticker-renderer, i.yt-live-chat-ticker-renderer, iframe.yt-live-chat-ticker-renderer, img.yt-live-chat-ticker-renderer, ins.yt-live-chat-ticker-renderer, kbd.yt-live-chat-ticker-renderer, label.yt-live-chat-ticker-renderer, legend.yt-live-chat-ticker-renderer, li.yt-live-chat-ticker-renderer, menu.yt-live-chat-ticker-renderer, object.yt-live-chat-ticker-renderer, ol.yt-live-chat-ticker-renderer, p.yt-live-chat-ticker-renderer, pre.yt-live-chat-ticker-renderer, q.yt-live-chat-ticker-renderer, s.yt-live-chat-ticker-renderer, samp.yt-live-chat-ticker-renderer, small.yt-live-chat-ticker-renderer, span.yt-live-chat-ticker-renderer, strike.yt-live-chat-ticker-renderer, strong.yt-live-chat-ticker-renderer, sub.yt-live-chat-ticker-renderer, sup.yt-live-chat-ticker-renderer, table.yt-live-chat-ticker-renderer, tbody.yt-live-chat-ticker-renderer, td.yt-live-chat-ticker-renderer, tfoot.yt-live-chat-ticker-renderer, th.yt-live-chat-ticker-renderer, thead.yt-live-chat-ticker-renderer, tr.yt-live-chat-ticker-renderer, tt.yt-live-chat-ticker-renderer, u.yt-live-chat-ticker-renderer, ul.yt-live-chat-ticker-renderer, var.yt-live-chat-ticker-renderer {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -20,7 +22,9 @@ yt-live-chat-ticker-renderer {
|
||||
|
||||
#items.yt-live-chat-ticker-renderer {
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
/* 为了支持滚动 */
|
||||
/* overflow: hidden; */
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
padding: 0 24px 8px 24px;
|
||||
}
|
||||
@@ -63,3 +67,5 @@ yt-icon.yt-live-chat-ticker-renderer {
|
||||
padding: 4px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
483
frontend/src/blcsdk.js
Normal file
@@ -0,0 +1,483 @@
|
||||
/** @module blcsdk */
|
||||
|
||||
(function(root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
define([], factory)
|
||||
} else {
|
||||
root.blcsdk = factory()
|
||||
}
|
||||
}(typeof self !== 'undefined' ? self : this, function() {
|
||||
const exports = {}
|
||||
|
||||
const VERSION = '1.0.0'
|
||||
/**
|
||||
* 取SDK版本
|
||||
* @returns {string} "1.0.0"
|
||||
*/
|
||||
function getVersion() {
|
||||
return VERSION
|
||||
}
|
||||
exports.getVersion = getVersion
|
||||
|
||||
// 初始化消息的Promise {promise, resolve, reject}
|
||||
let initPromise = null
|
||||
// 初始化消息,包含版本、配置等信息
|
||||
let initMsg = null
|
||||
|
||||
/**
|
||||
* 消息处理器
|
||||
* @type {MsgHandler}
|
||||
*/
|
||||
let msgHandler = null
|
||||
/**
|
||||
* 用户设置的消息处理器
|
||||
* @type {MsgHandler}
|
||||
*/
|
||||
let rawMsgHandler = null
|
||||
|
||||
/**
|
||||
* @typedef InitOptions
|
||||
* @property {boolean} noMsgDelay 去掉消息延迟,但会导致消息不平滑
|
||||
*/
|
||||
|
||||
/**
|
||||
* 初始化SDK
|
||||
*
|
||||
* 在调用除了{@link setMsgHandler}以外的其他接口之前必须先调用这个
|
||||
*/
|
||||
async function init(
|
||||
/** @type {?InitOptions} */
|
||||
{noMsgDelay = false} = {}
|
||||
) {
|
||||
if (initPromise) {
|
||||
throw new Error('Cannot call init() again')
|
||||
}
|
||||
// initPromise = Promise.withResolvers()
|
||||
initPromise = {}
|
||||
initPromise.promise = new Promise((resolve, reject) => {
|
||||
initPromise.resolve = resolve
|
||||
initPromise.reject = reject
|
||||
})
|
||||
|
||||
if (window.parent === window) {
|
||||
initPromise.reject(new Error('No parent window'))
|
||||
return initPromise.promise
|
||||
}
|
||||
|
||||
msgHandler = noMsgDelay ? new SdkMsgHandler() : new SmoothedSdkMsgHandler()
|
||||
window.addEventListener('message', onWindowMessage)
|
||||
|
||||
// 连接blivechat
|
||||
blcSendMsg('blcTemplateConnect')
|
||||
setTimeout(() => initPromise.reject(new Error('Timed out waiting for blcInit message')), 10 * 1000)
|
||||
|
||||
// 等待初始化消息
|
||||
initMsg = await initPromise.promise
|
||||
console.debug('blcsdk initialized, init_msg=', initMsg)
|
||||
}
|
||||
exports.init = init
|
||||
|
||||
/**
|
||||
* 设置消息处理器
|
||||
* @param {?MsgHandler} handler 消息处理器
|
||||
*/
|
||||
function setMsgHandler(handler) {
|
||||
rawMsgHandler = handler
|
||||
}
|
||||
exports.setMsgHandler = setMsgHandler
|
||||
|
||||
/**
|
||||
* 取blivechat前端版本
|
||||
* @returns {string} "v1.10.0-dev"
|
||||
*/
|
||||
function getBlcVersion() {
|
||||
if (!initMsg) {
|
||||
throw new Error('Please call init() first')
|
||||
}
|
||||
return initMsg.blcVersion
|
||||
}
|
||||
exports.getBlcVersion = getBlcVersion
|
||||
|
||||
/**
|
||||
* 取blivechat前端用的SDK版本。是父窗口用的版本,不是这个包的{@link getVersion}返回值
|
||||
* @returns {string} "1.0.0"
|
||||
*/
|
||||
function getBlcSdkVersion() {
|
||||
if (!initMsg) {
|
||||
throw new Error('Please call init() first')
|
||||
}
|
||||
return initMsg.sdkVersion
|
||||
}
|
||||
exports.getBlcSdkVersion = getBlcSdkVersion
|
||||
|
||||
/**
|
||||
* @typedef Config
|
||||
* @property {boolean} showGiftName 显示礼物名
|
||||
* @property {boolean} mergeSimilarDanmaku 合并相似弹幕
|
||||
* @property {boolean} mergeGift 合并礼物
|
||||
* @property {number} maxNumber 最大弹幕数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 取blivechat前端房间部分配置
|
||||
* @returns {Config}
|
||||
*/
|
||||
function getConfig() {
|
||||
if (!initMsg) {
|
||||
throw new Error('Please call init() first')
|
||||
}
|
||||
return Object.freeze(initMsg.config)
|
||||
}
|
||||
exports.getConfig = getConfig
|
||||
|
||||
function blcSendMsg(type, data = null) {
|
||||
if (window.parent === window) {
|
||||
return
|
||||
}
|
||||
let msg = { type, data }
|
||||
window.parent.postMessage(msg, '*')
|
||||
}
|
||||
|
||||
function onWindowMessage(event) {
|
||||
if (event.source !== window.parent) {
|
||||
return
|
||||
}
|
||||
|
||||
let { type, data } = event.data
|
||||
switch (type) {
|
||||
case 'blcInit':
|
||||
initPromise.resolve(data)
|
||||
break
|
||||
case 'blcAddMsg':
|
||||
msgHandler.addMsg(data)
|
||||
break
|
||||
case 'blcDelMsgs':
|
||||
msgHandler.delMsgs(data.ids)
|
||||
break
|
||||
case 'blcUpdateMsg':
|
||||
msgHandler.updateMsg(data.id, data.newValuesObj)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** 模板消息处理器接口 */
|
||||
class MsgHandler {
|
||||
/**
|
||||
* 添加消息
|
||||
* @param {AnyDisplayMsg} msg
|
||||
*/
|
||||
addMsg(msg) {}
|
||||
|
||||
/**
|
||||
* 删除消息,主要用于撤回醒目留言
|
||||
* @param {string[]} ids 要删除的消息ID
|
||||
*/
|
||||
delMsgs(ids) {}
|
||||
|
||||
/**
|
||||
* 更新消息字段,主要用于更新翻译结果
|
||||
* @param {string} id 要更新的消息ID
|
||||
* @param {Object} newValuesObj 字段和对应的新值
|
||||
*/
|
||||
updateMsg(id, newValuesObj) {}
|
||||
}
|
||||
exports.MsgHandler = MsgHandler
|
||||
|
||||
class SdkMsgHandler extends MsgHandler {
|
||||
addMsg(msg) { this._callRawHandler('addMsg', msg) }
|
||||
delMsgs(ids) { this._callRawHandler('delMsgs', ids) }
|
||||
updateMsg(id, newValuesObj) { this._callRawHandler('updateMsg', id, newValuesObj) }
|
||||
_callRawHandler(...args) { doCallRawHandler(...args) }
|
||||
}
|
||||
|
||||
function doCallRawHandler(funcName, ...args) {
|
||||
if (!rawMsgHandler) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
let func = rawMsgHandler[funcName]
|
||||
return func.call(rawMsgHandler, ...args)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息时间间隔范围
|
||||
const MSG_MIN_INTERVAL = 80
|
||||
const MSG_MAX_INTERVAL = 1000
|
||||
|
||||
class SmoothedSdkMsgHandler extends SdkMsgHandler {
|
||||
constructor() {
|
||||
super()
|
||||
// 消息队列
|
||||
this._queue = []
|
||||
// 消费消息队列的定时器ID
|
||||
this._emitSmoothedMsgTimerId = null
|
||||
// 最近进队列的时间间隔,用来估计下次进队列的时间
|
||||
this._enqueueIntervals = []
|
||||
// 上次进队列的时间
|
||||
this._lastEnqueueTime = null
|
||||
// 估计的下次进队列时间间隔
|
||||
this._estimatedEnqueueInterval = null
|
||||
|
||||
this._boundEmitSmoothedMsgs = this._emitSmoothedMsgs.bind(this)
|
||||
}
|
||||
|
||||
_callRawHandler(funcName, ...args) {
|
||||
let msg = {funcName, args}
|
||||
this._enqueueMsg(msg)
|
||||
}
|
||||
|
||||
_enqueueMsg(msg) {
|
||||
// 估计进队列时间间隔
|
||||
if (!this._lastEnqueueTime) {
|
||||
this._lastEnqueueTime = new Date()
|
||||
} else {
|
||||
let curTime = new Date()
|
||||
let interval = curTime - this._lastEnqueueTime
|
||||
// 真实的进队列时间间隔模式大概是这样:2500, 300, 300, 300, 2500, 300, ...
|
||||
// B站消息有缓冲,会一次发多条消息。这里把波峰视为发送了一次真实的WS消息,所以要过滤掉间隔太小的
|
||||
if (interval > 1000 || this._enqueueIntervals.length < 5) {
|
||||
this._enqueueIntervals.push(interval)
|
||||
if (this._enqueueIntervals.length > 5) {
|
||||
this._enqueueIntervals.splice(0, this._enqueueIntervals.length - 5)
|
||||
}
|
||||
// 这边估计得尽量大,只要不太早把消息缓冲发完就是平滑的。有MESSAGE_MAX_INTERVAL保底,不会让消息延迟太大
|
||||
// 其实可以用单调队列求最大值,偷懒不写了
|
||||
this._estimatedEnqueueInterval = Math.max(...this._enqueueIntervals)
|
||||
}
|
||||
// 上次入队时间还是要设置,否则会太早把消息缓冲发完,然后较长时间没有新消息
|
||||
this._lastEnqueueTime = curTime
|
||||
}
|
||||
|
||||
this._queue.push(msg)
|
||||
|
||||
if (!this._emitSmoothedMsgTimerId) {
|
||||
this._emitSmoothedMsgTimerId = setTimeout(this._boundEmitSmoothedMsgs)
|
||||
}
|
||||
}
|
||||
|
||||
_emitSmoothedMsgs() {
|
||||
this._emitSmoothedMsgTimerId = null
|
||||
if (this._queue.length <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 估计的下次进队列剩余时间
|
||||
let estimatedNextEnqueueRemainTime = 10 * 1000
|
||||
if (this._estimatedEnqueueInterval) {
|
||||
estimatedNextEnqueueRemainTime = Math.max(this._lastEnqueueTime - new Date() + this._estimatedEnqueueInterval, 1)
|
||||
}
|
||||
// 计算发送的消息数,保证在下次进队列之前发完
|
||||
// 下次进队列之前应该发多少条消息
|
||||
let shouldEmitNum = Math.max(this._queue.length, 0)
|
||||
// 下次进队列之前最多能发多少次
|
||||
let maxCanEmitCount = estimatedNextEnqueueRemainTime / MSG_MIN_INTERVAL
|
||||
// 这次发多少条消息
|
||||
let numToEmit
|
||||
if (shouldEmitNum < maxCanEmitCount) {
|
||||
// 队列中消息数很少,每次发1条也能发完
|
||||
numToEmit = 1
|
||||
} else {
|
||||
// 每次发1条以上,保证按最快速度能发完
|
||||
numToEmit = Math.ceil(shouldEmitNum / maxCanEmitCount)
|
||||
}
|
||||
|
||||
// 发消息
|
||||
let msgs = this._queue.splice(0, numToEmit)
|
||||
for (let msg of msgs) {
|
||||
doCallRawHandler(msg.funcName, ...msg.args)
|
||||
}
|
||||
|
||||
if (this._queue.length <= 0) {
|
||||
return
|
||||
}
|
||||
// 消息没发完,计算下次发消息时间
|
||||
let sleepTime
|
||||
if (numToEmit === 1) {
|
||||
// 队列中消息数很少,随便定个[MESSAGE_MIN_INTERVAL, MESSAGE_MAX_INTERVAL]的时间
|
||||
sleepTime = estimatedNextEnqueueRemainTime / this._queue.length
|
||||
sleepTime *= 0.5 + Math.random()
|
||||
if (sleepTime > MSG_MAX_INTERVAL) {
|
||||
sleepTime = MSG_MAX_INTERVAL
|
||||
} else if (sleepTime < MSG_MIN_INTERVAL) {
|
||||
sleepTime = MSG_MIN_INTERVAL
|
||||
}
|
||||
} else {
|
||||
// 按最快速度发
|
||||
sleepTime = MSG_MIN_INTERVAL
|
||||
}
|
||||
this._emitSmoothedMsgTimerId = window.setTimeout(this._boundEmitSmoothedMsgs, sleepTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息类型
|
||||
* @enum {number}
|
||||
*/
|
||||
const MsgType = Object.freeze({
|
||||
/** 评论 @see TextMsg */
|
||||
TEXT: 0,
|
||||
/** 礼物 @see GiftMsg */
|
||||
GIFT: 1,
|
||||
/** 上舰 @see MemberMsg */
|
||||
MEMBER: 2,
|
||||
/** 醒目留言 @see SuperChatMsg */
|
||||
SUPER_CHAT: 3,
|
||||
})
|
||||
exports.MsgType = MsgType
|
||||
|
||||
/**
|
||||
* 作者类型
|
||||
* @enum {number}
|
||||
*/
|
||||
const AuthorType = Object.freeze({
|
||||
NORMAL: 0,
|
||||
/** 舰队 */
|
||||
MEMBER: 1,
|
||||
/** 房管 */
|
||||
ADMIN: 2,
|
||||
/** 主播 */
|
||||
OWNER: 3,
|
||||
})
|
||||
exports.AuthorType = AuthorType
|
||||
|
||||
/**
|
||||
* 舰队等级。因为历史原因,消息里的字段名叫`privilegeType`
|
||||
* @enum {number}
|
||||
*/
|
||||
const GuardLevel = Object.freeze({
|
||||
NONE: 0,
|
||||
/** 总督 */
|
||||
LV3: 1,
|
||||
/** 提督 */
|
||||
LV2: 2,
|
||||
/** 舰长 */
|
||||
LV1: 3,
|
||||
})
|
||||
exports.GuardLevel = GuardLevel
|
||||
|
||||
/**
|
||||
* 一段内容的类型
|
||||
* @enum {number}
|
||||
*/
|
||||
const ContentPartType = Object.freeze({
|
||||
/** 文本 */
|
||||
TEXT: 0,
|
||||
/** 图片 */
|
||||
IMAGE: 1,
|
||||
})
|
||||
exports.ContentPartType = ContentPartType
|
||||
|
||||
//
|
||||
// 以下只用于类型注解,运行时没什么用
|
||||
//
|
||||
|
||||
/**
|
||||
* 用于显示的消息
|
||||
* @typedef {TextMsg | GiftMsg | MemberMsg | SuperChatMsg} AnyDisplayMsg
|
||||
*/
|
||||
|
||||
/**
|
||||
* 评论消息。因为历史原因叫TextMsg,实际上会包含表情图片
|
||||
* @typedef TextMsg
|
||||
* @property {string} id 消息ID
|
||||
* @property {MsgType} type 消息类型
|
||||
* @property {string} avatarUrl 用户头像URL
|
||||
* @property {Date} time 时间
|
||||
* @property {string} authorName 用户名
|
||||
* @property {AuthorType} authorType 用户类型
|
||||
* @property {string} content 纯文本表示的内容
|
||||
* @property {AnyContentPart[]} contentParts 解析后的内容,包括文本、图片
|
||||
* @property {GuardLevel} privilegeType 舰队等级
|
||||
* @property {string} translation 内容的翻译,刚添加时一般是空的,之后通过更新消息赋值
|
||||
* @property {string} uid 用户Open ID或ID,使用房间ID连接时不保证是唯一的
|
||||
* @property {number} medalLevel 勋章等级,如果没戴当前房间勋章则为0
|
||||
* @property {string} medalName 勋章名,如果没戴当前房间勋章则为空字符串
|
||||
*/
|
||||
exports.TextMsg = /** @type {TextMsg} */ (undefined)
|
||||
|
||||
/**
|
||||
* 一段内容
|
||||
* @typedef {TextContentPart | ImageContentPart} AnyContentPart
|
||||
*/
|
||||
/**
|
||||
|
||||
* 一段文本内容
|
||||
* @typedef TextContentPart
|
||||
* @property {ContentPartType} type 内容类型
|
||||
* @property {string} text 内容
|
||||
*/
|
||||
|
||||
/**
|
||||
* 一段图片内容
|
||||
* @typedef ImageContentPart
|
||||
* @property {ContentPartType} type 内容类型
|
||||
* @property {string} text 纯文本表示的内容
|
||||
* @property {string} url 图片URL
|
||||
* @property {number} width 宽度,加载失败则为0
|
||||
* @property {number} height 高度,加载失败则为0
|
||||
*/
|
||||
|
||||
/**
|
||||
* 礼物消息
|
||||
* @typedef GiftMsg
|
||||
* @property {string} id 消息ID
|
||||
* @property {MsgType} type 消息类型
|
||||
* @property {string} avatarUrl 用户头像URL
|
||||
* @property {Date} time 时间
|
||||
* @property {string} authorName 用户名
|
||||
* @property {string} authorNamePronunciation 用户名读音
|
||||
* @property {number} price 总价(元),免费礼物则为0
|
||||
* @property {string} giftName 礼物名
|
||||
* @property {number} num 数量
|
||||
* @property {number} totalFreeCoin 免费礼物总价(银瓜子数),付费礼物则为0
|
||||
* @property {number} giftId 礼物ID
|
||||
* @property {string} giftIconUrl 礼物图标URL
|
||||
* @property {string} uid 用户Open ID或ID,使用房间ID连接时不保证是唯一的
|
||||
* @property {GuardLevel} privilegeType 舰队等级
|
||||
* @property {number} medalLevel 勋章等级,如果没戴当前房间勋章则为0
|
||||
* @property {string} medalName 勋章名,如果没戴当前房间勋章则为空字符串
|
||||
*/
|
||||
exports.GiftMsg = /** @type {GiftMsg} */ (undefined)
|
||||
|
||||
/**
|
||||
* 上舰消息
|
||||
* @typedef MemberMsg
|
||||
* @property {string} id 消息ID
|
||||
* @property {MsgType} type 消息类型
|
||||
* @property {string} avatarUrl 用户头像URL
|
||||
* @property {Date} time 时间
|
||||
* @property {string} authorName 用户名
|
||||
* @property {string} authorNamePronunciation 用户名读音
|
||||
* @property {GuardLevel} privilegeType 舰队等级
|
||||
* @property {number} num 数量
|
||||
* @property {string} unit 单位("月")
|
||||
* @property {number} price 总价(元)
|
||||
* @property {string} uid 用户Open ID或ID,使用房间ID连接时不保证是唯一的
|
||||
* @property {number} medalLevel 勋章等级,如果没戴当前房间勋章则为0
|
||||
* @property {string} medalName 勋章名,如果没戴当前房间勋章则为空字符串
|
||||
*/
|
||||
exports.MemberMsg = /** @type {MemberMsg} */ (undefined)
|
||||
|
||||
/**
|
||||
* 醒目留言消息
|
||||
* @typedef SuperChatMsg
|
||||
* @property {string} id 消息ID
|
||||
* @property {MsgType} type 消息类型
|
||||
* @property {string} avatarUrl 用户头像URL
|
||||
* @property {Date} time 时间
|
||||
* @property {string} authorName 用户名
|
||||
* @property {string} authorNamePronunciation 用户名读音
|
||||
* @property {number} price 价格(元)
|
||||
* @property {string} content 内容
|
||||
* @property {string} translation 内容的翻译,刚添加时一般是空的,之后通过更新消息赋值
|
||||
* @property {string} uid 用户Open ID或ID,使用房间ID连接时不保证是唯一的
|
||||
* @property {GuardLevel} privilegeType 舰队等级
|
||||
* @property {number} medalLevel 勋章等级,如果没戴当前房间勋章则为0
|
||||
* @property {string} medalName 勋章名,如果没戴当前房间勋章则为空字符串
|
||||
*/
|
||||
exports.SuperChatMsg = /** @type {SuperChatMsg} */ (undefined)
|
||||
|
||||
return exports
|
||||
}))
|
||||
@@ -9,10 +9,10 @@
|
||||
:isInMemberMessage="false" :authorName="authorName" :authorType="authorType" :privilegeType="privilegeType"
|
||||
></author-chip>
|
||||
<span id="message" class="style-scope yt-live-chat-text-message-renderer">
|
||||
<template v-for="(content, index) in richContent">
|
||||
<span :key="index" v-if="content.type === CONTENT_TYPE_TEXT">{{ content.text }}</span>
|
||||
<template v-for="(content, index) in contentParts">
|
||||
<span :key="index" v-if="content.type === CONTENT_PART_TYPE_TEXT">{{ content.text }}</span>
|
||||
<!-- 如果CSS设置的尺寸比属性设置的尺寸还大,在图片加载完后布局会变化,可能导致滚动卡住,没什么好的解决方法 -->
|
||||
<img :key="index" v-else-if="content.type === CONTENT_TYPE_IMAGE"
|
||||
<img :key="index" v-else-if="content.type === CONTENT_PART_TYPE_IMAGE"
|
||||
class="emoji yt-formatted-string style-scope yt-live-chat-text-message-renderer"
|
||||
:src="content.url" :alt="content.text" :shared-tooltip-text="content.text" :id="`emoji-${content.text}`"
|
||||
:width="content.width" :height="content.height"
|
||||
@@ -48,14 +48,14 @@ export default {
|
||||
time: Date,
|
||||
authorName: String,
|
||||
authorType: Number,
|
||||
richContent: Array,
|
||||
contentParts: Array,
|
||||
privilegeType: Number,
|
||||
repeated: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
CONTENT_TYPE_TEXT: constants.CONTENT_TYPE_TEXT,
|
||||
CONTENT_TYPE_IMAGE: constants.CONTENT_TYPE_IMAGE
|
||||
CONTENT_PART_TYPE_TEXT: constants.CONTENT_PART_TYPE_TEXT,
|
||||
CONTENT_PART_TYPE_IMAGE: constants.CONTENT_PART_TYPE_IMAGE
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -84,12 +84,12 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
yt-live-chat-text-message-renderer>#content>#message>.el-badge {
|
||||
<style scoped>
|
||||
#message >>> .el-badge {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
yt-live-chat-text-message-renderer>#content>#message>.el-badge .el-badge__content {
|
||||
#message >>> .el-badge__content {
|
||||
font-size: 12px !important;
|
||||
line-height: 18px !important;
|
||||
text-shadow: none !important;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<yt-live-chat-ticker-renderer :hidden="showMessages.length === 0">
|
||||
<div id="container" dir="ltr" class="style-scope yt-live-chat-ticker-renderer">
|
||||
<el-scrollbar ref="scrollbar" id="container" dir="ltr" class="style-scope yt-live-chat-ticker-renderer">
|
||||
<transition-group tag="div" :css="false" @enter="onTickerItemEnter" @leave="onTickerItemLeave"
|
||||
id="items" class="style-scope yt-live-chat-ticker-renderer"
|
||||
>
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
</yt-live-chat-ticker-paid-message-item-renderer>
|
||||
</transition-group>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
<template v-if="pinnedMessage">
|
||||
<membership-item :key="pinnedMessage.id" v-if="pinnedMessage.type === MESSAGE_TYPE_MEMBER"
|
||||
class="style-scope yt-live-chat-ticker-renderer"
|
||||
@@ -110,7 +110,11 @@ export default {
|
||||
el.style.width = 0
|
||||
await this.$nextTick()
|
||||
el.style.width = `${width}px`
|
||||
window.setTimeout(done, 200)
|
||||
window.setTimeout(() => {
|
||||
done()
|
||||
// 因为el-scrollbar监听不到事件,这里必须手动调update
|
||||
this.$refs.scrollbar.update()
|
||||
}, 200)
|
||||
},
|
||||
onTickerItemLeave(el, done) {
|
||||
el.classList.add('sliding-down')
|
||||
@@ -122,6 +126,8 @@ export default {
|
||||
el.classList.remove('collapsing')
|
||||
el.style.width = 'auto'
|
||||
done()
|
||||
// 因为el-scrollbar监听不到事件,这里必须手动调update
|
||||
this.$refs.scrollbar.update()
|
||||
}, 200)
|
||||
}, 200)
|
||||
},
|
||||
|
||||
@@ -34,8 +34,8 @@ export const MESSAGE_TYPE_SUPER_CHAT = 3
|
||||
export const MESSAGE_TYPE_DEL = 4
|
||||
export const MESSAGE_TYPE_UPDATE = 5
|
||||
|
||||
export const CONTENT_TYPE_TEXT = 0
|
||||
export const CONTENT_TYPE_IMAGE = 1
|
||||
export const CONTENT_PART_TYPE_TEXT = 0
|
||||
export const CONTENT_PART_TYPE_IMAGE = 1
|
||||
|
||||
// 美元 -> 人民币 汇率
|
||||
const EXCHANGE_RATE = 7
|
||||
@@ -173,15 +173,15 @@ export function getShowContent(message) {
|
||||
return message.content
|
||||
}
|
||||
|
||||
export function getShowRichContent(message) {
|
||||
let richContent = [...message.richContent]
|
||||
export function getShowContentParts(message) {
|
||||
let contentParts = [...message.contentParts]
|
||||
if (message.translation) {
|
||||
richContent.push({
|
||||
type: CONTENT_TYPE_TEXT,
|
||||
contentParts.push({
|
||||
type: CONTENT_PART_TYPE_TEXT,
|
||||
text: `(${message.translation})`
|
||||
})
|
||||
}
|
||||
return richContent
|
||||
return contentParts
|
||||
}
|
||||
|
||||
export function getGiftShowContent(message, showGiftName) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
:authorName="message.authorName"
|
||||
:authorType="message.authorType"
|
||||
:privilegeType="message.privilegeType"
|
||||
:richContent="getShowRichContent(message)"
|
||||
:contentParts="getShowContentParts(message)"
|
||||
:repeated="message.repeated"
|
||||
></text-message>
|
||||
<paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_GIFT"
|
||||
@@ -153,7 +153,7 @@ export default {
|
||||
},
|
||||
getGiftShowNameAndNum: constants.getGiftShowNameAndNum,
|
||||
getShowContent: constants.getShowContent,
|
||||
getShowRichContent: constants.getShowRichContent,
|
||||
getShowContentParts: constants.getShowContentParts,
|
||||
getShowAuthorName: constants.getShowAuthorName,
|
||||
|
||||
addMessage(message) {
|
||||
@@ -192,7 +192,7 @@ export default {
|
||||
}
|
||||
return false
|
||||
},
|
||||
mergeSimilarGift(authorName, price, _freePrice, giftName, num) {
|
||||
mergeSimilarGift(authorName, price, _totalFreeCoin, giftName, num) {
|
||||
for (let message of this.iterRecentMessages(5)) {
|
||||
if (
|
||||
message.type === constants.MESSAGE_TYPE_GIFT
|
||||
@@ -201,7 +201,7 @@ export default {
|
||||
) {
|
||||
this.updateMessage(message.id, { $add: {
|
||||
price: price,
|
||||
// freePrice: freePrice, // 暂时没用到
|
||||
// totalFreeCoin: totalFreeCoin, // 暂时没用到
|
||||
num: num
|
||||
} })
|
||||
return true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Vue from 'vue'
|
||||
import VueI18n from 'vue-i18n'
|
||||
|
||||
import zh from '@/lang/zh'
|
||||
@@ -5,6 +6,10 @@ import zh from '@/lang/zh'
|
||||
let lastSetLocale = 'zh'
|
||||
let loadedLocales = ['zh']
|
||||
|
||||
if (!process.env.LIB_USE_CDN) {
|
||||
Vue.use(VueI18n)
|
||||
}
|
||||
|
||||
export async function setLocale(locale) {
|
||||
lastSetLocale = locale
|
||||
if (loadedLocales.indexOf(locale) === -1) {
|
||||
|
||||
@@ -65,6 +65,25 @@ export default {
|
||||
addEmoticon: 'Add emote',
|
||||
emoticonFileTooLarge: 'File size is too large. Max size is 1MB',
|
||||
|
||||
template: 'Custom HTML Templates',
|
||||
templateHelp: 'Help',
|
||||
templateHelpContent: `\
|
||||
<p>Custom HTML templates allow complete customization of the room page, including DOM structure and CSS styles.
|
||||
Templates may be developed by third-party authors, and their security and quality are the responsibility of the template author.
|
||||
You can find some published templates on <a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E8%87%AA%E5%AE%9A%E4%B9%89html%E6%A8%A1%E6%9D%BF"
|
||||
>GitHub Discussions</a></p>
|
||||
<p>To install a template: Put the extracted template directory into the "data/custom_public/templates" directory, then wait
|
||||
about 10 seconds and refresh the webpage. Alternatively, you can directly enter the template URL to use an online template</p>
|
||||
<p>Note: After modifying the template setting, the custom CSS in OBS will not take effect</p>
|
||||
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89HTML%E6%A8%A1%E6%9D%BF"
|
||||
>Template Development Documentation</a></p>
|
||||
`,
|
||||
templateDefaultTitle: 'Default',
|
||||
templateDefaultDescription: 'A YouTube-style template that can be styled with custom CSS. Choose this if you are unfamiliar with custom HTML templates, otherwise your custom CSS in OBS will not take effect',
|
||||
templateCustomUrlTitle: 'Enter template URL',
|
||||
templateCustomUrlDescription: 'For templates not listed, you can manually enter the URL provided by the author here',
|
||||
author: 'Author: ',
|
||||
|
||||
urlTooLong: 'The room URL is too long, and will be truncated by Livehime (but not by OBS)',
|
||||
roomUrlUpdated: 'The room URL is updated. Remember to copy it again',
|
||||
roomUrl: 'Room URL',
|
||||
@@ -186,12 +205,12 @@ export default {
|
||||
be developed by third-party authors, and the security and quality are the responsibility of the plugin author. You can
|
||||
find some published plugins in <a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E6%8F%92%E4%BB%B6"
|
||||
>GitHub Discussions</a></p>
|
||||
<p>Plugin installation method: Put the extracted plugin directory into the "data/plugins" directory, then restart blivechat</p>
|
||||
<p>To install a plugin: Put the extracted plugin directory into the "data/plugins" directory, then restart blivechat</p>
|
||||
<p>Notes: Most plugins require enabling the "Relay messages by the server" option and connecting to the room
|
||||
in order to receive messages</p>
|
||||
<p><a target="_blank" href="https://www.bilibili.com/video/BV1nZ42187TX/">Introducing video</a></p>
|
||||
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F">
|
||||
Plugin development documentation</a></p>
|
||||
<p><a target="_blank" href="https://www.bilibili.com/video/BV1nZ42187TX/">Introducing Video</a></p>
|
||||
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F"
|
||||
>Plugin Development Documentation</a></p>
|
||||
`,
|
||||
author: 'Author: ',
|
||||
disabledByServer: 'Administration for plugins is disabled by the server',
|
||||
|
||||
@@ -65,6 +65,25 @@ export default {
|
||||
addEmoticon: 'スタンプを追加',
|
||||
emoticonFileTooLarge: 'ファイルサイズが大きすぎます。最大サイズは1MBです',
|
||||
|
||||
template: 'カスタムHTMLテンプレート',
|
||||
templateHelp: 'ヘルプ',
|
||||
templateHelpContent: `\
|
||||
<p>カスタムHTMLテンプレートを使用すると、DOM構造やCSSスタイルを含むフロントエンドページを完全にカスタマイズできます。
|
||||
テンプレートはサードパーティの作者によって開発される場合があり、そのセキュリティと品質はテンプレート作者の責任となります。
|
||||
公開されているテンプレートは<a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E8%87%AA%E5%AE%9A%E4%B9%89html%E6%A8%A1%E6%9D%BF"
|
||||
>GitHub Discussions</a>で入手できます</p>
|
||||
<p>テンプレートのインストール方法:解凍したテンプレートディレクトリを「data/custom_public/templates」フォルダに配置し、
|
||||
約10秒待ってからウェブページを更新してください。また、テンプレートURLを直接入力してオンラインテンプレートを使用することもできます</p>
|
||||
<p>注意:カスタムテンプレートを変更した場合、OBS内のカスタムCSSは反映されません</p>
|
||||
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89HTML%E6%A8%A1%E6%9D%BF"
|
||||
>テンプレート開発ドキュメント</a></p>
|
||||
`,
|
||||
templateDefaultTitle: 'デフォルト',
|
||||
templateDefaultDescription: 'YouTube風のテンプレートで、カスタムCSSでスタイルを変更できます。カスタムHTMLテンプレートの機能に慣れていない場合はこれを選択してください。そうでない場合、OBS内のカスタムCSSは反映されません',
|
||||
templateCustomUrlTitle: 'テンプレートURLを入力',
|
||||
templateCustomUrlDescription: 'リストにないテンプレートは、作者が提供するURLをここで手動で入力できます',
|
||||
author: '作者:',
|
||||
|
||||
urlTooLong: 'ルームのURLが長すぎて、直播姬によって切り詰められます(ただし、OBSでは切り詰められません)',
|
||||
roomUrlUpdated: 'ルームのURLが更新されました。再度コピーすることをお忘れなく',
|
||||
roomUrl: 'ルームのURL',
|
||||
|
||||
@@ -65,6 +65,23 @@ export default {
|
||||
addEmoticon: '添加表情',
|
||||
emoticonFileTooLarge: '文件尺寸太大,最大1MB',
|
||||
|
||||
template: '自定义HTML模板',
|
||||
templateHelp: '帮助',
|
||||
templateHelpContent: `\
|
||||
<p>自定义HTML模板可以完全自定义房间页面,包括DOM结构、CSS样式。模板可能由第三方作者开发,其安全性和质量由模板作者负责。
|
||||
你可以在<a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E8%87%AA%E5%AE%9A%E4%B9%89html%E6%A8%A1%E6%9D%BF"
|
||||
>GitHub Discussions</a>获取一些已发布的模板</p>
|
||||
<p>模板安装方法:把解压后的模板目录放到“data/custom_public/templates”目录,然后等待约10秒后刷新网页。
|
||||
另外你也可以直接输入模板URL来使用在线模板</p>
|
||||
<p>注意:修改自定义模板设置后,OBS中的自定义CSS将不会生效</p>
|
||||
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89HTML%E6%A8%A1%E6%9D%BF">模板开发文档</a></p>
|
||||
`,
|
||||
templateDefaultTitle: '默认',
|
||||
templateDefaultDescription: '仿YouTube风格的模板,可以用自定义CSS修改样式。如果你不了解自定义HTML模板的功能,就选择这个,否则OBS中的自定义CSS不会生效',
|
||||
templateCustomUrlTitle: '输入模板URL',
|
||||
templateCustomUrlDescription: '没有列出的模板,也可以在这里手动输入作者提供的URL',
|
||||
author: '作者:',
|
||||
|
||||
urlTooLong: '房间URL太长了,会被直播姬截断(OBS不会)',
|
||||
roomUrlUpdated: '房间URL已更新,记得重新复制',
|
||||
roomUrl: '房间URL',
|
||||
@@ -79,8 +96,8 @@ export default {
|
||||
legacy: '经典',
|
||||
lineLike: '仿微信',
|
||||
|
||||
light: '明亮',
|
||||
dark: '黑暗',
|
||||
light: '亮色',
|
||||
dark: '暗色',
|
||||
|
||||
outlines: '描边',
|
||||
showOutlines: '显示描边',
|
||||
@@ -150,7 +167,7 @@ export default {
|
||||
fadeOutTime: '淡出时间(毫秒)',
|
||||
slide: '滑动',
|
||||
reverseSlide: '反向滑动',
|
||||
playAnimation: '播放动画',
|
||||
playAnimation: '生成消息',
|
||||
|
||||
result: '结果',
|
||||
copy: '复制',
|
||||
@@ -186,7 +203,7 @@ export default {
|
||||
你可以在<a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E6%8F%92%E4%BB%B6"
|
||||
>GitHub Discussions</a>获取一些已发布的插件</p>
|
||||
<p>插件安装方法:把解压后的插件目录放到“data/plugins”目录,然后重启blivechat</p>
|
||||
<p>注意事项:大部分插件需要开启“通过服务器转发消息”,并且连接到房间,才能接收消息</p>
|
||||
<p>注意:大部分插件需要开启“通过服务器转发消息”,并且连接到房间,才能接收消息</p>
|
||||
<p><a target="_blank" href="https://www.bilibili.com/video/BV1nZ42187TX/">介绍视频</a></p>
|
||||
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F">插件开发文档</a></p>
|
||||
`,
|
||||
|
||||
@@ -83,16 +83,20 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
.el-scrollbar {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.scrollbar-wrapper {
|
||||
.el-scrollbar >>> .scrollbar-wrapper {
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.scrollbar-wrapper .el-menu {
|
||||
.el-scrollbar >>> .el-menu {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.el-scrollbar >>> .is-horizontal {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<sidebar></sidebar>
|
||||
</el-aside>
|
||||
<el-main>
|
||||
<el-button v-show="isMobile" class="menu-button" icon="el-icon-s-unfold" @click="hideSidebar = false"></el-button>
|
||||
<el-button v-show="isMobile" icon="el-icon-s-unfold" @click="hideSidebar = false"></el-button>
|
||||
<keep-alive>
|
||||
<router-view></router-view>
|
||||
</keep-alive>
|
||||
@@ -51,8 +51,8 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.app-wrapper, .sidebar-container {
|
||||
<style scoped>
|
||||
.app-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -67,11 +67,12 @@ export default {
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
height: 100%;
|
||||
background-color: #304156;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-wrapper.mobile .sidebar-container {
|
||||
.mobile .sidebar-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -79,13 +80,13 @@ export default {
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.app-wrapper.mobile .sidebar-container.hide-sidebar {
|
||||
.mobile .sidebar-container.hide-sidebar {
|
||||
pointer-events: none;
|
||||
transition-duration: 0.3s;
|
||||
transform: translate3d(-230px, 0, 0);
|
||||
}
|
||||
|
||||
.sidebar-container .logo-container {
|
||||
.logo-container {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
@@ -93,14 +94,14 @@ export default {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-container .logo-container .sidebar-logo {
|
||||
.sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.sidebar-container .logo-container .sidebar-title {
|
||||
.sidebar-title {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
@@ -111,7 +112,7 @@ export default {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sidebar-container .version {
|
||||
.version {
|
||||
height: 30px;
|
||||
background: #2b2f3a;
|
||||
color: #aaa;
|
||||
@@ -121,8 +122,4 @@ export default {
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-container .is-horizontal {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import Vue from 'vue'
|
||||
import VueRouter from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import ElementUI from 'element-ui'
|
||||
if (!process.env.LIB_USE_CDN) {
|
||||
import('element-ui/lib/theme-chalk/index.css')
|
||||
}
|
||||
|
||||
import * as apiBase from './api/base'
|
||||
import * as i18n from './i18n'
|
||||
import App from './App'
|
||||
import NotFound from './views/NotFound'
|
||||
|
||||
axios.defaults.timeout = 10 * 1000
|
||||
if (!process.env.LIB_USE_CDN) {
|
||||
Vue.use(VueRouter)
|
||||
Vue.use(ElementUI)
|
||||
}
|
||||
|
||||
Vue.config.ignoredElements = [
|
||||
/^yt-/
|
||||
@@ -54,6 +61,8 @@ const router = new VueRouter({
|
||||
]
|
||||
})
|
||||
|
||||
await apiBase.init()
|
||||
|
||||
new Vue({
|
||||
render: h => h(App),
|
||||
router,
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
<div>
|
||||
<h1>{{ $t('help.help') }}</h1>
|
||||
<p>{{ $t('help.p1_1') }} <a href="https://play-live.bilibili.com/" target="_blank">https://play-live.bilibili.com/</a> {{ $t('help.p1_2') }}</p>
|
||||
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-1.png"></el-image></p>
|
||||
<p><el-image lazy src="/static/img/tutorial/tutorial-1.jpg"></el-image></p>
|
||||
<p>{{ $t('help.p2') }}</p>
|
||||
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-2.png"></el-image></p>
|
||||
<p><el-image lazy src="/static/img/tutorial/tutorial-2.jpg"></el-image></p>
|
||||
<p>{{ $t('help.p3') }}</p>
|
||||
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-3.png"></el-image></p>
|
||||
<p><el-image lazy src="/static/img/tutorial/tutorial-3.jpg"></el-image></p>
|
||||
<p>{{ $t('help.p4') }}</p>
|
||||
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-4.png"></el-image></p>
|
||||
<p><el-image lazy src="/static/img/tutorial/tutorial-4.jpg"></el-image></p>
|
||||
<p>{{ $t('help.p5') }}</p>
|
||||
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-5.png"></el-image></p>
|
||||
<p><el-image lazy src="/static/img/tutorial/tutorial-5.jpg"></el-image></p>
|
||||
<p><br><br><br><br><br><br><br><br>--------------------------------------------------------------------------------------------------------</p>
|
||||
<p>使用前必看:<a href="https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98" target="_blank">注意事项和常见问题</a></p>
|
||||
<p>喜欢的话可以推荐给别人 _(:з」∠)_</p>
|
||||
<p>如果需要使用翻译功能,建议看<a href="https://www.bilibili.com/read/cv14663633" target="_blank">配置官方翻译接口傻瓜式教程</a>。注意必须下载到本地才能改后台配置,本地使用方法看<a href="https://github.com/xfgryujk/blivechat#%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95" target="_blank">项目地址中的使用方法说明</a></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,13 +22,3 @@ export default {
|
||||
name: 'Help'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.img-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.img-container.large-img .el-image {
|
||||
height: 80vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
222
frontend/src/views/Home/TemplateSelect.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div>
|
||||
<p>
|
||||
<el-button round icon="el-icon-question" @click="isHelpVisible = true">{{$t('home.templateHelp')}}</el-button>
|
||||
<el-dialog :title="$t('home.templateHelp')" :visible.sync="isHelpVisible">
|
||||
<div style="word-break: initial;" v-html="$t('home.templateHelpContent')"></div>
|
||||
</el-dialog>
|
||||
</p>
|
||||
|
||||
<el-radio-group :value="selectedIndex" @input="onSelect">
|
||||
<el-radio :label="INDEX_DEFAULT" border>
|
||||
<div class="card-body">
|
||||
<div class="title-line">
|
||||
<h3 class="name">{{ $t('home.templateDefaultTitle') }}</h3>
|
||||
</div>
|
||||
<div class="description-line">
|
||||
<div class="description">
|
||||
<p>{{ $t('home.templateDefaultDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-radio>
|
||||
|
||||
<el-radio :label="INDEX_CUSTOM" border>
|
||||
<div class="card-body">
|
||||
<div class="title-line">
|
||||
<h3 class="name">{{ $t('home.templateCustomUrlTitle') }}</h3>
|
||||
</div>
|
||||
<div class="description-line">
|
||||
<div class="description">
|
||||
<p>{{ $t('home.templateCustomUrlDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<el-input v-model="customUrl" placeholder="https://example.com/path/to/you/template/index.html"></el-input>
|
||||
</div>
|
||||
</div>
|
||||
</el-radio>
|
||||
|
||||
<el-radio v-for="(template, index) in templates" :key="template.id" :label="index" border>
|
||||
<div class="card-body">
|
||||
<div class="title-line">
|
||||
<h3 class="name">{{ template.name }}</h3>
|
||||
<span class="version">{{ template.version }}</span>
|
||||
<span class="author push-inline-end">{{ $t('home.author') }}{{ template.author }}</span>
|
||||
</div>
|
||||
<div class="description-line">
|
||||
<el-image v-if="template.thumbnail !== ''" class="thumbnail" lazy fit="cover" :src="template.thumbnail"></el-image>
|
||||
<div class="description">
|
||||
<p v-if="template.description !== ''">{{ template.description }}</p>
|
||||
<p>URL: {{ template.url }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as mainApi from '@/api/main'
|
||||
|
||||
const INDEX_DEFAULT = 'default'
|
||||
const INDEX_CUSTOM = 'custom'
|
||||
|
||||
export default {
|
||||
name: 'TemplateSelect',
|
||||
props: {
|
||||
value: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
INDEX_DEFAULT,
|
||||
INDEX_CUSTOM,
|
||||
|
||||
isHelpVisible: false,
|
||||
templates: [],
|
||||
customUrl: '',
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedIndex() {
|
||||
if (this.value == '') {
|
||||
return INDEX_DEFAULT
|
||||
}
|
||||
let index = this.templates.findIndex(template => template.url === this.value)
|
||||
if (index !== -1) {
|
||||
return index
|
||||
}
|
||||
return INDEX_CUSTOM
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(val) {
|
||||
if (this.selectedIndex === INDEX_CUSTOM) {
|
||||
this.customUrl = val
|
||||
}
|
||||
}
|
||||
},
|
||||
customUrl(val) {
|
||||
if (this.selectedIndex === INDEX_CUSTOM) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.updateTemplates()
|
||||
},
|
||||
methods: {
|
||||
async updateTemplates() {
|
||||
try {
|
||||
this.templates = (await mainApi.getTemplates()).templates
|
||||
} catch (e) {
|
||||
this.$message.error(`Failed to fetch templates: ${e}`)
|
||||
throw e
|
||||
} finally {
|
||||
if (this.selectedIndex !== INDEX_CUSTOM) {
|
||||
this.customUrl = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelect(selectedIndex) {
|
||||
let url
|
||||
if (selectedIndex === INDEX_DEFAULT) {
|
||||
url = ''
|
||||
} else if (selectedIndex === INDEX_CUSTOM) {
|
||||
url = this.customUrl
|
||||
} else {
|
||||
url = this.templates[selectedIndex].url
|
||||
}
|
||||
this.$emit('input', url)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-radio-group {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.el-radio {
|
||||
display: block;
|
||||
height: unset;
|
||||
margin: 0 0 16px 0 !important;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.el-radio >>> .el-radio__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-radio >>> .el-radio__label {
|
||||
padding-left: unset;
|
||||
}
|
||||
|
||||
.push-inline-end {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.title-line {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: baseline;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin-block: 0 0;
|
||||
margin-inline-end: 8px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.version {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.author {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.description-line {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
gap: 1em;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
flex: none;
|
||||
width: 190px;
|
||||
height: 105px;
|
||||
}
|
||||
|
||||
.description {
|
||||
flex: auto;
|
||||
overflow-y: auto;
|
||||
text-wrap: wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.description-line {
|
||||
flex-wrap: wrap;
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -217,6 +217,10 @@
|
||||
<el-button type="primary" icon="el-icon-plus" @click="addEmoticon">{{$t('home.addEmoticon')}}</el-button>
|
||||
</p>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="$t('home.template')" lazy>
|
||||
<template-select v-model="form.templateUrl"></template-select>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-form>
|
||||
</p>
|
||||
@@ -249,12 +253,14 @@
|
||||
import _ from 'lodash'
|
||||
import download from 'downloadjs'
|
||||
|
||||
import TemplateSelect from './TemplateSelect'
|
||||
import { mergeConfig } from '@/utils'
|
||||
import * as mainApi from '@/api/main'
|
||||
import * as chatConfig from '@/api/chatConfig'
|
||||
|
||||
export default {
|
||||
name: 'Home',
|
||||
components: { TemplateSelect },
|
||||
data() {
|
||||
return {
|
||||
serverConfig: {
|
||||
@@ -384,7 +390,8 @@ export default {
|
||||
getUnvalidatedRoomUrl(isTestRoom) {
|
||||
// 重要的字段放在前面,因为如果被截断就连接不了房间了
|
||||
let frontFields = {
|
||||
roomKeyType: this.form.roomKeyType
|
||||
roomKeyType: this.form.roomKeyType,
|
||||
templateUrl: this.form.templateUrl,
|
||||
}
|
||||
let backFields = {
|
||||
lang: this.$i18n.locale,
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<chat-renderer ref="renderer" :maxNumber="config.maxNumber" :showGiftName="config.showGiftName"></chat-renderer>
|
||||
<chat-renderer v-if="!useCustomTemplate" ref="renderer" :maxNumber="config.maxNumber" :showGiftName="config.showGiftName"></chat-renderer>
|
||||
<div v-else class="template-container">
|
||||
<iframe ref="templateIframe" :src="config.templateUrl" class="template-iframe" frameborder="0"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -12,6 +15,101 @@ import * as chat from '@/api/chat'
|
||||
import * as chatModels from '@/api/chat/models'
|
||||
import ChatRenderer from '@/components/ChatRenderer'
|
||||
import * as constants from '@/components/ChatRenderer/constants'
|
||||
/** @import * as blcsdk from '@/blcsdk' */
|
||||
|
||||
class DefaultRenderer {
|
||||
constructor(rendererVm) {
|
||||
this.addMessage = rendererVm.addMessage
|
||||
this.delMessages = rendererVm.delMessages
|
||||
this.updateMessage = rendererVm.updateMessage
|
||||
this.mergeSimilarText = rendererVm.mergeSimilarText
|
||||
this.mergeSimilarGift = rendererVm.mergeSimilarGift
|
||||
}
|
||||
|
||||
destroy() {
|
||||
let dummyFunc = () => {}
|
||||
this.addMessage = dummyFunc
|
||||
this.delMessages = dummyFunc
|
||||
this.updateMessage = dummyFunc
|
||||
this.mergeSimilarText = dummyFunc
|
||||
this.mergeSimilarGift = dummyFunc
|
||||
}
|
||||
}
|
||||
|
||||
const BLC_SDK_VERSION = '1.0.0'
|
||||
|
||||
class CustomTemplateRenderer {
|
||||
constructor(templateIframe, config) {
|
||||
this._templateIframe = templateIframe
|
||||
this._config = config
|
||||
|
||||
this._enabledSendMessageToTemplate = (type, data) => {
|
||||
let msg = { type, data }
|
||||
templateIframe.contentWindow.postMessage(msg, '*')
|
||||
}
|
||||
this._sendMessageToTemplate = () => {}
|
||||
|
||||
this._boundOnWindowMessage = this._onWindowMessage.bind(this)
|
||||
window.addEventListener('message', this._boundOnWindowMessage)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener('message', this._boundOnWindowMessage)
|
||||
|
||||
let dummyFunc = () => {}
|
||||
this._enabledSendMessageToTemplate = dummyFunc
|
||||
this._sendMessageToTemplate = dummyFunc
|
||||
}
|
||||
|
||||
addMessage(message) {
|
||||
this._sendMessageToTemplate('blcAddMsg', message)
|
||||
}
|
||||
|
||||
delMessages(ids) {
|
||||
let data = { ids }
|
||||
this._sendMessageToTemplate('blcDelMsgs', data)
|
||||
}
|
||||
|
||||
updateMessage(id, newValuesObj) {
|
||||
let data = { id, newValuesObj }
|
||||
this._sendMessageToTemplate('blcUpdateMsg', data)
|
||||
}
|
||||
|
||||
mergeSimilarText() {
|
||||
return false
|
||||
}
|
||||
|
||||
mergeSimilarGift() {
|
||||
return false
|
||||
}
|
||||
|
||||
_onWindowMessage(event) {
|
||||
if (event.source !== this._templateIframe.contentWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
let { type } = event.data
|
||||
switch (type) {
|
||||
case 'blcTemplateConnect': {
|
||||
this._sendMessageToTemplate = this._enabledSendMessageToTemplate
|
||||
|
||||
// 发送初始化消息
|
||||
let initData = {
|
||||
blcVersion: process.env.APP_VERSION,
|
||||
sdkVersion: BLC_SDK_VERSION,
|
||||
config: {
|
||||
showGiftName: this._config.showGiftName,
|
||||
mergeSimilarDanmaku: this._config.mergeSimilarDanmaku,
|
||||
mergeGift: this._config.mergeGift,
|
||||
maxNumber: this._config.maxNumber,
|
||||
}
|
||||
}
|
||||
this._sendMessageToTemplate('blcInit', initData)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Room',
|
||||
@@ -42,8 +140,10 @@ export default {
|
||||
textEmoticons: [], // 官方的文本表情(后端配置的)
|
||||
pronunciationConverter: null,
|
||||
|
||||
customStyleElement, // 仅用于样式生成器中预览样式
|
||||
customStyleElement, // 仅用于样式生成器中预览样式和使用自定义模板时
|
||||
presetCssLinkElement: null,
|
||||
|
||||
renderer: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -72,9 +172,26 @@ export default {
|
||||
}
|
||||
}
|
||||
return res
|
||||
},
|
||||
useCustomTemplate() {
|
||||
return this.config.templateUrl !== ''
|
||||
},
|
||||
},
|
||||
beforeMount() {
|
||||
this.initConfig()
|
||||
|
||||
// 主框架改成透明背景,防止影响到模板
|
||||
if (this.useCustomTemplate) {
|
||||
this.customStyleElement.textContent = 'body { background-color: transparent; }'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.useCustomTemplate) {
|
||||
this.renderer = new CustomTemplateRenderer(this.$refs.templateIframe, this.config)
|
||||
} else {
|
||||
this.renderer = new DefaultRenderer(this.$refs.renderer)
|
||||
}
|
||||
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (this.roomKeyValue === null) {
|
||||
this.init()
|
||||
@@ -101,6 +218,10 @@ export default {
|
||||
if (this.presetCssLinkElement) {
|
||||
document.head.removeChild(this.presetCssLinkElement)
|
||||
}
|
||||
|
||||
if (this.renderer) {
|
||||
this.renderer.destroy()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onVisibilityChange() {
|
||||
@@ -111,8 +232,6 @@ export default {
|
||||
this.init()
|
||||
},
|
||||
async init() {
|
||||
this.initConfig()
|
||||
|
||||
let initChatClientPromise = this.initChatClient()
|
||||
this.initTextEmoticons()
|
||||
if (this.config.giftUsernamePronunciation !== '') {
|
||||
@@ -142,6 +261,8 @@ export default {
|
||||
message: 'Loaded',
|
||||
duration: 500
|
||||
})
|
||||
|
||||
this.sendMessageToStylegen('stylegenExampleRoomLoad')
|
||||
},
|
||||
initConfig() {
|
||||
let locale = this.strConfig.lang
|
||||
@@ -220,8 +341,18 @@ export default {
|
||||
this.textEmoticons = await chat.getTextEmoticons()
|
||||
},
|
||||
|
||||
sendMessageToStylegen(type, data = null) {
|
||||
if (window.parent === window) {
|
||||
return
|
||||
}
|
||||
let msg = { type, data }
|
||||
window.parent.postMessage(msg, window.location.origin)
|
||||
},
|
||||
// 处理样式生成器发送的消息
|
||||
onWindowMessage(event) {
|
||||
if (event.source !== window.parent) {
|
||||
return
|
||||
}
|
||||
if (event.origin !== window.location.origin) {
|
||||
console.warn(`消息origin错误,${event.origin} != ${window.location.origin}`)
|
||||
return
|
||||
@@ -230,7 +361,7 @@ export default {
|
||||
let { type, data } = event.data
|
||||
switch (type) {
|
||||
case 'roomSetCustomStyle':
|
||||
this.customStyleElement.innerText = data.css
|
||||
this.customStyleElement.textContent = data.css
|
||||
break
|
||||
case 'roomStartClient':
|
||||
if (this.chatClient) {
|
||||
@@ -250,11 +381,12 @@ export default {
|
||||
if (!this.config.showDanmaku || !this.filterTextMessage(data)) {
|
||||
return
|
||||
}
|
||||
let richContent = await this.getRichContent(data)
|
||||
let contentParts = await this.parseContentParts(data)
|
||||
// 合并要放在异步调用后面,因为异步调用后可能有新的消息,会漏合并
|
||||
if (this.mergeSimilarText(data.content)) {
|
||||
return
|
||||
}
|
||||
/** @type {typeof blcsdk.TextMsg} */
|
||||
let message = {
|
||||
id: data.id,
|
||||
type: constants.MESSAGE_TYPE_TEXT,
|
||||
@@ -263,12 +395,16 @@ export default {
|
||||
authorName: data.authorName,
|
||||
authorType: data.authorType,
|
||||
content: data.content,
|
||||
richContent: richContent,
|
||||
contentParts: contentParts,
|
||||
privilegeType: data.privilegeType,
|
||||
repeated: 1,
|
||||
translation: data.translation
|
||||
translation: this.config.autoTranslate ? data.translation : '',
|
||||
// 给模板用的字段
|
||||
uid: data.uid,
|
||||
medalLevel: data.medalLevel,
|
||||
medalName: data.medalName,
|
||||
}
|
||||
this.$refs.renderer.addMessage(message)
|
||||
this.renderer.addMessage(message)
|
||||
},
|
||||
/** @param {chatModels.AddGiftMsg} data */
|
||||
onAddGift(data) {
|
||||
@@ -282,6 +418,7 @@ export default {
|
||||
if (price < this.config.minGiftPrice) { // 丢人
|
||||
return
|
||||
}
|
||||
/** @type {typeof blcsdk.GiftMsg} */
|
||||
let message = {
|
||||
id: data.id,
|
||||
type: constants.MESSAGE_TYPE_GIFT,
|
||||
@@ -290,17 +427,25 @@ export default {
|
||||
authorName: data.authorName,
|
||||
authorNamePronunciation: this.getPronunciation(data.authorName),
|
||||
price: price,
|
||||
// freePrice: data.totalFreeCoin, // 暂时没用到
|
||||
giftName: data.giftName,
|
||||
num: data.num
|
||||
num: data.num,
|
||||
// 给模板用的字段
|
||||
totalFreeCoin: data.totalFreeCoin,
|
||||
giftId: data.giftId,
|
||||
giftIconUrl: data.giftIconUrl,
|
||||
uid: data.uid,
|
||||
privilegeType: data.privilegeType,
|
||||
medalLevel: data.medalLevel,
|
||||
medalName: data.medalName,
|
||||
}
|
||||
this.$refs.renderer.addMessage(message)
|
||||
this.renderer.addMessage(message)
|
||||
},
|
||||
/** @param {chatModels.AddMemberMsg} data */
|
||||
onAddMember(data) {
|
||||
if (!this.config.showGift || !this.filterNewMemberMessage(data)) {
|
||||
return
|
||||
}
|
||||
/** @type {typeof blcsdk.MemberMsg} */
|
||||
let message = {
|
||||
id: data.id,
|
||||
type: constants.MESSAGE_TYPE_MEMBER,
|
||||
@@ -309,9 +454,16 @@ export default {
|
||||
authorName: data.authorName,
|
||||
authorNamePronunciation: this.getPronunciation(data.authorName),
|
||||
privilegeType: data.privilegeType,
|
||||
title: this.$t('chat.membershipTitle')
|
||||
title: this.$t('chat.membershipTitle'),
|
||||
// 给模板用的字段
|
||||
num: data.num,
|
||||
unit: data.unit,
|
||||
price: data.totalCoin / 1000,
|
||||
uid: data.uid,
|
||||
medalLevel: data.medalLevel,
|
||||
medalName: data.medalName,
|
||||
}
|
||||
this.$refs.renderer.addMessage(message)
|
||||
this.renderer.addMessage(message)
|
||||
},
|
||||
/** @param {chatModels.AddSuperChatMsg} data */
|
||||
onAddSuperChat(data) {
|
||||
@@ -321,6 +473,7 @@ export default {
|
||||
if (data.price < this.config.minGiftPrice) { // 丢人
|
||||
return
|
||||
}
|
||||
/** @type {typeof blcsdk.SuperChatMsg} */
|
||||
let message = {
|
||||
id: data.id,
|
||||
type: constants.MESSAGE_TYPE_SUPER_CHAT,
|
||||
@@ -330,20 +483,25 @@ export default {
|
||||
price: data.price,
|
||||
time: new Date(data.timestamp * 1000),
|
||||
content: data.content.trim(),
|
||||
translation: data.translation
|
||||
translation: this.config.autoTranslate ? data.translation : '',
|
||||
// 给模板用的字段
|
||||
uid: data.uid,
|
||||
privilegeType: data.privilegeType,
|
||||
medalLevel: data.medalLevel,
|
||||
medalName: data.medalName,
|
||||
}
|
||||
this.$refs.renderer.addMessage(message)
|
||||
this.renderer.addMessage(message)
|
||||
},
|
||||
/** @param {chatModels.DelSuperChatMsg} data */
|
||||
onDelSuperChat(data) {
|
||||
this.$refs.renderer.delMessages(data.ids)
|
||||
this.renderer.delMessages(data.ids)
|
||||
},
|
||||
/** @param {chatModels.UpdateTranslationMsg} data */
|
||||
onUpdateTranslation(data) {
|
||||
if (!this.config.autoTranslate) {
|
||||
return
|
||||
}
|
||||
this.$refs.renderer.updateMessage(data.id, { translation: data.translation })
|
||||
this.renderer.updateMessage(data.id, { translation: data.translation })
|
||||
},
|
||||
/** @param {chatModels.ChatClientFatalError} error */
|
||||
onFatalError(error) {
|
||||
@@ -413,13 +571,13 @@ export default {
|
||||
if (!this.config.mergeSimilarDanmaku) {
|
||||
return false
|
||||
}
|
||||
return this.$refs.renderer.mergeSimilarText(content)
|
||||
return this.renderer.mergeSimilarText(content)
|
||||
},
|
||||
mergeSimilarGift(authorName, price, freePrice, giftName, num) {
|
||||
if (!this.config.mergeGift) {
|
||||
return false
|
||||
}
|
||||
return this.$refs.renderer.mergeSimilarGift(authorName, price, freePrice, giftName, num)
|
||||
return this.renderer.mergeSimilarGift(authorName, price, freePrice, giftName, num)
|
||||
},
|
||||
getPronunciation(text) {
|
||||
if (this.pronunciationConverter === null) {
|
||||
@@ -427,29 +585,29 @@ export default {
|
||||
}
|
||||
return this.pronunciationConverter.getPronunciation(text)
|
||||
},
|
||||
async getRichContent(data) {
|
||||
let richContent = []
|
||||
async parseContentParts(data) {
|
||||
let contentParts = []
|
||||
|
||||
// 官方的非文本表情
|
||||
if (data.emoticon !== null) {
|
||||
richContent.push({
|
||||
type: constants.CONTENT_TYPE_IMAGE,
|
||||
contentParts.push({
|
||||
type: constants.CONTENT_PART_TYPE_IMAGE,
|
||||
text: data.content,
|
||||
url: data.emoticon,
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
await this.fillImageContentSizes(richContent)
|
||||
return richContent
|
||||
await this.fillImageContentSizes(contentParts)
|
||||
return contentParts
|
||||
}
|
||||
|
||||
// 没有文本表情,只能是纯文本
|
||||
if (this.config.emoticons.length === 0 && this.textEmoticons.length === 0) {
|
||||
richContent.push({
|
||||
type: constants.CONTENT_TYPE_TEXT,
|
||||
contentParts.push({
|
||||
type: constants.CONTENT_PART_TYPE_TEXT,
|
||||
text: data.content
|
||||
})
|
||||
return richContent
|
||||
return contentParts
|
||||
}
|
||||
|
||||
// 可能含有文本表情,需要解析
|
||||
@@ -466,15 +624,15 @@ export default {
|
||||
|
||||
// 加入之前的文本
|
||||
if (pos !== startPos) {
|
||||
richContent.push({
|
||||
type: constants.CONTENT_TYPE_TEXT,
|
||||
contentParts.push({
|
||||
type: constants.CONTENT_PART_TYPE_TEXT,
|
||||
text: data.content.slice(startPos, pos)
|
||||
})
|
||||
}
|
||||
|
||||
// 加入表情
|
||||
richContent.push({
|
||||
type: constants.CONTENT_TYPE_IMAGE,
|
||||
contentParts.push({
|
||||
type: constants.CONTENT_PART_TYPE_IMAGE,
|
||||
text: matchEmoticon.keyword,
|
||||
url: matchEmoticon.url,
|
||||
width: 0,
|
||||
@@ -485,19 +643,19 @@ export default {
|
||||
}
|
||||
// 加入尾部的文本
|
||||
if (pos !== startPos) {
|
||||
richContent.push({
|
||||
type: constants.CONTENT_TYPE_TEXT,
|
||||
contentParts.push({
|
||||
type: constants.CONTENT_PART_TYPE_TEXT,
|
||||
text: data.content.slice(startPos, pos)
|
||||
})
|
||||
}
|
||||
|
||||
await this.fillImageContentSizes(richContent)
|
||||
return richContent
|
||||
await this.fillImageContentSizes(contentParts)
|
||||
return contentParts
|
||||
},
|
||||
async fillImageContentSizes(richContent) {
|
||||
async fillImageContentSizes(contentParts) {
|
||||
let urlSizeMap = new Map()
|
||||
for (let content of richContent) {
|
||||
if (content.type === constants.CONTENT_TYPE_IMAGE) {
|
||||
for (let content of contentParts) {
|
||||
if (content.type === constants.CONTENT_PART_TYPE_IMAGE) {
|
||||
urlSizeMap.set(content.url, { width: 0, height: 0 })
|
||||
}
|
||||
}
|
||||
@@ -527,8 +685,8 @@ export default {
|
||||
}
|
||||
await Promise.all(promises)
|
||||
|
||||
for (let content of richContent) {
|
||||
if (content.type === constants.CONTENT_TYPE_IMAGE) {
|
||||
for (let content of contentParts) {
|
||||
if (content.type === constants.CONTENT_PART_TYPE_IMAGE) {
|
||||
let size = urlSizeMap.get(content.url)
|
||||
content.width = size.width
|
||||
content.height = size.height
|
||||
@@ -538,3 +696,16 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.template-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,360 +1,378 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<el-form label-width="150px" size="mini">
|
||||
<h3>{{ $t('stylegen.outlines') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showOutlines')">
|
||||
<el-switch v-model="form.showOutlines"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.outlineColor')">
|
||||
<el-color-picker v-model="form.outlineColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item :label="$t('stylegen.outlineSize')">
|
||||
<el-input v-model.number="form.outlineSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
<el-collapse>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.outlines') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showOutlines')">
|
||||
<el-switch v-model="form.showOutlines"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.outlineColor')">
|
||||
<el-color-picker v-model="form.outlineColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item :label="$t('stylegen.outlineSize')">
|
||||
<el-input v-model.number="form.outlineSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.avatars') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showAvatars')">
|
||||
<el-switch v-model="form.showAvatars"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.avatarSize')">
|
||||
<el-input v-model.number="form.avatarSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.avatars') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showAvatars')">
|
||||
<el-switch v-model="form.showAvatars"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.avatarSize')">
|
||||
<el-input v-model.number="form.avatarSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.userNames') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showUserNames')">
|
||||
<el-switch v-model="form.showUserNames"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.font')">
|
||||
<font-select v-model="form.userNameFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fontSize')">
|
||||
<el-input v-model.number="form.userNameFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.lineHeight')">
|
||||
<el-input v-model.number="form.userNameLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.normalColor')">
|
||||
<el-color-picker v-model="form.userNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.memberColor')">
|
||||
<el-color-picker v-model="form.memberUserNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.moderatorColor')">
|
||||
<el-color-picker v-model="form.moderatorUserNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.ownerColor')">
|
||||
<el-color-picker v-model="form.ownerUserNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showBadges')">
|
||||
<el-switch v-model="form.showBadges"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showColon')">
|
||||
<el-switch v-model="form.showColon"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.userNames') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showUserNames')">
|
||||
<el-switch v-model="form.showUserNames"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.font')">
|
||||
<font-select v-model="form.userNameFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fontSize')">
|
||||
<el-input v-model.number="form.userNameFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.lineHeight')">
|
||||
<el-input v-model.number="form.userNameLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.normalColor')">
|
||||
<el-color-picker v-model="form.userNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.memberColor')">
|
||||
<el-color-picker v-model="form.memberUserNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.moderatorColor')">
|
||||
<el-color-picker v-model="form.moderatorUserNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.ownerColor')">
|
||||
<el-color-picker v-model="form.ownerUserNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showBadges')">
|
||||
<el-switch v-model="form.showBadges"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showColon')">
|
||||
<el-switch v-model="form.showColon"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.messages') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.font')">
|
||||
<font-select v-model="form.messageFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.color')">
|
||||
<el-color-picker v-model="form.messageColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fontSize')">
|
||||
<el-input v-model.number="form.messageFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.lineHeight')">
|
||||
<el-input v-model.number="form.messageLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item :label="$t('stylegen.onNewLine')">
|
||||
<el-switch v-model="form.messageOnNewLine"></el-switch>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.emoticonSize')">
|
||||
<el-input v-model.number="form.emoticonSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.largeEmoticonSize')">
|
||||
<el-input v-model.number="form.largeEmoticonSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.messages') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.font')">
|
||||
<font-select v-model="form.messageFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.color')">
|
||||
<el-color-picker v-model="form.messageColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fontSize')">
|
||||
<el-input v-model.number="form.messageFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.lineHeight')">
|
||||
<el-input v-model.number="form.messageLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item :label="$t('stylegen.onNewLine')">
|
||||
<el-switch v-model="form.messageOnNewLine"></el-switch>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.emoticonSize')">
|
||||
<el-input v-model.number="form.emoticonSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.largeEmoticonSize')">
|
||||
<el-input v-model.number="form.largeEmoticonSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.time') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-form-item :label="$t('stylegen.showTime')">
|
||||
<el-switch v-model="form.showTime"></el-switch>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.font')">
|
||||
<font-select v-model="form.timeFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.color')">
|
||||
<el-color-picker v-model="form.timeColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fontSize')">
|
||||
<el-input v-model.number="form.timeFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.lineHeight')">
|
||||
<el-input v-model.number="form.timeLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.time') }}</h3>
|
||||
</template>
|
||||
<el-form-item :label="$t('stylegen.showTime')">
|
||||
<el-switch v-model="form.showTime"></el-switch>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.font')">
|
||||
<font-select v-model="form.timeFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.color')">
|
||||
<el-color-picker v-model="form.timeColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fontSize')">
|
||||
<el-input v-model.number="form.timeFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.lineHeight')">
|
||||
<el-input v-model.number="form.timeLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.backgrounds') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.bgColor')">
|
||||
<el-color-picker v-model="form.bgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.useBarsInsteadOfBg')">
|
||||
<el-switch v-model="form.useBarsInsteadOfBg"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.messageBgColor')">
|
||||
<el-color-picker v-model="form.messageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.memberMessageBgColor')">
|
||||
<el-color-picker v-model="form.memberMessageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.moderatorMessageBgColor')">
|
||||
<el-color-picker v-model="form.moderatorMessageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.ownerMessageBgColor')">
|
||||
<el-color-picker v-model="form.ownerMessageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.backgrounds') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.bgColor')">
|
||||
<el-color-picker v-model="form.bgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.useBarsInsteadOfBg')">
|
||||
<el-switch v-model="form.useBarsInsteadOfBg"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.messageBgColor')">
|
||||
<el-color-picker v-model="form.messageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.memberMessageBgColor')">
|
||||
<el-color-picker v-model="form.memberMessageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.moderatorMessageBgColor')">
|
||||
<el-color-picker v-model="form.moderatorMessageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.ownerMessageBgColor')">
|
||||
<el-color-picker v-model="form.ownerMessageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.scAndNewMember') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineFont')">
|
||||
<font-select v-model="form.firstLineFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineColor')">
|
||||
<el-color-picker v-model="form.firstLineColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineFontSize')">
|
||||
<el-input v-model.number="form.firstLineFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineLineHeight')">
|
||||
<el-input v-model.number="form.firstLineLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider></el-divider>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.scAndNewMember') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineFont')">
|
||||
<font-select v-model="form.firstLineFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineColor')">
|
||||
<el-color-picker v-model="form.firstLineColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineFontSize')">
|
||||
<el-input v-model.number="form.firstLineFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineLineHeight')">
|
||||
<el-input v-model.number="form.firstLineLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider></el-divider>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineFont')">
|
||||
<font-select v-model="form.secondLineFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineColor')">
|
||||
<el-color-picker v-model="form.secondLineColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineFontSize')">
|
||||
<el-input v-model.number="form.secondLineFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineLineHeight')">
|
||||
<el-input v-model.number="form.secondLineLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider></el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineFont')">
|
||||
<font-select v-model="form.secondLineFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineColor')">
|
||||
<el-color-picker v-model="form.secondLineColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineFontSize')">
|
||||
<el-input v-model.number="form.secondLineFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineLineHeight')">
|
||||
<el-input v-model.number="form.secondLineLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider></el-divider>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineFont')">
|
||||
<font-select v-model="form.scContentFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineColor')">
|
||||
<el-color-picker v-model="form.scContentColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineFontSize')">
|
||||
<el-input v-model.number="form.scContentFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineLineHeight')">
|
||||
<el-input v-model.number="form.scContentLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider></el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineFont')">
|
||||
<font-select v-model="form.scContentFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineColor')">
|
||||
<el-color-picker v-model="form.scContentColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineFontSize')">
|
||||
<el-input v-model.number="form.scContentFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineLineHeight')">
|
||||
<el-input v-model.number="form.scContentLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider></el-divider>
|
||||
|
||||
<el-form-item :label="$t('stylegen.showNewMemberBg')">
|
||||
<el-switch v-model="form.showNewMemberBg"></el-switch>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showScTicker')">
|
||||
<el-switch v-model="form.showScTicker"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showOtherThings')">
|
||||
<el-switch v-model="form.showOtherThings"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-form-item :label="$t('stylegen.showNewMemberBg')">
|
||||
<el-switch v-model="form.showNewMemberBg"></el-switch>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showScTicker')">
|
||||
<el-switch v-model="form.showScTicker"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showOtherThings')">
|
||||
<el-switch v-model="form.showOtherThings"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.animation') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.animateIn')">
|
||||
<el-switch v-model="form.animateIn"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fadeInTime')">
|
||||
<el-input v-model.number="form.fadeInTime" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.animateOut')">
|
||||
<el-switch v-model="form.animateOut"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fadeOutTime')">
|
||||
<el-input v-model.number="form.fadeOutTime" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item :label="$t('stylegen.animateOutWaitTime')">
|
||||
<el-input v-model.number="form.animateOutWaitTime" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.slide')">
|
||||
<el-switch v-model="form.slide"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.reverseSlide')">
|
||||
<el-switch v-model="form.reverseSlide"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.animation') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.animateIn')">
|
||||
<el-switch v-model="form.animateIn"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fadeInTime')">
|
||||
<el-input v-model.number="form.fadeInTime" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.animateOut')">
|
||||
<el-switch v-model="form.animateOut"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fadeOutTime')">
|
||||
<el-input v-model.number="form.fadeOutTime" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item :label="$t('stylegen.animateOutWaitTime')">
|
||||
<el-input v-model.number="form.animateOutWaitTime" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.slide')">
|
||||
<el-switch v-model="form.slide"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.reverseSlide')">
|
||||
<el-switch v-model="form.reverseSlide"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,315 +1,331 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<el-form label-width="150px" size="mini">
|
||||
<h3>{{ $t('stylegen.avatars') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showAvatars')">
|
||||
<el-switch v-model="form.showAvatars"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.avatarSize')">
|
||||
<el-input v-model.number="form.avatarSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-collapse>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.avatars') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showAvatars')">
|
||||
<el-switch v-model="form.showAvatars"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.avatarSize')">
|
||||
<el-input v-model.number="form.avatarSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.userNames') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showUserNames')">
|
||||
<el-switch v-model="form.showUserNames"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.font')">
|
||||
<font-select v-model="form.userNameFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fontSize')">
|
||||
<el-input v-model.number="form.userNameFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.lineHeight')">
|
||||
<el-input v-model.number="form.userNameLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.normalColor')">
|
||||
<el-color-picker v-model="form.userNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.memberColor')">
|
||||
<el-color-picker v-model="form.memberUserNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.moderatorColor')">
|
||||
<el-color-picker v-model="form.moderatorUserNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.ownerColor')">
|
||||
<el-color-picker v-model="form.ownerUserNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showBadges')">
|
||||
<el-switch v-model="form.showBadges"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.userNames') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showUserNames')">
|
||||
<el-switch v-model="form.showUserNames"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.font')">
|
||||
<font-select v-model="form.userNameFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fontSize')">
|
||||
<el-input v-model.number="form.userNameFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.lineHeight')">
|
||||
<el-input v-model.number="form.userNameLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.normalColor')">
|
||||
<el-color-picker v-model="form.userNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.memberColor')">
|
||||
<el-color-picker v-model="form.memberUserNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.moderatorColor')">
|
||||
<el-color-picker v-model="form.moderatorUserNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.ownerColor')">
|
||||
<el-color-picker v-model="form.ownerUserNameColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showBadges')">
|
||||
<el-switch v-model="form.showBadges"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.messages') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.font')">
|
||||
<font-select v-model="form.messageFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.color')">
|
||||
<el-color-picker v-model="form.messageColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fontSize')">
|
||||
<el-input v-model.number="form.messageFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.lineHeight')">
|
||||
<el-input v-model.number="form.messageLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.emoticonSize')">
|
||||
<el-input v-model.number="form.emoticonSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.largeEmoticonSize')">
|
||||
<el-input v-model.number="form.largeEmoticonSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.messages') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.font')">
|
||||
<font-select v-model="form.messageFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.color')">
|
||||
<el-color-picker v-model="form.messageColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fontSize')">
|
||||
<el-input v-model.number="form.messageFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.lineHeight')">
|
||||
<el-input v-model.number="form.messageLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.emoticonSize')">
|
||||
<el-input v-model.number="form.emoticonSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.largeEmoticonSize')">
|
||||
<el-input v-model.number="form.largeEmoticonSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.time') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-form-item :label="$t('stylegen.showTime')">
|
||||
<el-switch v-model="form.showTime"></el-switch>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.font')">
|
||||
<font-select v-model="form.timeFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.color')">
|
||||
<el-color-picker v-model="form.timeColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fontSize')">
|
||||
<el-input v-model.number="form.timeFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.lineHeight')">
|
||||
<el-input v-model.number="form.timeLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.time') }}</h3>
|
||||
</template>
|
||||
<el-form-item :label="$t('stylegen.showTime')">
|
||||
<el-switch v-model="form.showTime"></el-switch>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.font')">
|
||||
<font-select v-model="form.timeFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.color')">
|
||||
<el-color-picker v-model="form.timeColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fontSize')">
|
||||
<el-input v-model.number="form.timeFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.lineHeight')">
|
||||
<el-input v-model.number="form.timeLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.backgrounds') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.bgColor')">
|
||||
<el-color-picker v-model="form.bgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showLargeEmoticonBg')">
|
||||
<el-switch v-model="form.showLargeEmoticonBg"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.messageBgColor')">
|
||||
<el-color-picker v-model="form.messageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.memberMessageBgColor')">
|
||||
<el-color-picker v-model="form.memberMessageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.moderatorMessageBgColor')">
|
||||
<el-color-picker v-model="form.moderatorMessageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.ownerMessageBgColor')">
|
||||
<el-color-picker v-model="form.ownerMessageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.backgrounds') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.bgColor')">
|
||||
<el-color-picker v-model="form.bgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showLargeEmoticonBg')">
|
||||
<el-switch v-model="form.showLargeEmoticonBg"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.messageBgColor')">
|
||||
<el-color-picker v-model="form.messageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.memberMessageBgColor')">
|
||||
<el-color-picker v-model="form.memberMessageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.moderatorMessageBgColor')">
|
||||
<el-color-picker v-model="form.moderatorMessageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.ownerMessageBgColor')">
|
||||
<el-color-picker v-model="form.ownerMessageBgColor" show-alpha></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.scAndNewMember') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineFont')">
|
||||
<font-select v-model="form.firstLineFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineFontSize')">
|
||||
<el-input v-model.number="form.firstLineFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineLineHeight')">
|
||||
<el-input v-model.number="form.firstLineLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider></el-divider>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.scAndNewMember') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineFont')">
|
||||
<font-select v-model="form.firstLineFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineFontSize')">
|
||||
<el-input v-model.number="form.firstLineFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.firstLineLineHeight')">
|
||||
<el-input v-model.number="form.firstLineLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider></el-divider>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineFont')">
|
||||
<font-select v-model="form.secondLineFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineFontSize')">
|
||||
<el-input v-model.number="form.secondLineFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineLineHeight')">
|
||||
<el-input v-model.number="form.secondLineLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider></el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineFont')">
|
||||
<font-select v-model="form.secondLineFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineFontSize')">
|
||||
<el-input v-model.number="form.secondLineFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.secondLineLineHeight')">
|
||||
<el-input v-model.number="form.secondLineLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider></el-divider>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineFont')">
|
||||
<font-select v-model="form.scContentFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineFontSize')">
|
||||
<el-input v-model.number="form.scContentFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineLineHeight')">
|
||||
<el-input v-model.number="form.scContentLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider></el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineFont')">
|
||||
<font-select v-model="form.scContentFont"></font-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineFontSize')">
|
||||
<el-input v-model.number="form.scContentFontSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.scContentLineLineHeight')">
|
||||
<el-input v-model.number="form.scContentLineHeight" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider></el-divider>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showScTicker')">
|
||||
<el-switch v-model="form.showScTicker"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showOtherThings')">
|
||||
<el-switch v-model="form.showOtherThings"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showScTicker')">
|
||||
<el-switch v-model="form.showScTicker"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.showOtherThings')">
|
||||
<el-switch v-model="form.showOtherThings"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
|
||||
<h3>{{ $t('stylegen.animation') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.animateIn')">
|
||||
<el-switch v-model="form.animateIn"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fadeInTime')">
|
||||
<el-input v-model.number="form.fadeInTime" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.animateOut')">
|
||||
<el-switch v-model="form.animateOut"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fadeOutTime')">
|
||||
<el-input v-model.number="form.fadeOutTime" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item :label="$t('stylegen.animateOutWaitTime')">
|
||||
<el-input v-model.number="form.animateOutWaitTime" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.slide')">
|
||||
<el-switch v-model="form.slide"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.reverseSlide')">
|
||||
<el-switch v-model="form.reverseSlide"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-collapse-item>
|
||||
<template slot="title">
|
||||
<h3>{{ $t('stylegen.animation') }}</h3>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.animateIn')">
|
||||
<el-switch v-model="form.animateIn"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fadeInTime')">
|
||||
<el-input v-model.number="form.fadeInTime" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.animateOut')">
|
||||
<el-switch v-model="form.animateOut"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.fadeOutTime')">
|
||||
<el-input v-model.number="form.fadeOutTime" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item :label="$t('stylegen.animateOutWaitTime')">
|
||||
<el-input v-model.number="form.animateOutWaitTime" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.slide')">
|
||||
<el-switch v-model="form.slide"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.reverseSlide')">
|
||||
<el-switch v-model="form.reverseSlide"></el-switch>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="16">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane :label="$t('stylegen.legacy')" name="legacy">
|
||||
<legacy ref="legacy" v-model="subComponentResults.legacy"></legacy>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="$t('stylegen.lineLike')" name="lineLike">
|
||||
<line-like ref="lineLike" v-model="subComponentResults.lineLike"></line-like>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="$t('stylegen.legacy')" name="legacy">
|
||||
<legacy ref="legacy" v-model="subComponentResults.legacy"></legacy>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-form label-width="150px" size="mini">
|
||||
@@ -38,9 +38,7 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div id="example-container" :class="{ light: exampleBgLight }">
|
||||
<iframe id="example-room-iframe" ref="exampleRoomIframe"
|
||||
:src="exampleRoomUrl" frameborder="0" @load="onExampleRoomLoad"
|
||||
></iframe>
|
||||
<iframe id="example-room-iframe" ref="exampleRoomIframe" :src="exampleRoomUrl" frameborder="0"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
@@ -66,14 +64,14 @@ export default {
|
||||
legacy: '',
|
||||
lineLike: ''
|
||||
},
|
||||
activeTab: 'legacy',
|
||||
activeTab: 'lineLike',
|
||||
// 输入框的结果
|
||||
inputResult: '',
|
||||
// 防抖后延迟变化的结果
|
||||
debounceResult: '',
|
||||
|
||||
playAnimation: true,
|
||||
exampleBgLight: false
|
||||
exampleBgLight: true,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -96,22 +94,42 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.debounceResult = this.inputResult = this.subComponentResult
|
||||
|
||||
window.addEventListener('message', this.onWindowMessage)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('message', this.onWindowMessage)
|
||||
},
|
||||
methods: {
|
||||
onExampleRoomLoad() {
|
||||
this.setExampleRoomCustomCss(this.debounceResult)
|
||||
this.setExampleRoomClientStart(this.playAnimation)
|
||||
sendMessageToExampleRoom(type, data = null) {
|
||||
let msg = { type, data }
|
||||
this.$refs.exampleRoomIframe.contentWindow.postMessage(msg, window.location.origin)
|
||||
},
|
||||
// 处理房间发送的消息
|
||||
onWindowMessage(event) {
|
||||
if (event.source !== this.$refs.exampleRoomIframe.contentWindow) {
|
||||
return
|
||||
}
|
||||
if (event.origin !== window.location.origin) {
|
||||
console.warn(`消息origin错误,${event.origin} != ${window.location.origin}`)
|
||||
return
|
||||
}
|
||||
|
||||
let { type } = event.data
|
||||
switch (type) {
|
||||
case 'stylegenExampleRoomLoad':
|
||||
this.setExampleRoomCustomCss(this.debounceResult)
|
||||
this.setExampleRoomClientStart(this.playAnimation)
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
setExampleRoomCustomCss(css) {
|
||||
this.sendMessageToExampleRoom('roomSetCustomStyle', { css })
|
||||
},
|
||||
setExampleRoomClientStart(isStart) {
|
||||
this.sendMessageToExampleRoom(isStart ? 'roomStartClient' : 'roomStopClient')
|
||||
},
|
||||
sendMessageToExampleRoom(type, data = null) {
|
||||
let msg = { type, data }
|
||||
this.$refs.exampleRoomIframe.contentWindow.postMessage(msg, window.location.origin)
|
||||
},
|
||||
|
||||
copyResult() {
|
||||
this.$refs.result.select()
|
||||
|
||||
31
frontend/vercel.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"cleanUrls": true,
|
||||
"trailingSlash": false,
|
||||
"headers": [
|
||||
{
|
||||
"source": "/((?!api/)[^.]*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "public, max-age=180"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/((?!api/).+\\.\\w+)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "public, max-age=86400"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/((?!api/)[^.]+)",
|
||||
"destination": "/"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
|
||||
// 不能用localhost,https://forum.dfinity.org/t/development-workflow-quickly-test-code-modifications/1793/21
|
||||
const API_BASE_URL = 'http://127.0.0.1:12450'
|
||||
|
||||
module.exports = {
|
||||
function toBool(val) {
|
||||
if (typeof val === 'string') {
|
||||
return ['false', 'no', 'off', '0', ''].indexOf(val.toLowerCase()) === -1
|
||||
}
|
||||
return Boolean(val)
|
||||
}
|
||||
|
||||
module.exports = defineConfig({
|
||||
devServer: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
@@ -16,24 +25,35 @@ module.exports = {
|
||||
},
|
||||
}
|
||||
},
|
||||
productionSourceMap: toBool(process.env.PROD_SOURCE_MAP),
|
||||
chainWebpack: config => {
|
||||
const APP_VERSION = `v${process.env.npm_package_version}`
|
||||
const LIB_USE_CDN = toBool(process.env.LIB_USE_CDN)
|
||||
|
||||
const ENV = {
|
||||
APP_VERSION,
|
||||
LIB_USE_CDN,
|
||||
BACKEND_DISCOVERY: toBool(process.env.BACKEND_DISCOVERY),
|
||||
}
|
||||
config.plugin('define')
|
||||
.tap(args => {
|
||||
let defineMap = args[0]
|
||||
let env = defineMap['process.env']
|
||||
env['APP_VERSION'] = JSON.stringify(APP_VERSION)
|
||||
for (let [name, value] of Object.entries(ENV)) {
|
||||
env[name] = JSON.stringify(value)
|
||||
}
|
||||
return args
|
||||
})
|
||||
|
||||
config.externals({
|
||||
'element-ui': 'ELEMENT',
|
||||
lodash: '_',
|
||||
pako: 'pako',
|
||||
vue: 'Vue',
|
||||
'vue-router': 'VueRouter',
|
||||
'vue-i18n': 'VueI18n',
|
||||
})
|
||||
if (LIB_USE_CDN) {
|
||||
config.externals({
|
||||
'element-ui': 'ELEMENT',
|
||||
lodash: '_',
|
||||
pako: 'pako',
|
||||
vue: 'Vue',
|
||||
'vue-router': 'VueRouter',
|
||||
'vue-i18n': 'VueI18n',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
43
main.py
@@ -39,6 +39,7 @@ ROUTES = [
|
||||
|
||||
server: Optional[tornado.httpserver.HTTPServer] = None
|
||||
|
||||
cmd_args = None
|
||||
shut_down_event: Optional[asyncio.Event] = None
|
||||
|
||||
|
||||
@@ -55,11 +56,12 @@ async def main():
|
||||
def init():
|
||||
init_signal_handlers()
|
||||
|
||||
args = parse_args()
|
||||
global cmd_args
|
||||
cmd_args = parse_args()
|
||||
|
||||
init_logging(args.debug)
|
||||
init_logging(cmd_args.debug)
|
||||
logger.info('App started, initializing')
|
||||
config.init(args)
|
||||
config.init(cmd_args)
|
||||
|
||||
utils.request.init()
|
||||
models.database.init()
|
||||
@@ -83,21 +85,36 @@ def init_signal_handlers():
|
||||
global shut_down_event
|
||||
shut_down_event = asyncio.Event()
|
||||
|
||||
signums = (signal.SIGINT, signal.SIGTERM)
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
for signum in signums:
|
||||
loop.add_signal_handler(signum, on_shut_down_signal)
|
||||
except NotImplementedError:
|
||||
# 不太安全,但Windows只能用这个
|
||||
for signum in signums:
|
||||
signal.signal(signum, on_shut_down_signal)
|
||||
is_win = sys.platform == 'win32'
|
||||
loop = asyncio.get_running_loop()
|
||||
if not is_win:
|
||||
def add_signal_handler(signum, callback):
|
||||
loop.add_signal_handler(signum, callback)
|
||||
else:
|
||||
def add_signal_handler(signum, callback):
|
||||
# 不太安全,但Windows只能用这个
|
||||
signal.signal(signum, lambda _signum, _frame: loop.call_soon(callback))
|
||||
|
||||
shut_down_signums = (signal.SIGINT, signal.SIGTERM)
|
||||
if not is_win:
|
||||
reload_signum = signal.SIGHUP
|
||||
else:
|
||||
reload_signum = signal.SIGBREAK
|
||||
|
||||
for shut_down_signum in shut_down_signums:
|
||||
add_signal_handler(shut_down_signum, on_shut_down_signal)
|
||||
add_signal_handler(reload_signum, on_reload_signal)
|
||||
|
||||
|
||||
def on_shut_down_signal(*_args):
|
||||
def on_shut_down_signal():
|
||||
shut_down_event.set()
|
||||
|
||||
|
||||
def on_reload_signal():
|
||||
logger.info('Received reload signal')
|
||||
config.reload(cmd_args)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description='用于OBS的仿YouTube风格的bilibili直播评论栏')
|
||||
parser.add_argument('--host', help='服务器host,默认和配置中的一样', default=None)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
-r blivedm/requirements.txt
|
||||
cachetools==5.3.1
|
||||
circuitbreaker==2.0.0
|
||||
pycryptodome==3.19.1
|
||||
sqlalchemy==2.0.19
|
||||
tornado==6.3.3
|
||||
sqlalchemy==2.0.37
|
||||
tornado==6.4.2
|
||||
|
||||
@@ -193,7 +193,6 @@ class WebLiveClient(blivedm.BLiveClient):
|
||||
assert room_key.type == RoomKeyType.ROOM_ID
|
||||
super().__init__(
|
||||
room_key.value,
|
||||
uid=0,
|
||||
session=utils.request.http_session,
|
||||
heartbeat_interval=self.HEARTBEAT_INTERVAL,
|
||||
)
|
||||
@@ -266,7 +265,10 @@ class OpenLiveClient(blivedm.OpenLiveClient):
|
||||
logger.error('_start_game() failed')
|
||||
return False
|
||||
except api_open_live.BusinessError as e:
|
||||
logger.warning('_start_game() failed')
|
||||
logger.warning(
|
||||
'_start_game() failed, room_id=%s',
|
||||
api_open_live.auth_code_room_id_cache.get(self._room_owner_auth_code, None)
|
||||
)
|
||||
|
||||
if e.code == 7007:
|
||||
# 身份码错误
|
||||
@@ -288,7 +290,11 @@ class OpenLiveClient(blivedm.OpenLiveClient):
|
||||
})
|
||||
|
||||
return False
|
||||
return self._parse_start_game(data['data'])
|
||||
|
||||
res = self._parse_start_game(data['data'])
|
||||
if res:
|
||||
api_open_live.auth_code_room_id_cache[self._room_owner_auth_code] = self.room_id
|
||||
return res
|
||||
|
||||
async def _end_game(self):
|
||||
if self._game_id in (None, ''):
|
||||
@@ -501,7 +507,9 @@ class LiveMsgHandler(blivedm.BaseHandler):
|
||||
|
||||
async def __on_danmaku(self, client: WebLiveClient, message: dm_web_models.DanmakuMessage):
|
||||
# 先异步调用再获取房间,因为返回时房间可能已经不存在了
|
||||
avatar_url = await services.avatar.get_avatar_url(message.uid, message.uname)
|
||||
avatar_url = message.face
|
||||
if avatar_url == '':
|
||||
avatar_url = await services.avatar.get_avatar_url(message.uid, message.uname)
|
||||
|
||||
room = client_room_manager.get_room(client.room_key)
|
||||
if room is None:
|
||||
@@ -516,6 +524,11 @@ class LiveMsgHandler(blivedm.BaseHandler):
|
||||
else:
|
||||
author_type = 0
|
||||
|
||||
show_content = message.msg
|
||||
reply_uname = message.extra_dict.get('reply_uname', '')
|
||||
if reply_uname != '':
|
||||
show_content = f'@{reply_uname} {show_content}'
|
||||
|
||||
if message.dm_type == 1:
|
||||
content_type = api.chat.ContentType.EMOTICON
|
||||
content_type_params = api.chat.make_emoticon_params(
|
||||
@@ -544,7 +557,7 @@ class LiveMsgHandler(blivedm.BaseHandler):
|
||||
timestamp=int(message.timestamp / 1000),
|
||||
author_name=message.uname,
|
||||
author_type=author_type,
|
||||
content=message.msg,
|
||||
content=show_content,
|
||||
privilege_type=message.privilege_type,
|
||||
is_gift_danmaku=bool(message.msg_type),
|
||||
author_level=message.user_level,
|
||||
@@ -587,22 +600,22 @@ class LiveMsgHandler(blivedm.BaseHandler):
|
||||
'num': message.num,
|
||||
# 给插件用的字段
|
||||
'giftId': message.gift_id,
|
||||
'giftIconUrl': '',
|
||||
'giftIconUrl': message.gift_img_basic,
|
||||
'uid': str(message.uid) if message.uid != 0 else message.uname,
|
||||
'privilegeType': message.guard_level,
|
||||
'medalLevel': 0,
|
||||
'medalName': '',
|
||||
'medalLevel': 0 if message.medal_ruid != client.room_owner_uid else message.medal_level,
|
||||
'medalName': '' if message.medal_ruid != client.room_owner_uid else message.medal_name,
|
||||
}
|
||||
room.send_cmd_data(api.chat.Command.ADD_GIFT, data)
|
||||
services.plugin.broadcast_cmd_data(
|
||||
sdk_models.Command.ADD_GIFT, data, make_plugin_msg_extra_from_live_client(client)
|
||||
)
|
||||
|
||||
def _on_buy_guard(self, client: WebLiveClient, message: dm_web_models.GuardBuyMessage):
|
||||
def _on_user_toast_v2(self, client: WebLiveClient, message: dm_web_models.UserToastV2Message):
|
||||
utils.async_io.create_task_with_ref(self.__on_buy_guard(client, message))
|
||||
|
||||
@staticmethod
|
||||
async def __on_buy_guard(client: WebLiveClient, message: dm_web_models.GuardBuyMessage):
|
||||
async def __on_buy_guard(client: WebLiveClient, message: dm_web_models.UserToastV2Message):
|
||||
# 先异步调用再获取房间,因为返回时房间可能已经不存在了
|
||||
avatar_url = await services.avatar.get_avatar_url(message.uid, message.username)
|
||||
|
||||
@@ -618,7 +631,7 @@ class LiveMsgHandler(blivedm.BaseHandler):
|
||||
'privilegeType': message.guard_level,
|
||||
# 给插件用的字段
|
||||
'num': message.num,
|
||||
'unit': '月', # 单位在USER_TOAST_MSG消息里,不想改消息。现在没有别的单位,web接口也很少有人用了,先写死吧
|
||||
'unit': message.unit,
|
||||
'total_coin': message.price * message.num,
|
||||
'uid': str(message.uid) if message.uid != 0 else message.username,
|
||||
'medalLevel': 0,
|
||||
@@ -660,8 +673,8 @@ class LiveMsgHandler(blivedm.BaseHandler):
|
||||
# 给插件用的字段
|
||||
'uid': str(message.uid) if message.uid != 0 else message.uname,
|
||||
'privilegeType': message.guard_level,
|
||||
'medalLevel': 0,
|
||||
'medalName': '',
|
||||
'medalLevel': 0 if message.medal_room_id != client.room_id else message.medal_level,
|
||||
'medalName': '' if message.medal_room_id != client.room_id else message.medal_name,
|
||||
}
|
||||
room.send_cmd_data(api.chat.Command.ADD_SUPER_CHAT, data)
|
||||
services.plugin.broadcast_cmd_data(
|
||||
@@ -728,11 +741,17 @@ class LiveMsgHandler(blivedm.BaseHandler):
|
||||
|
||||
if message.open_id == client.room_owner_open_id:
|
||||
author_type = 3 # 主播
|
||||
elif message.is_admin:
|
||||
author_type = 2 # 房管
|
||||
elif message.guard_level != 0: # 1总督,2提督,3舰长
|
||||
author_type = 1 # 舰队
|
||||
else:
|
||||
author_type = 0
|
||||
|
||||
show_content = message.msg
|
||||
if message.reply_uname != '':
|
||||
show_content = f'@{message.reply_uname} {show_content}'
|
||||
|
||||
if message.dm_type == 1:
|
||||
content_type = api.chat.ContentType.EMOTICON
|
||||
content_type_params = api.chat.make_emoticon_params(message.emoji_img_url)
|
||||
@@ -758,7 +777,7 @@ class LiveMsgHandler(blivedm.BaseHandler):
|
||||
timestamp=message.timestamp,
|
||||
author_name=message.uname,
|
||||
author_type=author_type,
|
||||
content=message.msg,
|
||||
content=show_content,
|
||||
privilege_type=message.guard_level,
|
||||
medal_level=0 if not message.fans_medal_wearing_status else message.fans_medal_level,
|
||||
id_=message.msg_id,
|
||||
@@ -784,7 +803,7 @@ class LiveMsgHandler(blivedm.BaseHandler):
|
||||
if room is None:
|
||||
return
|
||||
|
||||
total_coin = message.price * message.gift_num
|
||||
total_coin = message.r_price * message.gift_num
|
||||
data = {
|
||||
'id': message.msg_id,
|
||||
'avatarUrl': services.avatar.process_avatar_url(message.uface),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import dataclasses
|
||||
import datetime
|
||||
import enum
|
||||
@@ -10,13 +10,13 @@ import hmac
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from typing import *
|
||||
|
||||
import Crypto.Cipher.AES as cry_aes # noqa
|
||||
import Crypto.Util.Padding as cry_pad # noqa
|
||||
import aiohttp
|
||||
import cachetools
|
||||
import circuitbreaker
|
||||
|
||||
import config
|
||||
import utils.async_io
|
||||
@@ -71,11 +71,7 @@ async def _do_init():
|
||||
|
||||
def create_translate_provider(cfg):
|
||||
type_ = cfg['type']
|
||||
if type_ == 'TencentTranslateFree':
|
||||
return TencentTranslateFree(
|
||||
cfg['query_interval'], cfg['source_language'], cfg['target_language']
|
||||
)
|
||||
elif type_ == 'TencentTranslate':
|
||||
if type_ == 'TencentTranslate':
|
||||
return TencentTranslate(
|
||||
cfg['query_interval'], cfg['source_language'], cfg['target_language'],
|
||||
cfg['secret_id'], cfg['secret_key'], cfg['region']
|
||||
@@ -85,10 +81,10 @@ def create_translate_provider(cfg):
|
||||
cfg['query_interval'], cfg['source_language'], cfg['target_language'],
|
||||
cfg['app_id'], cfg['secret']
|
||||
)
|
||||
elif type_ == 'GeminiTranslate':
|
||||
return GeminiTranslate(
|
||||
cfg['query_interval'], cfg['proxy'], cfg['api_key'], cfg['model_code'],
|
||||
cfg['prompt'], cfg['temperature']
|
||||
elif type_ == 'OpenAiApi':
|
||||
return OpenAiApi(
|
||||
cfg['query_interval'], cfg['api_key'], cfg['base_url'], cfg['proxy'], cfg['model'],
|
||||
cfg['prompt'], cfg['max_tokens'], cfg['temperature'], cfg['top_p']
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -261,8 +257,10 @@ class TranslateProvider:
|
||||
logger.info('%s became available', cls_name)
|
||||
|
||||
task = await _pop_task()
|
||||
# 为了简化代码,约定只会在_translate_wrapper里变成不可用,所以获取task之后这里还是可用的
|
||||
assert self.is_available
|
||||
if not self.is_available:
|
||||
if not _push_task(task):
|
||||
task.future.set_result(None)
|
||||
continue
|
||||
|
||||
start_time = datetime.datetime.now()
|
||||
await self._translate_wrapper(task)
|
||||
@@ -273,7 +271,7 @@ class TranslateProvider:
|
||||
except Exception: # noqa
|
||||
logger.exception('%s error:', cls_name)
|
||||
|
||||
async def _translate_wrapper(self, task: TranslateTask) -> Optional[str]:
|
||||
async def _translate_wrapper(self, task: TranslateTask):
|
||||
try:
|
||||
exc = None
|
||||
task.remain_retry_count -= 1
|
||||
@@ -283,7 +281,7 @@ class TranslateProvider:
|
||||
res = None
|
||||
if res is not None:
|
||||
task.future.set_result(res)
|
||||
return res
|
||||
return
|
||||
|
||||
if task.remain_retry_count > 0:
|
||||
# 还可以重试则放回队列
|
||||
@@ -295,220 +293,11 @@ class TranslateProvider:
|
||||
task.future.set_exception(exc)
|
||||
else:
|
||||
task.future.set_result(None)
|
||||
return None
|
||||
|
||||
async def _do_translate(self, text) -> Optional[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class TencentTranslateFree(TranslateProvider):
|
||||
def __init__(self, query_interval, source_language, target_language):
|
||||
super().__init__(query_interval)
|
||||
self._be_available_event.clear() # _do_init之后才可用
|
||||
self._source_language = source_language
|
||||
self._target_language = target_language
|
||||
|
||||
self._server_time_delta = 0
|
||||
self._uc_key = self._uc_iv = ''
|
||||
self._qtv = self._qtk = ''
|
||||
self._reinit_future = None
|
||||
|
||||
# 连续失败的次数
|
||||
self._fail_count = 0
|
||||
|
||||
async def init(self):
|
||||
if not await super().init():
|
||||
return False
|
||||
self._reinit_future = asyncio.create_task(self._reinit_coroutine())
|
||||
return True
|
||||
|
||||
async def _do_init(self):
|
||||
try:
|
||||
async with utils.request.http_session.get('https://fanyi.qq.com/') as r:
|
||||
if r.status != 200:
|
||||
logger.warning('TencentTranslateFree init request failed: status=%d %s', r.status, r.reason)
|
||||
return False
|
||||
html = await r.text()
|
||||
|
||||
try:
|
||||
server_time = r.headers['Date']
|
||||
server_time = datetime.datetime.strptime(server_time, '%a, %d %b %Y %H:%M:%S GMT')
|
||||
server_time = server_time.replace(tzinfo=datetime.timezone.utc).timestamp()
|
||||
self._server_time_delta = int((datetime.datetime.now().timestamp() - server_time) * 1000)
|
||||
except (KeyError, ValueError):
|
||||
self._server_time_delta = 0
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError):
|
||||
logger.exception('TencentTranslateFree init error:')
|
||||
return False
|
||||
|
||||
# 获取token URL
|
||||
m = re.search(r"""\breauthuri\s*=\s*['"](.+?)['"]""", html)
|
||||
if m is None:
|
||||
logger.exception('TencentTranslateFree init failed: reauthuri not found')
|
||||
return False
|
||||
reauthuri = m[1]
|
||||
|
||||
# 获取验证用的key、iv
|
||||
m = re.search(r"""\s*=\s*['"]((?:\w+\|\w+-)+\w+\|\w+)['"]""", html)
|
||||
if m is None:
|
||||
logger.exception('TencentTranslateFree init failed: initial global variables not found')
|
||||
return False
|
||||
uc_key = None
|
||||
uc_iv = None
|
||||
for item in m[1].split('-'):
|
||||
key, _, value = item.partition('|')
|
||||
if key == 'a137':
|
||||
uc_key = value
|
||||
elif key == 'E74':
|
||||
uc_iv = value
|
||||
if uc_key is not None and uc_iv is not None:
|
||||
break
|
||||
|
||||
# 获取token
|
||||
try:
|
||||
async with utils.request.http_session.post('https://fanyi.qq.com/api/' + reauthuri) as r:
|
||||
if r.status != 200:
|
||||
logger.warning('TencentTranslateFree init request failed: reauthuri=%s, status=%d %s',
|
||||
reauthuri, r.status, r.reason)
|
||||
return False
|
||||
data = await r.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError):
|
||||
logger.exception('TencentTranslateFree init error:')
|
||||
return False
|
||||
|
||||
qtv = data.get('qtv', None)
|
||||
if qtv is None:
|
||||
logger.warning('TencentTranslateFree init failed: qtv not found')
|
||||
return False
|
||||
qtk = data.get('qtk', None)
|
||||
if qtk is None:
|
||||
logger.warning('TencentTranslateFree init failed: qtk not found')
|
||||
return False
|
||||
|
||||
self._uc_key = uc_key
|
||||
self._uc_iv = uc_iv
|
||||
self._qtv = qtv
|
||||
self._qtk = qtk
|
||||
|
||||
self._on_availability_change()
|
||||
return True
|
||||
|
||||
async def _reinit_coroutine(self):
|
||||
while True:
|
||||
logger.debug('TencentTranslateFree reinit')
|
||||
start_time = datetime.datetime.now()
|
||||
try:
|
||||
await self._do_init()
|
||||
except Exception: # noqa
|
||||
pass
|
||||
cost_time = (datetime.datetime.now() - start_time).total_seconds()
|
||||
|
||||
await asyncio.sleep(30 - cost_time)
|
||||
|
||||
@property
|
||||
def is_available(self):
|
||||
return '' not in (self._uc_key, self._uc_iv, self._qtv, self._qtk) and super().is_available
|
||||
|
||||
async def _translate_wrapper(self, task: TranslateTask) -> Optional[str]:
|
||||
res = await super()._translate_wrapper(task)
|
||||
if res is not None:
|
||||
self._fail_count = 0
|
||||
else:
|
||||
self._on_fail()
|
||||
return res
|
||||
|
||||
async def _do_translate(self, text) -> Optional[str]:
|
||||
try:
|
||||
async with utils.request.http_session.post(
|
||||
'https://fanyi.qq.com/api/translate',
|
||||
headers={
|
||||
'Referer': 'https://fanyi.qq.com/',
|
||||
'uc': self._get_uc()
|
||||
},
|
||||
data={
|
||||
'source': self._source_language,
|
||||
'target': self._target_language,
|
||||
'sourceText': text,
|
||||
'qtv': self._qtv,
|
||||
'qtk': self._qtk
|
||||
}
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
logger.warning('TencentTranslateFree request failed: status=%d %s', r.status, r.reason)
|
||||
return None
|
||||
self._update_uc_key(r)
|
||||
data = await r.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError):
|
||||
return None
|
||||
if data['errCode'] != 0:
|
||||
logger.warning('TencentTranslateFree failed: %d %s', data['errCode'], data['errMsg'])
|
||||
return None
|
||||
res = ''.join(record['targetText'] for record in data['translate']['records'])
|
||||
if res == '' and text.strip() != '':
|
||||
# qtv、qtk过期
|
||||
logger.info('TencentTranslateFree result is empty %s', data)
|
||||
return None
|
||||
return res
|
||||
|
||||
def _get_uc(self):
|
||||
user_actions = self._gen_user_actions()
|
||||
cur_timestamp = str(int(datetime.datetime.now().timestamp() * 1000))
|
||||
server_time_delta = str(self._server_time_delta)
|
||||
uc = '|'.join([user_actions, cur_timestamp, server_time_delta])
|
||||
|
||||
aes = cry_aes.new(self._uc_key.encode('utf-8'), cry_aes.MODE_CBC, self._uc_iv.encode('utf-8'))
|
||||
uc = cry_pad.pad(uc.encode('utf-8'), aes.block_size, 'pkcs7')
|
||||
uc = aes.encrypt(uc)
|
||||
uc = base64.b64encode(uc).decode('utf-8')
|
||||
return uc
|
||||
|
||||
@staticmethod
|
||||
def _gen_user_actions():
|
||||
# 1:点击翻译;2:源输入框聚焦或失去焦点;3:点击源语言列表;4:点击交换语言;5:点击目标语言列表;6:源输入框输入、粘贴
|
||||
user_actions = []
|
||||
if random.randint(1, 5) == 1:
|
||||
for i in range(random.randint(1, 2)):
|
||||
user_actions.append('2')
|
||||
user_actions.append('6')
|
||||
for i in range(random.randint(0, 6)):
|
||||
user_actions.append(random.choice('26'))
|
||||
if random.randint(1, 5) == 1:
|
||||
user_actions.append('1')
|
||||
return ''.join(user_actions)
|
||||
|
||||
def _update_uc_key(self, r):
|
||||
try:
|
||||
hf_f = r.headers['f']
|
||||
hf_ts = int(r.headers['ts'])
|
||||
except (KeyError, ValueError):
|
||||
return
|
||||
|
||||
cur_timestamp = int(datetime.datetime.now().timestamp() * 1000)
|
||||
hf_f = base64.b64decode(hf_f.encode('utf-8')).decode('utf-8')
|
||||
pos = int(hf_f[72: 72 + 4])
|
||||
uc_key = hf_f[pos: pos + 16]
|
||||
uc_iv = hf_f[pos + 16: pos + 16 + 16]
|
||||
|
||||
self._server_time_delta = cur_timestamp - hf_ts
|
||||
self._uc_key = uc_key
|
||||
self._uc_iv = uc_iv
|
||||
|
||||
def _on_fail(self):
|
||||
self._fail_count += 1
|
||||
# 为了可靠性,连续失败5次时冷却直到下次重新init
|
||||
if self._fail_count >= 5:
|
||||
self._cool_down()
|
||||
|
||||
def _cool_down(self):
|
||||
logger.info('TencentTranslateFree is cooling down')
|
||||
# 下次_do_init后恢复
|
||||
self._uc_key = self._uc_iv = ''
|
||||
self._qtv = self._qtk = ''
|
||||
self._fail_count = 0
|
||||
|
||||
self._on_availability_change()
|
||||
|
||||
|
||||
class TencentTranslate(TranslateProvider):
|
||||
def __init__(self, query_interval, source_language, target_language,
|
||||
secret_id, secret_key, region):
|
||||
@@ -559,8 +348,9 @@ class TencentTranslate(TranslateProvider):
|
||||
hashed_request_payload = hashlib.sha256(body_bytes).hexdigest()
|
||||
canonical_request = f'POST\n/\n\n{canonical_headers}\n{signed_headers}\n{hashed_request_payload}'
|
||||
|
||||
request_timestamp = int(datetime.datetime.now().timestamp())
|
||||
date = datetime.datetime.utcfromtimestamp(request_timestamp).strftime('%Y-%m-%d')
|
||||
cur_time_utc = datetime.datetime.now(datetime.UTC)
|
||||
request_timestamp = int(cur_time_utc.timestamp())
|
||||
date = cur_time_utc.strftime('%Y-%m-%d')
|
||||
credential_scope = f'{date}/tmt/tc3_request'
|
||||
hashed_canonical_request = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
|
||||
string_to_sign = f'TC3-HMAC-SHA256\n{request_timestamp}\n{credential_scope}\n{hashed_canonical_request}'
|
||||
@@ -684,96 +474,96 @@ class BaiduTranslate(TranslateProvider):
|
||||
self._on_availability_change()
|
||||
|
||||
|
||||
class GeminiTranslate(TranslateProvider):
|
||||
def __init__(self, query_interval, proxy, api_key, model_code, prompt, temperature):
|
||||
class _OpenAiApiExpectedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OpenAiApi(TranslateProvider):
|
||||
def __init__(self, query_interval, api_key, base_url, proxy, model, prompt, max_tokens, temperature, top_p):
|
||||
super().__init__(query_interval)
|
||||
self._url = base_url + '/chat/completions'
|
||||
self._proxy = proxy or None
|
||||
self._api_key = api_key
|
||||
self._url = f'https://generativelanguage.googleapis.com/v1beta/{model_code}:generateContent'
|
||||
self._prompt = prompt
|
||||
self._headers = {
|
||||
'Authorization': 'Bearer ' + api_key
|
||||
}
|
||||
self._body = {
|
||||
'contents': [
|
||||
{
|
||||
# 'role': 'user',
|
||||
'parts': [{'text': ''}]
|
||||
}
|
||||
'model': model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': prompt},
|
||||
{'role': 'user', 'content': ''},
|
||||
],
|
||||
'safetySettings': [
|
||||
{
|
||||
'category': 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||
'threshold': 'BLOCK_NONE'
|
||||
},
|
||||
{
|
||||
'category': 'HARM_CATEGORY_HATE_SPEECH',
|
||||
'threshold': 'BLOCK_NONE'
|
||||
},
|
||||
{
|
||||
'category': 'HARM_CATEGORY_HARASSMENT',
|
||||
'threshold': 'BLOCK_NONE'
|
||||
},
|
||||
{
|
||||
'category': 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||
'threshold': 'BLOCK_NONE'
|
||||
}
|
||||
],
|
||||
'generationConfig': {
|
||||
'temperature': temperature,
|
||||
'topP': 1,
|
||||
'topK': 32,
|
||||
'candidateCount': 1,
|
||||
'maxOutputTokens': 8192
|
||||
}
|
||||
'stream': False,
|
||||
'max_tokens': max_tokens,
|
||||
'temperature': temperature,
|
||||
'top_p': top_p,
|
||||
}
|
||||
|
||||
self._breaker = circuitbreaker.CircuitBreaker(
|
||||
failure_threshold=5,
|
||||
recovery_timeout=60,
|
||||
)
|
||||
self._cool_down_timer_handle = None
|
||||
|
||||
@property
|
||||
def is_available(self):
|
||||
return self._cool_down_timer_handle is None and super().is_available
|
||||
return self._breaker.state != circuitbreaker.STATE_OPEN and super().is_available
|
||||
|
||||
async def _translate_wrapper(self, task: TranslateTask):
|
||||
# 并发请求,不然太慢了
|
||||
utils.async_io.create_task_with_ref(super()._translate_wrapper(task))
|
||||
|
||||
async def _do_translate(self, text) -> Optional[str]:
|
||||
input_text = self._prompt.format(original_text=text)
|
||||
self._body['contents'][0]['parts'][0]['text'] = input_text
|
||||
data = None
|
||||
try:
|
||||
async with utils.request.http_session.post(
|
||||
self._url,
|
||||
params={'key': self._api_key},
|
||||
json=self._body,
|
||||
proxy=self._proxy,
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
logger.warning('GeminiTranslate request failed: status=%d %s', r.status, r.reason)
|
||||
self._on_fail(r.status)
|
||||
return None
|
||||
data = await r.json()
|
||||
with self._breaker:
|
||||
body = copy.deepcopy(self._body)
|
||||
body['messages'][-1]['content'] = text
|
||||
async with utils.request.http_session.post(
|
||||
self._url,
|
||||
headers=self._headers,
|
||||
json=body,
|
||||
proxy=self._proxy,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
rsp_body = await r.text()
|
||||
logger.warning(
|
||||
'OpenAiApi request failed: status=%d %s, body=%s', r.status, r.reason, rsp_body
|
||||
)
|
||||
raise _OpenAiApiExpectedError('OpenAiApi request failed')
|
||||
data = await r.json()
|
||||
return data['choices'][0]['message']['content']
|
||||
|
||||
except (_OpenAiApiExpectedError, circuitbreaker.CircuitBreakerError):
|
||||
pass
|
||||
except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as e:
|
||||
logger.warning('GeminiTranslate request failed, %s: %s', type(e).__name__, e)
|
||||
return None
|
||||
|
||||
try:
|
||||
candidates = data['candidates']
|
||||
if not candidates:
|
||||
block_reason = data['promptFeedback'].get('blockReason', '')
|
||||
logger.warning('GeminiTranslate no candidate, block_reason=%s, text=%s', block_reason, text)
|
||||
return None
|
||||
|
||||
first_content_parts = candidates[0]['content']['parts']
|
||||
return ''.join(part.get('text', '') for part in first_content_parts)
|
||||
logger.warning('OpenAiApi request failed, %s: %s', type(e).__name__, e)
|
||||
except (KeyError, IndexError):
|
||||
logger.warning('GeminiTranslate failed to parse response: %s', data)
|
||||
return None
|
||||
if data is not None:
|
||||
logger.warning('OpenAiApi failed to parse response: %s', data)
|
||||
else:
|
||||
logger.exception('OpenAiApi unknown exception:')
|
||||
|
||||
def _on_fail(self, code):
|
||||
if self._breaker.state == circuitbreaker.STATE_OPEN:
|
||||
self._on_breaker_open()
|
||||
|
||||
return None
|
||||
|
||||
def _on_breaker_open(self):
|
||||
if self._cool_down_timer_handle is not None:
|
||||
return
|
||||
|
||||
if code in (401, 403):
|
||||
# API密钥无效,没有权限,或者不在有效地区。需要手动处理,等5分钟
|
||||
self._cool_down_timer_handle = asyncio.get_running_loop().call_later(
|
||||
5 * 60, self._on_cool_down_timeout
|
||||
)
|
||||
self._on_availability_change()
|
||||
self._cool_down_timer_handle = asyncio.get_running_loop().call_later(
|
||||
self._breaker.open_remaining, self._on_cool_down_timeout
|
||||
)
|
||||
self._on_availability_change()
|
||||
|
||||
def _on_cool_down_timeout(self):
|
||||
self._cool_down_timer_handle = None
|
||||
if self._breaker.state == circuitbreaker.STATE_OPEN:
|
||||
self._cool_down_timer_handle = asyncio.get_running_loop().call_later(
|
||||
self._breaker.open_remaining, self._on_cool_down_timeout
|
||||
)
|
||||
return
|
||||
|
||||
self._on_availability_change()
|
||||
|
||||
@@ -6,7 +6,7 @@ import aiohttp
|
||||
import utils.async_io
|
||||
import utils.request
|
||||
|
||||
VERSION = 'v1.9.1'
|
||||
VERSION = 'v1.10.0'
|
||||
|
||||
|
||||
def check_update():
|
||||
|
||||
130
utils/request.py
@@ -1,8 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from typing import *
|
||||
|
||||
import aiohttp
|
||||
import circuitbreaker
|
||||
|
||||
import api.open_live
|
||||
import config
|
||||
import utils.async_io
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COOKIE_JAR_PATH = os.path.join(config.DATA_PATH, 'cookie_jar.pickle')
|
||||
|
||||
# 不带这堆头部有时候也能成功请求,但是带上后成功的概率更高
|
||||
BILIBILI_COMMON_HEADERS = {
|
||||
@@ -14,14 +27,37 @@ BILIBILI_COMMON_HEADERS = {
|
||||
|
||||
http_session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
_COMMON_SERVER_DISCOVERY_URLS = [
|
||||
'https://api1.blive.chat/api/endpoints',
|
||||
'https://api2.blive.chat/api/endpoints',
|
||||
]
|
||||
_last_update_common_server_time: Optional[datetime.datetime] = None
|
||||
_common_server_base_urls = [
|
||||
'https://api1.blive.chat',
|
||||
'https://api2.blive.chat',
|
||||
]
|
||||
_cur_common_server_base_url: Optional[str] = None
|
||||
_common_server_base_url_to_circuit_breaker: Dict[str, circuitbreaker.CircuitBreaker] = {}
|
||||
|
||||
|
||||
def init():
|
||||
try:
|
||||
cookie_jar = aiohttp.CookieJar()
|
||||
cookie_jar.load(COOKIE_JAR_PATH)
|
||||
except (OSError, pickle.PickleError):
|
||||
cookie_jar = None
|
||||
|
||||
global http_session
|
||||
http_session = aiohttp.ClientSession(
|
||||
response_class=CustomClientResponse,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
cookie_jar=cookie_jar,
|
||||
)
|
||||
|
||||
cfg = config.get_config()
|
||||
if not cfg.is_open_live_configured:
|
||||
_update_common_server_base_urls()
|
||||
|
||||
|
||||
async def shut_down():
|
||||
if http_session is not None:
|
||||
@@ -35,3 +71,97 @@ class CustomClientResponse(aiohttp.ClientResponse):
|
||||
return await super()._wait_released()
|
||||
except asyncio.CancelledError as e:
|
||||
raise aiohttp.ClientConnectionError('Connection released') from e
|
||||
|
||||
|
||||
def _update_common_server_base_urls():
|
||||
global _last_update_common_server_time
|
||||
cur_time = datetime.datetime.now()
|
||||
if (
|
||||
_last_update_common_server_time is not None
|
||||
and cur_time - _last_update_common_server_time < datetime.timedelta(minutes=3)
|
||||
):
|
||||
return
|
||||
_last_update_common_server_time = cur_time
|
||||
utils.async_io.create_task_with_ref(_do_update_common_server_base_urls())
|
||||
|
||||
|
||||
async def _do_update_common_server_base_urls():
|
||||
global _last_update_common_server_time
|
||||
_last_update_common_server_time = datetime.datetime.now()
|
||||
|
||||
async def request_get_urls(discovery_url):
|
||||
async with http_session.get(discovery_url) as res:
|
||||
res.raise_for_status()
|
||||
data = await res.json()
|
||||
return data['endpoints']
|
||||
|
||||
common_server_base_urls = []
|
||||
futures = [
|
||||
asyncio.create_task(request_get_urls(url))
|
||||
for url in _COMMON_SERVER_DISCOVERY_URLS
|
||||
]
|
||||
for future in asyncio.as_completed(futures):
|
||||
try:
|
||||
common_server_base_urls = await future
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning('Failed to discover common server endpoints from one source: %s', e)
|
||||
for future in futures:
|
||||
future.cancel()
|
||||
if not common_server_base_urls:
|
||||
logger.error('Failed to discover common server endpoints from any source')
|
||||
return
|
||||
|
||||
# 按响应时间排序
|
||||
sorted_common_server_base_urls = []
|
||||
error_base_urls = []
|
||||
|
||||
async def test_endpoint(base_url):
|
||||
try:
|
||||
url = base_url + '/api/ping'
|
||||
async with http_session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as res:
|
||||
res.raise_for_status()
|
||||
sorted_common_server_base_urls.append(base_url)
|
||||
except Exception: # noqa
|
||||
error_base_urls.append(base_url)
|
||||
|
||||
await asyncio.gather(*(test_endpoint(base_url) for base_url in common_server_base_urls))
|
||||
sorted_common_server_base_urls.extend(error_base_urls)
|
||||
|
||||
global _common_server_base_urls, _cur_common_server_base_url
|
||||
_common_server_base_urls = sorted_common_server_base_urls
|
||||
if _cur_common_server_base_url not in _common_server_base_urls:
|
||||
_cur_common_server_base_url = None
|
||||
logger.info('Found common server endpoints: %s', _common_server_base_urls)
|
||||
|
||||
|
||||
def get_common_server_base_url_and_circuit_breaker() -> Tuple[Optional[str], Optional[circuitbreaker.CircuitBreaker]]:
|
||||
_update_common_server_base_urls()
|
||||
|
||||
global _cur_common_server_base_url
|
||||
if _cur_common_server_base_url is not None:
|
||||
breaker = _get_or_add_common_server_circuit_breaker(_cur_common_server_base_url)
|
||||
if breaker.state != circuitbreaker.STATE_OPEN:
|
||||
return _cur_common_server_base_url, breaker
|
||||
_cur_common_server_base_url = None
|
||||
|
||||
# 找第一个未熔断的
|
||||
for base_url in _common_server_base_urls:
|
||||
breaker = _get_or_add_common_server_circuit_breaker(base_url)
|
||||
if breaker.state != circuitbreaker.STATE_OPEN:
|
||||
_cur_common_server_base_url = base_url
|
||||
logger.info('Switch common server endpoint to %s', _cur_common_server_base_url)
|
||||
return _cur_common_server_base_url, breaker
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def _get_or_add_common_server_circuit_breaker(base_url):
|
||||
breaker = _common_server_base_url_to_circuit_breaker.get(base_url, None)
|
||||
if breaker is None:
|
||||
breaker = _common_server_base_url_to_circuit_breaker[base_url] = circuitbreaker.CircuitBreaker(
|
||||
failure_threshold=3,
|
||||
recovery_timeout=60,
|
||||
expected_exception=api.open_live.TransportError,
|
||||
)
|
||||
return breaker
|
||||
|
||||