feat: add admin-managed system configuration center

Introduce a registry-backed KV settings system with encrypted secret storage,
add an admin UI for grouped system configuration, and shrink bootstrap YAML
to startup-critical settings with docs updated for the new workflow.
pull/717/head
ROC 3 months ago
parent 46bcd8b518
commit f2df8af8ef

@ -424,4 +424,4 @@ All notable changes to paopao-ce are documented in this file.
---
**Older change logs can be found on [GitHub](https://github.com/rocboss/paopao-ce/releases?after=v0.2.0).**
**Older change logs can be found on [GitHub](https://github.com/rocboss/paopao-ce/releases?after=v0.2.0).**

@ -157,7 +157,7 @@ If you mount a custom `config.yaml`, make sure `Meili.ApiKey` matches the contai
1. Initialize your database with the matching SQL file for your chosen database engine.
2. Copy the configuration template.
3. Adjust the storage, database, cache, and search settings for your environment.
3. Adjust only the bootstrap-critical settings for your environment.
4. Run or build the backend.
```sh
@ -238,6 +238,15 @@ At startup, PaoPao reads either:
The first file found is used.
Important: the external file is no longer expected to carry every runtime knob. PaoPao loads embedded defaults first, then overlays your local config file.
Recommended split:
- **Bootstrap YAML**: ports, feature selection, database, Redis, JWT, `AdminSettings.EncryptionKey`
- **Admin UI (`/#/admin/settings`)**: most site, search, storage, SMS, payment, and app-behavior settings
If a setting is marked **restart required** in the admin page, it is persisted immediately but only becomes active after a process restart.
The `Features` section controls which capability bundles are enabled:
```yaml

@ -157,7 +157,7 @@ docker run --name paopao-ce-allinone -d -p 8000:8008 -p 7700:7700 \
1. 按照所选数据库导入对应 SQL 初始化脚本。
2. 复制配置模板。
3. 根据本地环境调整存储、数据库、缓存和搜索相关配置。
3. 只调整与你环境相关的启动关键配置。
4. 启动或构建后端。
```sh
@ -238,6 +238,15 @@ make run TAGS='embed'
先找到哪个文件,就使用哪个文件。
注意外部配置文件现在不再要求承载全部运行参数。PaoPao 会先加载内置默认配置,再叠加你本地的配置文件。
建议的职责划分:
- **Bootstrap YAML**端口、Feature 组合、数据库、Redis、JWT、`AdminSettings.EncryptionKey`
- **管理后台**`/#/admin/settings`):大部分站点、搜索、存储、短信、支付以及应用行为类配置
如果某个配置项在后台中被标记为**重启后生效**,表示它会先持久化保存,但需要重启进程后才会真正切换到新值。
`Features` 用于控制不同能力组合:
```yaml

@ -116,7 +116,7 @@ Then open:
#### Backend
1. Import `scripts/paopao-mysql.sql` into MySQL.
2. Copy the sample config and adjust it for your environment.
2. Copy the sample config and adjust only the bootstrap-critical values for your environment.
3. Start the backend.
```sh
@ -163,7 +163,13 @@ For the full installation guide, Docker build variants, desktop prerequisites, a
## Configuration and Feature Suites
`config.yaml.sample` is the canonical configuration template. At runtime, PaoPao reads either `./custom/config.yaml` or `./config.yaml`, preferring the first file it finds.
`config.yaml.sample` is now a **minimal bootstrap template**. At runtime, PaoPao loads the embedded default config first, then overlays `./custom/config.yaml` or `./config.yaml` if present, preferring the first file it finds.
That means the external config can stay intentionally small. In the current design:
- **Keep in YAML**: ports, feature selection, database, Redis, JWT, and `AdminSettings.EncryptionKey`
- **Prefer the admin UI** (`/#/admin/settings`): site profile, app behavior limits, search provider settings, object-storage settings, SMS/payment settings, and other operational knobs supported by the registry
- **Watch apply mode**: some settings are live, while others are marked restart-required in the admin page
The `Features` section controls which capability bundles are enabled:
@ -225,7 +231,7 @@ If you plan to contribute new functionality, target **`dev`** unless the maintai
## Contributing
Issues, discussions, and pull requests are welcome. If you want to contribute:
Pull requests are welcome. If you want to contribute:
1. Fork the repository.
2. Create a branch from `dev` for feature work.

@ -116,7 +116,7 @@ docker compose up -d
#### 后端
1. 将 `scripts/paopao-mysql.sql` 导入 MySQL。
2. 复制示例配置并按本地环境调整
2. 复制示例配置,并只调整与你环境相关的启动关键项
3. 启动后端服务。
```sh
@ -163,7 +163,13 @@ yarn tauri build
## 配置与 Feature 套件
`config.yaml.sample` 是项目的标准配置模板。运行时PaoPao 会读取 `./custom/config.yaml``./config.yaml`,并优先使用先找到的文件。
`config.yaml.sample` 现在是一个**最小化 bootstrap 模板**。运行时PaoPao 会先加载内置默认配置,再叠加 `./custom/config.yaml``./config.yaml`,并优先使用先找到的文件。
这意味着外部配置文件可以有意保持精简。当前建议是:
- **继续放在 YAML 中**端口、Feature 组合、数据库、Redis、JWT以及 `AdminSettings.EncryptionKey`
- **尽量改到管理后台**`/#/admin/settings`):站点资料、应用行为阈值、全文索引、对象存储、短信/支付等已接入 registry 的配置
- **注意生效方式**:部分配置即时生效,部分会在后台中明确标记为“重启后生效”
`Features` 配置用于控制启用哪些能力组合:
@ -225,7 +231,7 @@ release/paopao serve --no-default-features --features sqlite3,localoss,loggerfil
## 参与贡献
欢迎提交 Issue、讨论和 Pull Request。建议的贡献流程
欢迎提交 Pull Request。建议的贡献流程
1. Fork 当前仓库。
2. 从 `dev` 分支拉出自己的功能分支。

@ -33,6 +33,11 @@ type Admin interface {
Chain() gin.HandlersChain
SiteInfo(*web.SiteInfoReq) (*web.SiteInfoResp, error)
GetSiteSettings() (*web.SiteSettingsResp, error)
UpdateSiteSettings(*web.SiteSettingsReq) (*web.SiteSettingsResp, error)
GetSettingsSchema() (*web.AdminSettingsSchemaResp, error)
GetSettingsValues() (*web.AdminSettingsValuesResp, error)
SaveSettings(*web.AdminSettingsSaveReq) (*web.AdminSettingsSaveResp, error)
ChangeUserStatus(*web.ChangeUserStatusReq) error
mustEmbedUnimplementedAdminServant()
@ -60,6 +65,64 @@ func RegisterAdminServant(e *gin.Engine, s Admin) {
resp, err := s.SiteInfo(req)
s.Render(c, resp, err)
})
router.Handle("GET", "admin/site/profile", func(c *gin.Context) {
select {
case <-c.Request.Context().Done():
return
default:
}
resp, err := s.GetSiteSettings()
s.Render(c, resp, err)
})
router.Handle("POST", "admin/site/profile", func(c *gin.Context) {
select {
case <-c.Request.Context().Done():
return
default:
}
req := new(web.SiteSettingsReq)
if err := s.Bind(c, req); err != nil {
s.Render(c, nil, err)
return
}
resp, err := s.UpdateSiteSettings(req)
s.Render(c, resp, err)
})
router.Handle("GET", "admin/settings/schema", func(c *gin.Context) {
select {
case <-c.Request.Context().Done():
return
default:
}
resp, err := s.GetSettingsSchema()
s.Render(c, resp, err)
})
router.Handle("GET", "admin/settings/values", func(c *gin.Context) {
select {
case <-c.Request.Context().Done():
return
default:
}
resp, err := s.GetSettingsValues()
s.Render(c, resp, err)
})
router.Handle("POST", "admin/settings/save", func(c *gin.Context) {
select {
case <-c.Request.Context().Done():
return
default:
}
req := new(web.AdminSettingsSaveReq)
if err := s.Bind(c, req); err != nil {
s.Render(c, nil, err)
return
}
resp, err := s.SaveSettings(req)
s.Render(c, resp, err)
})
router.Handle("POST", "admin/user/status", func(c *gin.Context) {
select {
case <-c.Request.Context().Done():
@ -86,6 +149,26 @@ func (UnimplementedAdminServant) SiteInfo(req *web.SiteInfoReq) (*web.SiteInfoRe
return nil, mir.Errorln(http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented))
}
func (UnimplementedAdminServant) GetSiteSettings() (*web.SiteSettingsResp, error) {
return nil, mir.Errorln(http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented))
}
func (UnimplementedAdminServant) UpdateSiteSettings(req *web.SiteSettingsReq) (*web.SiteSettingsResp, error) {
return nil, mir.Errorln(http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented))
}
func (UnimplementedAdminServant) GetSettingsSchema() (*web.AdminSettingsSchemaResp, error) {
return nil, mir.Errorln(http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented))
}
func (UnimplementedAdminServant) GetSettingsValues() (*web.AdminSettingsValuesResp, error) {
return nil, mir.Errorln(http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented))
}
func (UnimplementedAdminServant) SaveSettings(req *web.AdminSettingsSaveReq) (*web.AdminSettingsSaveResp, error) {
return nil, mir.Errorln(http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented))
}
func (UnimplementedAdminServant) ChangeUserStatus(req *web.ChangeUserStatusReq) error {
return mir.Errorln(http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented))
}

@ -9,14 +9,13 @@ import (
"github.com/alimy/mir/v5"
"github.com/gin-gonic/gin"
"github.com/rocboss/paopao-ce/internal/conf"
"github.com/rocboss/paopao-ce/internal/model/web"
)
type Site interface {
_default_
Profile() (*conf.WebProfileConf, error)
Profile() (*web.SiteProfileResp, error)
Version() (*web.VersionResp, error)
mustEmbedUnimplementedSiteServant()
@ -52,7 +51,7 @@ func RegisterSiteServant(e *gin.Engine, s Site) {
// UnimplementedSiteServant can be embedded to have forward compatible implementations.
type UnimplementedSiteServant struct{}
func (UnimplementedSiteServant) Profile() (*conf.WebProfileConf, error) {
func (UnimplementedSiteServant) Profile() (*web.SiteProfileResp, error) {
return nil, mir.Errorln(http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented))
}

@ -10,8 +10,8 @@ import (
var rootCmd = &cobra.Command{
Use: "paopao",
Short: `an artistic "twitter like" community`,
Long: `an artistic "twitter like" community`,
Short: `A scalable social community platform powered by Gin backend and modern TypeScript/Vue frontend architecture`,
Long: `A scalable social community platform powered by Gin backend and modern TypeScript/Vue frontend architecture`,
}
// Setup set root command name,short-describe, long-describe

@ -19,6 +19,7 @@ import (
"github.com/rocboss/paopao-ce/internal"
"github.com/rocboss/paopao-ce/internal/conf"
"github.com/rocboss/paopao-ce/internal/service"
"github.com/rocboss/paopao-ce/internal/sitesetting"
"github.com/rocboss/paopao-ce/pkg/debug"
"github.com/rocboss/paopao-ce/pkg/utils"
"github.com/rocboss/paopao-ce/pkg/version"
@ -67,6 +68,7 @@ func serveRun(_cmd *cobra.Command, _args []string) {
defer shutdownFn()
}
internal.Initial()
sitesetting.Bootstrap(_cmd.Context(), conf.MustGormDB())
ss := service.MustInitService()
if len(ss) < 1 {
fmt.Fprintln(color.Output, "no service need start so just exit")

@ -1,200 +1,59 @@
App: # APP基础设置项
# Minimal bootstrap config for first startup.
# Most product and operational settings are now managed in the admin UI:
# /#/admin/settings
#
# Loading behavior:
# - Built-in defaults are embedded in the binary
# - This file only overrides the bootstrap-critical parts
# - Ports / DB / Redis / feature selection stay file-based
App:
RunMode: debug
AttachmentIncomeRate: 0.8
MaxCommentCount: 10
DefaultContextTimeout: 60
DefaultPageSize: 10
MaxPageSize: 100
Server: # 服务设置
RunMode: debug
HttpIp: 0.0.0.0
HttpPort: 8010
ReadTimeout: 60
WriteTimeout: 60
Features:
Default: ["Web", "Frontend:EmbedWeb", "Meili", "LocalOSS", "MySQL", "BigCacheIndex", "LoggerFile"]
Develop: ["Base", "MySQL", "BigCacheIndex", "Meili", "Sms", "AliOSS", "LoggerMeili", "OSS:Retention"]
Demo: ["Base", "MySQL", "Option", "Zinc", "Sms", "MinIO", "LoggerZinc", "Migration"]
Slim: ["Base", "Sqlite3", "LocalOSS", "LoggerFile", "OSS:TempDir"]
Base: ["Redis", "PhoneBind"]
Docs: ["Docs:OpenAPI"]
Deprecated: ["Deprecated:OldWeb"]
Service: ["Web", "Admin", "SpaceX", "Bot", "LocalOSS", "Mobile", "Frontend:Web", "Frontend:EmbedWeb", "Docs"]
Option: ["SimpleCacheIndex"]
Sms: "SmsJuhe"
WebServer: # Web服务
Default: ["Web", "Frontend:EmbedWeb", "Meili", "LocalOSS", "MySQL", "BigCacheIndex", "LoggerFile"]
WebServer:
HttpIp: 0.0.0.0
HttpPort: 8008
ReadTimeout: 60
WriteTimeout: 60
AdminServer: # Admin后台运维服务
HttpIp: 0.0.0.0
HttpPort: 8014
ReadTimeout: 60
WriteTimeout: 60
SpaceXServer: # SpaceX服务
HttpIp: 0.0.0.0
HttpPort: 8012
ReadTimeout: 60
WriteTimeout: 60
BotServer: # Bot服务
HttpIp: 0.0.0.0
HttpPort: 8016
ReadTimeout: 60
WriteTimeout: 60
LocalossServer: # Localoss服务
HttpIp: 0.0.0.0
HttpPort: 8018
ReadTimeout: 60
WriteTimeout: 60
FrontendWebServer: # Web前端服务
HttpIp: 0.0.0.0
HttpPort: 8006
ReadTimeout: 60
WriteTimeout: 60
DocsServer: # 开发文档服务
HttpIp: 0.0.0.0
HttpPort: 8011
ReadTimeout: 60
WriteTimeout: 60
MobileServer: # 移动端grpc api服务
Host: 0.0.0.0
Port: 8020
SmsJuhe:
Gateway: https://v.juhe.cn/sms/send
Key:
TplID:
TplVal: "#code#=%s&#m#=%d"
Alipay:
AppID:
InProduction: True
RootCertFile: "custom/alipay/RootCert.crt"
PublicCertFile: "custom/alipay/CertPublicKey_RSA2.crt"
AppPublicCertFile: "custom/alipay/AppCertPublicKey.crt"
CacheIndex:
MaxUpdateQPS: 100 # 最大添加/删除/更新Post的QPS, 设置范围[10, 10000], 默认100
SimpleCacheIndex: # 缓存泡泡广场消息流
MaxIndexSize: 200 # 最大缓存条数
CheckTickDuration: 60 # 循环自检查每多少秒一次
ExpireTickDuration: 300 # 每多少秒后强制过期缓存, 设置为0禁止强制使缓存过期
BigCacheIndex: # 使用BigCache缓存泡泡广场消息流
MaxIndexPage: 1024 # 最大缓存页数必须是2^n, 代表最大同时缓存多少页数据
Verbose: False # 是否打印cache操作的log
ExpireInSecond: 300 # 多少秒(>0)后强制过期缓存
Logger: # 日志通用配置
Level: debug # 日志级别 panic|fatal|error|warn|info|debug|trace
LoggerFile: # 使用File写日志
SavePath: custom/data/paopao-ce/logs
FileName: app
FileExt: .log
LoggerOtlp: # 使用OpenTelemetry写日志
Endpoint: openobserve:5081
Authorization: Basic ls8icEBvcGVub2JzFXJ2ZS6haCpZTU4ybGdBUFlXcjA0UdNk
Organization: paopao-ce
TraceStream: paopao-trace
MetricStream: paopao-metric
LogStream: paopao-log
Insecure: true
JWT: # 鉴权加密
Secret: 18a6413dc4fe394c66345ebe501b2f26
Issuer: paopao-api
Expire: 86400
TweetSearch: # 推文关键字搜索相关配置
MaxUpdateQPS: 100 # 最大添加/删除/更新Post的QPS设置范围[10, 10000], 默认100
MinWorker: 10 # 最小后台更新工作者, 设置范围[5, 1000], 默认10
Zinc: # Zinc搜索配置
Host: zinc:4080
Index: paopao-data
User: admin
Password: admin
Secure: False
Meili: # Meili搜索配置
Host: meili:7700
Index: paopao-data
ApiKey: paopao-meilisearch
Secure: False
ObjectStorage: # 对象存储通用配置
RetainInDays: 2 # 临时对象过期时间多少天
TempDir: tmp # 临时对象存放目录名
AliOSS: # 阿里云OSS存储配置
Endpoint:
AccessKeyID:
AccessKeySecret:
Bucket:
Domain:
COS: # 腾讯云COS存储配置
SecretID:
SecretKey:
Region: ap-shanghai
Bucket: demo-1888888888
Domain:
HuaweiOBS: # 华为云OBS存储配置
AccessKey:
SecretKey:
Endpoint:
Bucket: paopao
Domain:
MinIO: # MinIO 存储配置
AccessKey: Q3AM3UQ867SPQQA43P2F
SecretKey: zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG
Secure: False
Endpoint: minio:9000
Bucket: paopao
Domain: 127.0.0.1:9000
S3: # Amazon S3 存储配置
AccessKey: "YOUR-ACCESSKEYID"
SecretKey: "YOUR-SECRETACCESSKEY"
Secure: True
Endpoint: s3.amazonaws.com
Bucket: paopao
Domain:
LocalOSS: # 本地文件OSS存储配置
SavePath: custom/data/paopao-ce/oss
Secure: False
Bucket: paopao
Domain: 127.0.0.1:8008
Database: # Database通用配置
LogLevel: error # 日志级别 silent|error|warn|info
TablePrefix: p_ # 表名前缀
MySQL: # MySQL数据库
Database:
LogLevel: error
TablePrefix: p_
MySQL:
Username: paopao
Password: paopao
Host: db:3306
DBName: paopao
Charset: utf8mb4
ParseTime: True
ParseTime: true
MaxIdleConns: 10
MaxOpenConns: 30
Postgres: # PostgreSQL数据库
User: paopao
Password: paopao
DBName: paopao
Host: localhost
Port: 5432
SSLMode: disable
TimeZone: Asia/Shanghai
Sqlite3: # Sqlite3数据库
Path: custom/data/sqlite3/paopao-ce.db
Redis:
InitAddress:
- redis:6379
WebProfile:
UseFriendship: true # 前端是否使用好友体系
EnableTrendsBar: false # 广场页面是否开启动态条栏功能
EnableWallet: false # 是否开启钱包功能
AllowTweetAttachment: true # 是否允许推文附件
AllowTweetAttachmentPrice: true # 是否允许推文付费附件
AllowTweetVideo: true # 是否允许视频推文
AllowUserRegister: true # 是否允许用户注册
AllowPhoneBind: true # 是否允许手机绑定
DefaultTweetMaxLength: 2000 # 推文允许输入的最大长度, 默认2000字值的范围需要查询后端支持的最大字数
TweetWebEllipsisSize: 400 # Web端推文作为feed显示的最长字数默认400字
TweetMobileEllipsisSize: 300 # 移动端推文作为feed显示的最长字数默认300字
DefaultTweetVisibility: friend # 推文可见性,默认好友可见 值: public/following/friend/private
DefaultMsgLoopInterval: 5000 # 拉取未读消息的间隔,单位:毫秒, 默认5000ms
CopyrightTop: "2023 paopao.info"
CopyrightLeft: "Roc's Me"
CopyrightLeftLink: ""
CopyrightRight: "泡泡(PaoPao)开源社区"
CopyrightRightLink: "https://www.paopao.info"
- redis:6379
JWT:
Secret: 18a6413dc4fe394c66345ebe501b2f26
Issuer: paopao-api
Expire: 86400
AdminSettings:
EncryptionKey: "CHANGE-ME-TO-A-LONG-RANDOM-SECRET"
# Optional first-boot overrides for the providers enabled in Features.Default.
# If the embedded defaults already match your environment, you can remove these
# and manage later adjustments from /#/admin/settings.
Meili:
Host: meili:7700
Index: paopao-data
ApiKey: paopao-meilisearch
Secure: false
LocalOSS:
SavePath: custom/data/paopao-ce/oss
Secure: false
Bucket: paopao
Domain: 127.0.0.1:8008

@ -1,71 +1,51 @@
App: # APP基础设置项
# Minimal Docker bootstrap config.
# The container still starts from embedded defaults; this file keeps only the
# startup-critical overrides for the bundled Docker environment.
App:
RunMode: debug
AttachmentIncomeRate: 0.8
MaxCommentCount: 10
DefaultContextTimeout: 60
DefaultPageSize: 10
MaxPageSize: 100
Features:
Default: ["Web", "Frontend:EmbedWeb", "Meili", "LocalOSS", "Sqlite3", "LoggerFile", "Migration"]
Sms: "SmsJuhe"
WebServer: # Web服务
Default: ["Web", "Frontend:EmbedWeb", "Meili", "LocalOSS", "MySQL", "LoggerFile", "Migration"]
WebServer:
HttpIp: 0.0.0.0
HttpPort: 8008
ReadTimeout: 60
WriteTimeout: 60
SmsJuhe:
Gateway: https://v.juhe.cn/sms/send
Key:
TplID:
TplVal: "#code#=%s&#m#=%d"
Logger: # 日志通用配置
Level: debug # 日志级别 panic|fatal|error|warn|info|debug|trace
LoggerFile: # 使用File写日志
SavePath: custom/logs
FileName: app
FileExt: .log
JWT: # 鉴权加密
Database:
LogLevel: error
TablePrefix: p_
MySQL: # MySQL数据库
Username: paopao_new # 填写你的数据库账号
Password: YYwanPQz4sanEZBd # 填写你的数据库密码
Host: 10.10.10.199:8306
DBName: paopao_new
Charset: utf8mb4
ParseTime: True
MaxIdleConns: 10
MaxOpenConns: 30
Redis:
InitAddress:
- 127.0.0.1:6379
JWT:
Secret: 18a6413dc4fe394c66345ebe501b2f26
Issuer: paopao-api
Expire: 86400
Meili: # Meili搜索配置
AdminSettings:
EncryptionKey: "CHANGE-ME-TO-A-LONG-RANDOM-SECRET"
Meili:
Host: 127.0.0.1:7700
Index: paopao-data
ApiKey: paopao-meilisearch
Secure: False
ObjectStorage: # 对象存储通用配置
RetainInDays: 2 # 临时对象过期时间多少天
TempDir: tmp # 临时对象存放目录名
LocalOSS: # 本地文件OSS存储配置
Secure: false
LocalOSS:
SavePath: custom/oss
Secure: False
Secure: false
Bucket: paopao
Domain: 127.0.0.1:8008
Database: # Database通用配置
LogLevel: error # 日志级别 silent|error|warn|info
TablePrefix: p_ # 表名前缀
Sqlite3: # Sqlite3数据库
Path: custom/paopao-ce.db
Redis:
InitAddress:
- 127.0.0.1:6379
WebProfile:
UseFriendship: true # 前端是否使用好友体系
EnableTrendsBar: true # 广场页面是否开启动态条栏功能
EnableWallet: false # 是否开启钱包功能
AllowTweetAttachment: true # 是否允许推文附件
AllowTweetAttachmentPrice: true # 是否允许推文付费附件
AllowTweetVideo: true # 是否允许视频推文
AllowUserRegister: true # 是否允许用户注册
AllowPhoneBind: true # 是否允许手机绑定
DefaultTweetMaxLength: 2000 # 推文允许输入的最大长度, 默认2000字值的范围需要查询后端支持的最大字数
TweetWebEllipsisSize: 400 # Web端推文作为feed显示的最长字数默认400字
TweetMobileEllipsisSize: 300 # 移动端推文作为feed显示的最长字数默认300字
DefaultTweetVisibility: friend # 推文可见性,默认好友可见 值: public/following/friend/private
DefaultMsgLoopInterval: 5000 # 拉取未读消息的间隔,单位:毫秒, 默认5000ms
CopyrightTop: "2023 paopao.info"
CopyrightLeft: "Roc's Me"
CopyrightLeftLink: ""
CopyrightRight: "泡泡(PaoPao)开源社区"
CopyrightRightLink: "https://www.paopao.info"

@ -58,6 +58,7 @@ var (
S3Setting *s3Conf
LocalOSSSetting *localossConf
JWTSetting *jwtConf
AdminSettingsSetting *adminSettingsConf
WebProfileSetting *WebProfileConf
)
@ -113,6 +114,7 @@ func setupSetting(suite []string, noDefault bool) error {
"Meili": &MeiliSetting,
"Redis": &redisSetting,
"JWT": &JWTSetting,
"AdminSettings": &AdminSettingsSetting,
"ObjectStorage": &ObjectStorage,
"AliOSS": &AliOSSSetting,
"COS": &COSSetting,

@ -172,6 +172,8 @@ JWT: # 鉴权加密
Secret: 18a6413dc4fe394c66345ebe501b2f26
Issuer: paopao-api
Expire: 86400
AdminSettings:
EncryptionKey:
TweetSearch: # 推文关键字搜索相关配置
MaxUpdateQPS: 100 # 最大添加/删除/更新Post的QPS设置范围[10, 10000], 默认100
MinWorker: 10 # 最小后台更新工作者, 设置范围[5, 1000], 默认10
@ -270,8 +272,8 @@ WebProfile:
TweetMobileEllipsisSize: 300 # 移动端推文作为feed显示的最长字数默认300字
DefaultTweetVisibility: friend # 推文默认可见性,默认好友可见 值: public/following/friend/private
DefaultMsgLoopInterval: 5000 # 拉取未读消息的间隔,单位:毫秒, 默认5000ms
CopyrightTop: "2023 paopao.info"
CopyrightLeft: "Roc's Me"
CopyrightTop: "2026 PaoPao"
CopyrightLeft: ""
CopyrightLeftLink: ""
CopyrightRight: "泡泡(PaoPao)开源社区"
CopyrightRightLink: "https://www.paopao.info"
CopyrightRightLink: "https://github.com/rocboss/paopao-ce"

@ -39,6 +39,7 @@ const (
TablePostContent = "post_content"
TablePostStar = "post_star"
TableTag = "tag"
TableSiteSettings = "site_settings"
TableUser = "user"
TableUserRelation = "user_relation"
TableUserMetric = "user_metric"

@ -287,6 +287,10 @@ type redisConf struct {
ConnWriteTimeout time.Duration
}
type adminSettingsConf struct {
EncryptionKey string
}
type jwtConf struct {
Secret string
Issuer string

@ -5,7 +5,8 @@
package web
import (
"github.com/rocboss/paopao-ce/internal/conf"
"encoding/json"
"github.com/rocboss/paopao-ce/pkg/version"
)
@ -13,4 +14,101 @@ type VersionResp struct {
BuildInfo *version.BuildInfo `json:"build_info"`
}
type SiteProfileResp = conf.WebProfileConf
type SiteProfileResp struct {
UseFriendship bool `json:"use_friendship"`
EnableTrendsBar bool `json:"enable_trends_bar"`
EnableWallet bool `json:"enable_wallet"`
AllowTweetAttachment bool `json:"allow_tweet_attachment"`
AllowTweetAttachmentPrice bool `json:"allow_tweet_attachment_price"`
AllowTweetVideo bool `json:"allow_tweet_video"`
AllowUserRegister bool `json:"allow_user_register"`
AllowPhoneBind bool `json:"allow_phone_bind"`
DefaultTweetMaxLength int `json:"default_tweet_max_length"`
TweetWebEllipsisSize int `json:"tweet_web_ellipsis_size"`
TweetMobileEllipsisSize int `json:"tweet_mobile_ellipsis_size"`
DefaultTweetVisibility string `json:"default_tweet_visibility"`
DefaultMsgLoopInterval int `json:"default_msg_loop_interval"`
CopyrightTop string `json:"copyright_top"`
CopyrightLeft string `json:"copyright_left"`
CopyrightLeftLink string `json:"copyright_left_link"`
CopyrightRight string `json:"copyright_right"`
CopyrightRightLink string `json:"copyright_right_link"`
}
type SiteSettingsReq struct {
BaseInfo `json:"-" binding:"-"`
UseFriendship *bool `json:"use_friendship" binding:"required"`
EnableTrendsBar *bool `json:"enable_trends_bar" binding:"required"`
EnableWallet *bool `json:"enable_wallet" binding:"required"`
AllowTweetAttachment *bool `json:"allow_tweet_attachment" binding:"required"`
AllowTweetAttachmentPrice *bool `json:"allow_tweet_attachment_price" binding:"required"`
AllowTweetVideo *bool `json:"allow_tweet_video" binding:"required"`
DefaultTweetMaxLength *int `json:"default_tweet_max_length" binding:"required,gte=1,lte=2000"`
TweetWebEllipsisSize *int `json:"tweet_web_ellipsis_size" binding:"required,gte=1,lte=2000"`
TweetMobileEllipsisSize *int `json:"tweet_mobile_ellipsis_size" binding:"required,gte=1,lte=2000"`
DefaultTweetVisibility *string `json:"default_tweet_visibility" binding:"required,oneof=public following friend private"`
DefaultMsgLoopInterval *int `json:"default_msg_loop_interval" binding:"required,gte=1000,lte=60000"`
CopyrightTop *string `json:"copyright_top" binding:"required,max=255"`
CopyrightLeft *string `json:"copyright_left" binding:"required,max=255"`
CopyrightLeftLink *string `json:"copyright_left_link" binding:"omitempty,max=255,url"`
CopyrightRight *string `json:"copyright_right" binding:"required,max=255"`
CopyrightRightLink *string `json:"copyright_right_link" binding:"omitempty,max=255,url"`
}
type SiteSettingsResp struct {
SiteProfileResp
ReadonlyFields []string `json:"readonly_fields"`
}
type AdminSettingValueInput struct {
Key string `json:"key" binding:"required"`
Value json.RawMessage `json:"value"`
}
type AdminSettingsSaveReq struct {
BaseInfo `json:"-" binding:"-"`
Items []AdminSettingValueInput `json:"items" binding:"required"`
}
type AdminSettingSchemaItem struct {
Key string `json:"key"`
Group string `json:"group"`
Section string `json:"section"`
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description"`
ApplyMode string `json:"apply_mode"`
Secret bool `json:"secret"`
Readonly bool `json:"readonly"`
Active bool `json:"active"`
BootstrapValue any `json:"bootstrap_value,omitempty"`
BootstrapConfigured bool `json:"bootstrap_configured,omitempty"`
Options any `json:"options,omitempty"`
}
type AdminSettingsSchemaResp struct {
Items []AdminSettingSchemaItem `json:"items"`
}
type AdminSettingValue struct {
Key string `json:"key"`
Value any `json:"value,omitempty"`
EffectiveValue any `json:"effective_value,omitempty"`
Source string `json:"source"`
PendingRestart bool `json:"pending_restart"`
Configured bool `json:"configured,omitempty"`
Active bool `json:"active"`
}
type AdminSettingsValuesResp struct {
Items []AdminSettingValue `json:"items"`
HasPendingRestart bool `json:"has_pending_restart"`
}
type AdminSettingsSaveResp struct {
Items []AdminSettingValue `json:"items"`
UpdatedKeys []string `json:"updated_keys"`
HasPendingRestart bool `json:"has_pending_restart"`
}

@ -5,6 +5,7 @@
package web
import (
"context"
"time"
"github.com/gin-gonic/gin"
@ -14,6 +15,7 @@ import (
"github.com/rocboss/paopao-ce/internal/model/web"
"github.com/rocboss/paopao-ce/internal/servants/base"
"github.com/rocboss/paopao-ce/internal/servants/chain"
"github.com/rocboss/paopao-ce/internal/sitesetting"
"github.com/rocboss/paopao-ce/pkg/xerror"
"github.com/sirupsen/logrus"
)
@ -22,6 +24,7 @@ type adminSrv struct {
api.UnimplementedAdminServant
*base.DaoServant
wc core.WebCache
settings *sitesetting.Service
serverUpTime int64
}
@ -61,10 +64,45 @@ func (s *adminSrv) SiteInfo(req *web.SiteInfoReq) (*web.SiteInfoResp, error) {
return res, nil
}
func newAdminSrv(s *base.DaoServant, wc core.WebCache) api.Admin {
func (s *adminSrv) GetSiteSettings() (*web.SiteSettingsResp, error) {
profile, err := s.settings.GetProfile(context.Background())
if err != nil {
return nil, err
}
return &web.SiteSettingsResp{
SiteProfileResp: *profile,
ReadonlyFields: sitesetting.CloneReadonlyFields(),
}, nil
}
func (s *adminSrv) UpdateSiteSettings(req *web.SiteSettingsReq) (*web.SiteSettingsResp, error) {
profile, err := s.settings.UpdateEditableProfile(context.Background(), sitesetting.EditableFromRequest(req))
if err != nil {
return nil, err
}
return &web.SiteSettingsResp{
SiteProfileResp: *profile,
ReadonlyFields: sitesetting.CloneReadonlyFields(),
}, nil
}
func (s *adminSrv) GetSettingsSchema() (*web.AdminSettingsSchemaResp, error) {
return s.settings.GetSchema()
}
func (s *adminSrv) GetSettingsValues() (*web.AdminSettingsValuesResp, error) {
return s.settings.GetValues(context.Background())
}
func (s *adminSrv) SaveSettings(req *web.AdminSettingsSaveReq) (*web.AdminSettingsSaveResp, error) {
return s.settings.SaveValues(context.Background(), req.Items)
}
func newAdminSrv(s *base.DaoServant, wc core.WebCache, settings *sitesetting.Service) api.Admin {
return &adminSrv{
DaoServant: s,
wc: wc,
settings: settings,
serverUpTime: time.Now().Unix(),
}
}

@ -5,20 +5,23 @@
package web
import (
"context"
api "github.com/rocboss/paopao-ce/auto/api/v1"
"github.com/rocboss/paopao-ce/internal/conf"
"github.com/rocboss/paopao-ce/internal/model/web"
"github.com/rocboss/paopao-ce/internal/servants/base"
"github.com/rocboss/paopao-ce/internal/sitesetting"
"github.com/rocboss/paopao-ce/pkg/version"
)
type siteSrv struct {
api.UnimplementedSiteServant
*base.BaseServant
settings *sitesetting.Service
}
func (*siteSrv) Profile() (*web.SiteProfileResp, error) {
return conf.WebProfileSetting, nil
func (s *siteSrv) Profile() (*web.SiteProfileResp, error) {
return s.settings.GetProfile(context.Background())
}
func (*siteSrv) Version() (*web.VersionResp, error) {
@ -27,8 +30,9 @@ func (*siteSrv) Version() (*web.VersionResp, error) {
}, nil
}
func newSiteSrv() api.Site {
func newSiteSrv(settings *sitesetting.Service) api.Site {
return &siteSrv{
BaseServant: base.NewBaseServant(),
settings: settings,
}
}

@ -15,6 +15,7 @@ import (
"github.com/rocboss/paopao-ce/internal/dao"
"github.com/rocboss/paopao-ce/internal/dao/cache"
"github.com/rocboss/paopao-ce/internal/servants/base"
"github.com/rocboss/paopao-ce/internal/sitesetting"
)
var (
@ -24,6 +25,7 @@ var (
_ac core.AppCache
_wc core.WebCache
_oss core.ObjectStorageService
_siteSettings *sitesetting.Service
_onceInitial sync.Once
)
@ -32,7 +34,7 @@ func RouteWeb(e *gin.Engine) {
lazyInitial()
ds := base.NewDaoServant()
// aways register servants
api.RegisterAdminServant(e, newAdminSrv(ds, _wc))
api.RegisterAdminServant(e, newAdminSrv(ds, _wc, _siteSettings))
api.RegisterCoreServant(e, newCoreSrv(ds, _oss, _wc))
api.RegisterRelaxServant(e, newRelaxSrv(ds, _wc), newRelaxChain())
api.RegisterLooseServant(e, newLooseSrv(ds, _ac))
@ -41,7 +43,7 @@ func RouteWeb(e *gin.Engine) {
api.RegisterTrendsServant(e, newTrendsSrv(ds))
api.RegisterFollowshipServant(e, newFollowshipSrv(ds))
api.RegisterFriendshipServant(e, newFriendshipSrv(ds))
api.RegisterSiteServant(e, newSiteSrv())
api.RegisterSiteServant(e, newSiteSrv(_siteSettings))
// regster servants if needed by configure
cfg.Be("Alipay", func() {
client := conf.MustAlipayClient()
@ -63,5 +65,6 @@ func lazyInitial() {
_ds = dao.DataService()
_ac = cache.NewAppCache()
_wc = cache.NewWebCache()
_siteSettings = sitesetting.NewService(conf.MustGormDB())
})
}

@ -0,0 +1,98 @@
// Copyright 2026 ROC. All rights reserved.
// Use of this source code is governed by a MIT style
// license that can be found in the LICENSE file.
package sitesetting
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"strings"
"github.com/rocboss/paopao-ce/internal/conf"
"github.com/rocboss/paopao-ce/pkg/xerror"
)
const encryptedValuePrefix = "enc:v1:"
type secretCodec struct {
key []byte
}
func newSecretCodec() *secretCodec {
if conf.AdminSettingsSetting == nil {
return &secretCodec{}
}
seed := strings.TrimSpace(conf.AdminSettingsSetting.EncryptionKey)
if seed == "" {
return &secretCodec{}
}
sum := sha256.Sum256([]byte(seed))
return &secretCodec{key: sum[:]}
}
func (c *secretCodec) enabled() bool {
return len(c.key) == 32
}
func (c *secretCodec) Encrypt(value string) (string, error) {
if value == "" {
return "", nil
}
if !c.enabled() {
return "", xerror.ServerError.WithDetails("admin settings encryption key is not configured")
}
block, err := aes.NewCipher(c.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nil, nonce, []byte(value), nil)
payload := append(nonce, ciphertext...)
return encryptedValuePrefix + base64.StdEncoding.EncodeToString(payload), nil
}
func (c *secretCodec) Decrypt(value string) (string, error) {
if value == "" {
return "", nil
}
if !strings.HasPrefix(value, encryptedValuePrefix) {
return value, nil
}
if !c.enabled() {
return "", xerror.ServerError.WithDetails("admin settings encryption key is not configured")
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, encryptedValuePrefix))
if err != nil {
return "", fmt.Errorf("decode encrypted setting: %w", err)
}
block, err := aes.NewCipher(c.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", xerror.ServerError.WithDetails("invalid encrypted setting payload")
}
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

@ -0,0 +1,688 @@
// Copyright 2026 ROC. All rights reserved.
// Use of this source code is governed by a MIT style
// license that can be found in the LICENSE file.
package sitesetting
import (
stdjson "encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/alimy/tryst/cfg"
"github.com/rocboss/paopao-ce/internal/conf"
"github.com/rocboss/paopao-ce/pkg/xerror"
"github.com/sirupsen/logrus"
)
type ValueType string
type ApplyMode string
const (
TypeBool ValueType = "bool"
TypeInt ValueType = "int"
TypeFloat ValueType = "float"
TypeString ValueType = "string"
ApplyModeLive ApplyMode = "live"
ApplyModeRestartRequired ApplyMode = "restart_required"
ApplyModeBootstrapOnly ApplyMode = "bootstrap_only"
)
type bootstrapSnapshot struct {
App confAppSnapshot
TweetSearch confTweetSearchSnapshot
Zinc confZincSnapshot
Meili confMeiliSnapshot
ObjectStorage confObjectStorageSnapshot
AliOSS confAliOSSSnapshot
COS confCOSSnapshot
HuaweiOBS confHuaweiOBSSnapshot
MinIO confMinIOSnapshot
S3 confS3Snapshot
LocalOSS confLocalOSSSnapshot
SmsJuhe confSmsJuheSnapshot
Alipay confAlipaySnapshot
WebProfile confWebProfileSnapshot
}
type confAppSnapshot struct {
AttachmentIncomeRate float64
MaxCommentCount int64
MaxWhisperDaily int64
MaxCaptchaTimes int
DefaultPageSize int
MaxPageSize int
}
type confTweetSearchSnapshot struct {
MaxUpdateQPS int
MinWorker int
}
type confZincSnapshot struct {
Host string
Index string
User string
Password string
Secure bool
}
type confMeiliSnapshot struct {
Host string
Index string
ApiKey string
Secure bool
}
type confObjectStorageSnapshot struct {
RetainInDays int
TempDir string
}
type confAliOSSSnapshot struct {
Endpoint string
AccessKeyID string
AccessKeySecret string
Bucket string
Domain string
}
type confCOSSnapshot struct {
SecretID string
SecretKey string
Region string
Bucket string
Domain string
}
type confHuaweiOBSSnapshot struct {
AccessKey string
SecretKey string
Endpoint string
Bucket string
Domain string
}
type confMinIOSnapshot struct {
AccessKey string
SecretKey string
Secure bool
Endpoint string
Bucket string
Domain string
}
type confS3Snapshot struct {
AccessKey string
SecretKey string
Secure bool
Endpoint string
Bucket string
Domain string
}
type confLocalOSSSnapshot struct {
SavePath string
Secure bool
Bucket string
Domain string
}
type confSmsJuheSnapshot struct {
Gateway string
Key string
TplID string
TplVal string
}
type confAlipaySnapshot struct {
AppID string
PrivateKey string
RootCertFile string
PublicCertFile string
AppPublicCertFile string
InProduction bool
}
type confWebProfileSnapshot struct {
UseFriendship bool
EnableTrendsBar bool
EnableWallet bool
AllowTweetAttachment bool
AllowTweetAttachmentPrice bool
AllowTweetVideo bool
AllowUserRegister bool
AllowPhoneBind bool
DefaultTweetMaxLength int
TweetWebEllipsisSize int
TweetMobileEllipsisSize int
DefaultTweetVisibility string
DefaultMsgLoopInterval int
CopyrightTop string
CopyrightLeft string
CopyrightLeftLink string
CopyrightRight string
CopyrightRightLink string
}
type Option struct {
Label string `json:"label"`
Value any `json:"value"`
}
type Definition struct {
Key string
Group string
Section string
Type ValueType
Label string
Description string
ApplyMode ApplyMode
Secret bool
Readonly bool
Options []Option
Active func() bool
CurrentValue func() any
BootstrapDefault func() any
ValidateValue func(any) (any, error)
BootstrapOverride func(any)
}
var (
ReadonlyFields = []string{"allow_user_register", "allow_phone_bind"}
bootstrapConfig *bootstrapSnapshot
visibilityOption = []Option{{Label: "Public", Value: "public"}, {Label: "Following", Value: "following"}, {Label: "Friend", Value: "friend"}, {Label: "Private", Value: "private"}}
)
func ensureBootstrapSnapshot() {
if bootstrapConfig != nil {
return
}
bootstrapConfig = &bootstrapSnapshot{}
if conf.AppSetting != nil {
bootstrapConfig.App = confAppSnapshot{
AttachmentIncomeRate: conf.AppSetting.AttachmentIncomeRate,
MaxCommentCount: conf.AppSetting.MaxCommentCount,
MaxWhisperDaily: conf.AppSetting.MaxWhisperDaily,
MaxCaptchaTimes: conf.AppSetting.MaxCaptchaTimes,
DefaultPageSize: conf.AppSetting.DefaultPageSize,
MaxPageSize: conf.AppSetting.MaxPageSize,
}
}
if conf.TweetSearchSetting != nil {
bootstrapConfig.TweetSearch = confTweetSearchSnapshot{MaxUpdateQPS: conf.TweetSearchSetting.MaxUpdateQPS, MinWorker: conf.TweetSearchSetting.MinWorker}
}
if conf.ZincSetting != nil {
bootstrapConfig.Zinc = confZincSnapshot{Host: conf.ZincSetting.Host, Index: conf.ZincSetting.Index, User: conf.ZincSetting.User, Password: conf.ZincSetting.Password, Secure: conf.ZincSetting.Secure}
}
if conf.MeiliSetting != nil {
bootstrapConfig.Meili = confMeiliSnapshot{Host: conf.MeiliSetting.Host, Index: conf.MeiliSetting.Index, ApiKey: conf.MeiliSetting.ApiKey, Secure: conf.MeiliSetting.Secure}
}
if conf.ObjectStorage != nil {
bootstrapConfig.ObjectStorage = confObjectStorageSnapshot{RetainInDays: conf.ObjectStorage.RetainInDays, TempDir: conf.ObjectStorage.TempDir}
}
if conf.AliOSSSetting != nil {
bootstrapConfig.AliOSS = confAliOSSSnapshot{Endpoint: conf.AliOSSSetting.Endpoint, AccessKeyID: conf.AliOSSSetting.AccessKeyID, AccessKeySecret: conf.AliOSSSetting.AccessKeySecret, Bucket: conf.AliOSSSetting.Bucket, Domain: conf.AliOSSSetting.Domain}
}
if conf.COSSetting != nil {
bootstrapConfig.COS = confCOSSnapshot{SecretID: conf.COSSetting.SecretID, SecretKey: conf.COSSetting.SecretKey, Region: conf.COSSetting.Region, Bucket: conf.COSSetting.Bucket, Domain: conf.COSSetting.Domain}
}
if conf.HuaweiOBSSetting != nil {
bootstrapConfig.HuaweiOBS = confHuaweiOBSSnapshot{AccessKey: conf.HuaweiOBSSetting.AccessKey, SecretKey: conf.HuaweiOBSSetting.SecretKey, Endpoint: conf.HuaweiOBSSetting.Endpoint, Bucket: conf.HuaweiOBSSetting.Bucket, Domain: conf.HuaweiOBSSetting.Domain}
}
if conf.MinIOSetting != nil {
bootstrapConfig.MinIO = confMinIOSnapshot{AccessKey: conf.MinIOSetting.AccessKey, SecretKey: conf.MinIOSetting.SecretKey, Secure: conf.MinIOSetting.Secure, Endpoint: conf.MinIOSetting.Endpoint, Bucket: conf.MinIOSetting.Bucket, Domain: conf.MinIOSetting.Domain}
}
if conf.S3Setting != nil {
bootstrapConfig.S3 = confS3Snapshot{AccessKey: conf.S3Setting.AccessKey, SecretKey: conf.S3Setting.SecretKey, Secure: conf.S3Setting.Secure, Endpoint: conf.S3Setting.Endpoint, Bucket: conf.S3Setting.Bucket, Domain: conf.S3Setting.Domain}
}
if conf.LocalOSSSetting != nil {
bootstrapConfig.LocalOSS = confLocalOSSSnapshot{SavePath: conf.LocalOSSSetting.SavePath, Secure: conf.LocalOSSSetting.Secure, Bucket: conf.LocalOSSSetting.Bucket, Domain: conf.LocalOSSSetting.Domain}
}
if conf.SmsJuheSetting != nil {
bootstrapConfig.SmsJuhe = confSmsJuheSnapshot{Gateway: conf.SmsJuheSetting.Gateway, Key: conf.SmsJuheSetting.Key, TplID: conf.SmsJuheSetting.TplID, TplVal: conf.SmsJuheSetting.TplVal}
}
if conf.AlipaySetting != nil {
bootstrapConfig.Alipay = confAlipaySnapshot{AppID: conf.AlipaySetting.AppID, PrivateKey: conf.AlipaySetting.PrivateKey, RootCertFile: conf.AlipaySetting.RootCertFile, PublicCertFile: conf.AlipaySetting.PublicCertFile, AppPublicCertFile: conf.AlipaySetting.AppPublicCertFile, InProduction: conf.AlipaySetting.InProduction}
}
if conf.WebProfileSetting != nil {
bootstrapConfig.WebProfile = confWebProfileSnapshot{
UseFriendship: conf.WebProfileSetting.UseFriendship,
EnableTrendsBar: conf.WebProfileSetting.EnableTrendsBar,
EnableWallet: conf.WebProfileSetting.EnableWallet,
AllowTweetAttachment: conf.WebProfileSetting.AllowTweetAttachment,
AllowTweetAttachmentPrice: conf.WebProfileSetting.AllowTweetAttachmentPrice,
AllowTweetVideo: conf.WebProfileSetting.AllowTweetVideo,
AllowUserRegister: conf.WebProfileSetting.AllowUserRegister,
AllowPhoneBind: conf.WebProfileSetting.AllowPhoneBind,
DefaultTweetMaxLength: conf.WebProfileSetting.DefaultTweetMaxLength,
TweetWebEllipsisSize: conf.WebProfileSetting.TweetWebEllipsisSize,
TweetMobileEllipsisSize: conf.WebProfileSetting.TweetMobileEllipsisSize,
DefaultTweetVisibility: conf.WebProfileSetting.DefaultTweetVisibility,
DefaultMsgLoopInterval: conf.WebProfileSetting.DefaultMsgLoopInterval,
CopyrightTop: conf.WebProfileSetting.CopyrightTop,
CopyrightLeft: conf.WebProfileSetting.CopyrightLeft,
CopyrightLeftLink: conf.WebProfileSetting.CopyrightLeftLink,
CopyrightRight: conf.WebProfileSetting.CopyrightRight,
CopyrightRightLink: conf.WebProfileSetting.CopyrightRightLink,
}
}
}
func CloneReadonlyFields() []string {
res := make([]string, len(ReadonlyFields))
copy(res, ReadonlyFields)
return res
}
func Registry() []Definition {
ensureBootstrapSnapshot()
return []Definition{
boolDef("web_profile.use_friendship", "web", "profile", "Use friendship", "Switch the frontend friendship model.", ApplyModeLive, false, true, func() any { return conf.WebProfileSetting.UseFriendship }, func() any { return bootstrapConfig.WebProfile.UseFriendship }, func(v any) { conf.WebProfileSetting.UseFriendship = v.(bool) }),
boolDef("web_profile.enable_trends_bar", "web", "profile", "Enable trends bar", "Show the trends sidebar in the web UI.", ApplyModeLive, false, true, func() any { return conf.WebProfileSetting.EnableTrendsBar }, func() any { return bootstrapConfig.WebProfile.EnableTrendsBar }, func(v any) { conf.WebProfileSetting.EnableTrendsBar = v.(bool) }),
boolDef("web_profile.enable_wallet", "web", "profile", "Enable wallet", "Enable wallet-related frontend features.", ApplyModeLive, false, true, func() any { return conf.WebProfileSetting.EnableWallet }, func() any { return bootstrapConfig.WebProfile.EnableWallet }, func(v any) { conf.WebProfileSetting.EnableWallet = v.(bool) }),
boolDef("web_profile.allow_tweet_attachment", "web", "profile", "Allow attachments", "Allow file attachments on posts.", ApplyModeLive, false, true, func() any { return conf.WebProfileSetting.AllowTweetAttachment }, func() any { return bootstrapConfig.WebProfile.AllowTweetAttachment }, func(v any) { conf.WebProfileSetting.AllowTweetAttachment = v.(bool) }),
boolDef("web_profile.allow_tweet_attachment_price", "web", "profile", "Allow paid attachments", "Allow post attachments to have a price.", ApplyModeLive, false, true, func() any { return conf.WebProfileSetting.AllowTweetAttachmentPrice }, func() any { return bootstrapConfig.WebProfile.AllowTweetAttachmentPrice }, func(v any) { conf.WebProfileSetting.AllowTweetAttachmentPrice = v.(bool) }),
boolDef("web_profile.allow_tweet_video", "web", "profile", "Allow video posts", "Allow video uploads on posts.", ApplyModeLive, false, true, func() any { return conf.WebProfileSetting.AllowTweetVideo }, func() any { return bootstrapConfig.WebProfile.AllowTweetVideo }, func(v any) { conf.WebProfileSetting.AllowTweetVideo = v.(bool) }),
boolDef("web_profile.allow_user_register", "web", "profile", "Allow user registration", "Bootstrap-only registration gate from YAML/features.", ApplyModeBootstrapOnly, false, false, func() any { return conf.WebProfileSetting.AllowUserRegister }, func() any { return bootstrapConfig.WebProfile.AllowUserRegister }, nil),
boolDef("web_profile.allow_phone_bind", "web", "profile", "Allow phone binding", "Bootstrap-only phone binding gate from YAML/features.", ApplyModeBootstrapOnly, false, false, func() any { return conf.WebProfileSetting.AllowPhoneBind }, func() any { return bootstrapConfig.WebProfile.AllowPhoneBind }, nil),
intDef("web_profile.default_tweet_max_length", "web", "profile", "Default tweet max length", "Maximum allowed tweet length.", ApplyModeLive, false, true, func() any { return conf.WebProfileSetting.DefaultTweetMaxLength }, func() any { return bootstrapConfig.WebProfile.DefaultTweetMaxLength }, func(v int) error { return between(v, 1, 2000, "default_tweet_max_length") }, func(v any) { conf.WebProfileSetting.DefaultTweetMaxLength = v.(int) }),
intDef("web_profile.tweet_web_ellipsis_size", "web", "profile", "Web ellipsis size", "Truncated feed length on web.", ApplyModeLive, false, true, func() any { return conf.WebProfileSetting.TweetWebEllipsisSize }, func() any { return bootstrapConfig.WebProfile.TweetWebEllipsisSize }, func(v int) error {
return between(v, 1, conf.WebProfileSetting.DefaultTweetMaxLength, "tweet_web_ellipsis_size")
}, func(v any) { conf.WebProfileSetting.TweetWebEllipsisSize = v.(int) }),
intDef("web_profile.tweet_mobile_ellipsis_size", "web", "profile", "Mobile ellipsis size", "Truncated feed length on mobile.", ApplyModeLive, false, true, func() any { return conf.WebProfileSetting.TweetMobileEllipsisSize }, func() any { return bootstrapConfig.WebProfile.TweetMobileEllipsisSize }, func(v int) error {
return between(v, 1, conf.WebProfileSetting.DefaultTweetMaxLength, "tweet_mobile_ellipsis_size")
}, func(v any) { conf.WebProfileSetting.TweetMobileEllipsisSize = v.(int) }),
stringDef("web_profile.default_tweet_visibility", "web", "profile", "Default tweet visibility", "Default visibility for new posts.", ApplyModeLive, false, true, visibilityOption, func() any { return conf.WebProfileSetting.DefaultTweetVisibility }, func() any { return bootstrapConfig.WebProfile.DefaultTweetVisibility }, validateVisibility, func(v any) { conf.WebProfileSetting.DefaultTweetVisibility = v.(string) }),
intDef("web_profile.default_msg_loop_interval", "web", "profile", "Message polling interval", "Polling interval for unread messages in milliseconds.", ApplyModeLive, false, true, func() any { return conf.WebProfileSetting.DefaultMsgLoopInterval }, func() any { return bootstrapConfig.WebProfile.DefaultMsgLoopInterval }, func(v int) error { return between(v, 1000, 60000, "default_msg_loop_interval") }, func(v any) { conf.WebProfileSetting.DefaultMsgLoopInterval = v.(int) }),
stringDef("web_profile.copyright_top", "web", "profile", "Copyright top", "Top copyright line displayed in the UI.", ApplyModeLive, false, true, nil, func() any { return conf.WebProfileSetting.CopyrightTop }, func() any { return bootstrapConfig.WebProfile.CopyrightTop }, validateRequiredTrimmed("copyright_top", 255), func(v any) { conf.WebProfileSetting.CopyrightTop = v.(string) }),
stringDef("web_profile.copyright_left", "web", "profile", "Copyright left", "Left footer label.", ApplyModeLive, false, true, nil, func() any { return conf.WebProfileSetting.CopyrightLeft }, func() any { return bootstrapConfig.WebProfile.CopyrightLeft }, validateRequiredTrimmed("copyright_left", 255), func(v any) { conf.WebProfileSetting.CopyrightLeft = v.(string) }),
stringDef("web_profile.copyright_left_link", "web", "profile", "Copyright left link", "Optional left footer link.", ApplyModeLive, false, true, nil, func() any { return conf.WebProfileSetting.CopyrightLeftLink }, func() any { return bootstrapConfig.WebProfile.CopyrightLeftLink }, validateOptionalURL("copyright_left_link", 255), func(v any) { conf.WebProfileSetting.CopyrightLeftLink = v.(string) }),
stringDef("web_profile.copyright_right", "web", "profile", "Copyright right", "Right footer label.", ApplyModeLive, false, true, nil, func() any { return conf.WebProfileSetting.CopyrightRight }, func() any { return bootstrapConfig.WebProfile.CopyrightRight }, validateRequiredTrimmed("copyright_right", 255), func(v any) { conf.WebProfileSetting.CopyrightRight = v.(string) }),
stringDef("web_profile.copyright_right_link", "web", "profile", "Copyright right link", "Optional right footer link.", ApplyModeLive, false, true, nil, func() any { return conf.WebProfileSetting.CopyrightRightLink }, func() any { return bootstrapConfig.WebProfile.CopyrightRightLink }, validateOptionalURL("copyright_right_link", 255), func(v any) { conf.WebProfileSetting.CopyrightRightLink = v.(string) }),
int64Def("app.max_comment_count", "app", "general", "Max comment count", "Maximum comments allowed on a post.", ApplyModeLive, func() any { return conf.AppSetting.MaxCommentCount }, func() any { return bootstrapConfig.App.MaxCommentCount }, func(v int64) error { return betweenInt64(v, 1, 100000, "max_comment_count") }, func(v any) { conf.AppSetting.MaxCommentCount = v.(int64) }),
floatDef("app.attachment_income_rate", "app", "general", "Attachment income rate", "Revenue share for paid attachments.", ApplyModeLive, func() any { return conf.AppSetting.AttachmentIncomeRate }, func() any { return bootstrapConfig.App.AttachmentIncomeRate }, func(v float64) error {
if v < 0 || v > 1 {
return xerror.InvalidParams.WithDetails("attachment_income_rate must be between 0 and 1")
}
return nil
}, func(v any) { conf.AppSetting.AttachmentIncomeRate = v.(float64) }),
intDef("app.default_page_size", "app", "general", "Default page size", "Default pagination size.", ApplyModeLive, false, true, func() any { return conf.AppSetting.DefaultPageSize }, func() any { return bootstrapConfig.App.DefaultPageSize }, func(v int) error { return between(v, 1, conf.AppSetting.MaxPageSize, "default_page_size") }, func(v any) { conf.AppSetting.DefaultPageSize = v.(int) }),
intDef("app.max_page_size", "app", "general", "Max page size", "Maximum pagination size.", ApplyModeLive, false, true, func() any { return conf.AppSetting.MaxPageSize }, func() any { return bootstrapConfig.App.MaxPageSize }, func(v int) error {
return between(v, maxInt(conf.AppSetting.DefaultPageSize, 1), 1000, "max_page_size")
}, func(v any) { conf.AppSetting.MaxPageSize = v.(int) }),
int64Def("app.max_whisper_daily", "app", "limits", "Max daily whispers", "Daily whisper send limit. Restart required because servants cache it at startup.", ApplyModeRestartRequired, func() any { return conf.AppSetting.MaxWhisperDaily }, func() any { return bootstrapConfig.App.MaxWhisperDaily }, func(v int64) error { return betweenInt64(v, 1, 1000000, "max_whisper_daily") }, func(v any) { conf.AppSetting.MaxWhisperDaily = v.(int64) }),
intDef("app.max_captcha_times", "app", "limits", "Max captcha times", "Max captcha request count cached at startup by web servants.", ApplyModeRestartRequired, false, true, func() any { return conf.AppSetting.MaxCaptchaTimes }, func() any { return bootstrapConfig.App.MaxCaptchaTimes }, func(v int) error { return between(v, 1, 1000, "max_captcha_times") }, func(v any) { conf.AppSetting.MaxCaptchaTimes = v.(int) }),
intDef("tweet_search.max_update_qps", "search", "bridge", "Search update QPS", "Buffered update throughput for search indexing.", ApplyModeRestartRequired, false, true, func() any { return conf.TweetSearchSetting.MaxUpdateQPS }, func() any { return bootstrapConfig.TweetSearch.MaxUpdateQPS }, func(v int) error { return between(v, 10, 10000, "max_update_qps") }, func(v any) { conf.TweetSearchSetting.MaxUpdateQPS = v.(int) }),
intDef("tweet_search.min_worker", "search", "bridge", "Search worker count", "Background worker count for search indexing.", ApplyModeRestartRequired, false, true, func() any { return conf.TweetSearchSetting.MinWorker }, func() any { return bootstrapConfig.TweetSearch.MinWorker }, func(v int) error { return between(v, 5, 1000, "min_worker") }, func(v any) { conf.TweetSearchSetting.MinWorker = v.(int) }),
stringDefWithActive("meili.host", "search", "meili", "Meili host", "Meilisearch host.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("Meili") }, func() any { return conf.MeiliSetting.Host }, func() any { return bootstrapConfig.Meili.Host }, validateTrimmedMax("meili.host", 255), func(v any) { conf.MeiliSetting.Host = v.(string) }),
stringDefWithActive("meili.index", "search", "meili", "Meili index", "Meilisearch index name.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("Meili") }, func() any { return conf.MeiliSetting.Index }, func() any { return bootstrapConfig.Meili.Index }, validateTrimmedMax("meili.index", 255), func(v any) { conf.MeiliSetting.Index = v.(string) }),
stringDefWithActive("meili.api_key", "search", "meili", "Meili API key", "Meilisearch API key.", ApplyModeRestartRequired, true, true, nil, func() bool { return cfg.If("Meili") }, func() any { return conf.MeiliSetting.ApiKey }, func() any { return bootstrapConfig.Meili.ApiKey }, validateTrimmedMax("meili.api_key", 512), func(v any) { conf.MeiliSetting.ApiKey = v.(string) }),
boolDefWithActive("meili.secure", "search", "meili", "Meili secure", "Use HTTPS for Meilisearch.", ApplyModeRestartRequired, false, true, func() bool { return cfg.If("Meili") }, func() any { return conf.MeiliSetting.Secure }, func() any { return bootstrapConfig.Meili.Secure }, func(v any) { conf.MeiliSetting.Secure = v.(bool) }),
stringDefWithActive("zinc.host", "search", "zinc", "Zinc host", "Zinc host.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("Zinc") }, func() any { return conf.ZincSetting.Host }, func() any { return bootstrapConfig.Zinc.Host }, validateTrimmedMax("zinc.host", 255), func(v any) { conf.ZincSetting.Host = v.(string) }),
stringDefWithActive("zinc.index", "search", "zinc", "Zinc index", "Zinc index name.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("Zinc") }, func() any { return conf.ZincSetting.Index }, func() any { return bootstrapConfig.Zinc.Index }, validateTrimmedMax("zinc.index", 255), func(v any) { conf.ZincSetting.Index = v.(string) }),
stringDefWithActive("zinc.user", "search", "zinc", "Zinc user", "Zinc username.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("Zinc") }, func() any { return conf.ZincSetting.User }, func() any { return bootstrapConfig.Zinc.User }, validateTrimmedMax("zinc.user", 255), func(v any) { conf.ZincSetting.User = v.(string) }),
stringDefWithActive("zinc.password", "search", "zinc", "Zinc password", "Zinc password.", ApplyModeRestartRequired, true, true, nil, func() bool { return cfg.If("Zinc") }, func() any { return conf.ZincSetting.Password }, func() any { return bootstrapConfig.Zinc.Password }, validateTrimmedMax("zinc.password", 512), func(v any) { conf.ZincSetting.Password = v.(string) }),
boolDefWithActive("zinc.secure", "search", "zinc", "Zinc secure", "Use HTTPS for Zinc.", ApplyModeRestartRequired, false, true, func() bool { return cfg.If("Zinc") }, func() any { return conf.ZincSetting.Secure }, func() any { return bootstrapConfig.Zinc.Secure }, func(v any) { conf.ZincSetting.Secure = v.(bool) }),
intDef("object_storage.retain_in_days", "storage", "common", "Object retention days", "Retention window for temporary objects.", ApplyModeRestartRequired, false, true, func() any { return conf.ObjectStorage.RetainInDays }, func() any { return bootstrapConfig.ObjectStorage.RetainInDays }, func(v int) error { return between(v, 0, 3650, "retain_in_days") }, func(v any) { conf.ObjectStorage.RetainInDays = v.(int) }),
stringDef("object_storage.temp_dir", "storage", "common", "Object temp dir", "Temporary directory/prefix for objects.", ApplyModeRestartRequired, false, true, nil, func() any { return conf.ObjectStorage.TempDir }, func() any { return bootstrapConfig.ObjectStorage.TempDir }, validateTrimmedMax("temp_dir", 255), func(v any) { conf.ObjectStorage.TempDir = v.(string) }),
stringDefWithActive("local_oss.save_path", "storage", "local_oss", "Local OSS save path", "Filesystem path for local object storage.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("LocalOSS") }, func() any { return conf.LocalOSSSetting.SavePath }, func() any { return bootstrapConfig.LocalOSS.SavePath }, validateTrimmedMax("local_oss.save_path", 1024), func(v any) { conf.LocalOSSSetting.SavePath = v.(string) }),
boolDefWithActive("local_oss.secure", "storage", "local_oss", "Local OSS secure", "Generate HTTPS URLs for local object storage.", ApplyModeRestartRequired, false, true, func() bool { return cfg.If("LocalOSS") }, func() any { return conf.LocalOSSSetting.Secure }, func() any { return bootstrapConfig.LocalOSS.Secure }, func(v any) { conf.LocalOSSSetting.Secure = v.(bool) }),
stringDefWithActive("local_oss.bucket", "storage", "local_oss", "Local OSS bucket", "Bucket folder name for local storage.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("LocalOSS") }, func() any { return conf.LocalOSSSetting.Bucket }, func() any { return bootstrapConfig.LocalOSS.Bucket }, validateTrimmedMax("local_oss.bucket", 255), func(v any) { conf.LocalOSSSetting.Bucket = v.(string) }),
stringDefWithActive("local_oss.domain", "storage", "local_oss", "Local OSS domain", "Public domain for local object storage.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("LocalOSS") }, func() any { return conf.LocalOSSSetting.Domain }, func() any { return bootstrapConfig.LocalOSS.Domain }, validateTrimmedMax("local_oss.domain", 255), func(v any) { conf.LocalOSSSetting.Domain = v.(string) }),
stringDefWithActive("minio.access_key", "storage", "minio", "MinIO access key", "MinIO access key.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("MinIO") }, func() any { return conf.MinIOSetting.AccessKey }, func() any { return bootstrapConfig.MinIO.AccessKey }, validateTrimmedMax("minio.access_key", 255), func(v any) { conf.MinIOSetting.AccessKey = v.(string) }),
stringDefWithActive("minio.secret_key", "storage", "minio", "MinIO secret key", "MinIO secret key.", ApplyModeRestartRequired, true, true, nil, func() bool { return cfg.If("MinIO") }, func() any { return conf.MinIOSetting.SecretKey }, func() any { return bootstrapConfig.MinIO.SecretKey }, validateTrimmedMax("minio.secret_key", 512), func(v any) { conf.MinIOSetting.SecretKey = v.(string) }),
boolDefWithActive("minio.secure", "storage", "minio", "MinIO secure", "Use HTTPS for MinIO.", ApplyModeRestartRequired, false, true, func() bool { return cfg.If("MinIO") }, func() any { return conf.MinIOSetting.Secure }, func() any { return bootstrapConfig.MinIO.Secure }, func(v any) { conf.MinIOSetting.Secure = v.(bool) }),
stringDefWithActive("minio.endpoint", "storage", "minio", "MinIO endpoint", "MinIO endpoint.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("MinIO") }, func() any { return conf.MinIOSetting.Endpoint }, func() any { return bootstrapConfig.MinIO.Endpoint }, validateTrimmedMax("minio.endpoint", 255), func(v any) { conf.MinIOSetting.Endpoint = v.(string) }),
stringDefWithActive("minio.bucket", "storage", "minio", "MinIO bucket", "MinIO bucket name.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("MinIO") }, func() any { return conf.MinIOSetting.Bucket }, func() any { return bootstrapConfig.MinIO.Bucket }, validateTrimmedMax("minio.bucket", 255), func(v any) { conf.MinIOSetting.Bucket = v.(string) }),
stringDefWithActive("minio.domain", "storage", "minio", "MinIO domain", "MinIO public domain.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("MinIO") }, func() any { return conf.MinIOSetting.Domain }, func() any { return bootstrapConfig.MinIO.Domain }, validateTrimmedMax("minio.domain", 255), func(v any) { conf.MinIOSetting.Domain = v.(string) }),
stringDefWithActive("s3.access_key", "storage", "s3", "S3 access key", "Amazon S3 access key.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("S3") }, func() any { return conf.S3Setting.AccessKey }, func() any { return bootstrapConfig.S3.AccessKey }, validateTrimmedMax("s3.access_key", 255), func(v any) { conf.S3Setting.AccessKey = v.(string) }),
stringDefWithActive("s3.secret_key", "storage", "s3", "S3 secret key", "Amazon S3 secret key.", ApplyModeRestartRequired, true, true, nil, func() bool { return cfg.If("S3") }, func() any { return conf.S3Setting.SecretKey }, func() any { return bootstrapConfig.S3.SecretKey }, validateTrimmedMax("s3.secret_key", 512), func(v any) { conf.S3Setting.SecretKey = v.(string) }),
boolDefWithActive("s3.secure", "storage", "s3", "S3 secure", "Use HTTPS for S3.", ApplyModeRestartRequired, false, true, func() bool { return cfg.If("S3") }, func() any { return conf.S3Setting.Secure }, func() any { return bootstrapConfig.S3.Secure }, func(v any) { conf.S3Setting.Secure = v.(bool) }),
stringDefWithActive("s3.endpoint", "storage", "s3", "S3 endpoint", "Amazon S3 endpoint.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("S3") }, func() any { return conf.S3Setting.Endpoint }, func() any { return bootstrapConfig.S3.Endpoint }, validateTrimmedMax("s3.endpoint", 255), func(v any) { conf.S3Setting.Endpoint = v.(string) }),
stringDefWithActive("s3.bucket", "storage", "s3", "S3 bucket", "Amazon S3 bucket name.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("S3") }, func() any { return conf.S3Setting.Bucket }, func() any { return bootstrapConfig.S3.Bucket }, validateTrimmedMax("s3.bucket", 255), func(v any) { conf.S3Setting.Bucket = v.(string) }),
stringDefWithActive("s3.domain", "storage", "s3", "S3 domain", "Amazon S3 public domain.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("S3") }, func() any { return conf.S3Setting.Domain }, func() any { return bootstrapConfig.S3.Domain }, validateTrimmedMax("s3.domain", 255), func(v any) { conf.S3Setting.Domain = v.(string) }),
stringDefWithActive("alioss.endpoint", "storage", "alioss", "AliOSS endpoint", "AliOSS endpoint.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("AliOSS") }, func() any { return conf.AliOSSSetting.Endpoint }, func() any { return bootstrapConfig.AliOSS.Endpoint }, validateTrimmedMax("alioss.endpoint", 255), func(v any) { conf.AliOSSSetting.Endpoint = v.(string) }),
stringDefWithActive("alioss.access_key_id", "storage", "alioss", "AliOSS access key ID", "AliOSS access key ID.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("AliOSS") }, func() any { return conf.AliOSSSetting.AccessKeyID }, func() any { return bootstrapConfig.AliOSS.AccessKeyID }, validateTrimmedMax("alioss.access_key_id", 255), func(v any) { conf.AliOSSSetting.AccessKeyID = v.(string) }),
stringDefWithActive("alioss.access_key_secret", "storage", "alioss", "AliOSS access key secret", "AliOSS secret key.", ApplyModeRestartRequired, true, true, nil, func() bool { return cfg.If("AliOSS") }, func() any { return conf.AliOSSSetting.AccessKeySecret }, func() any { return bootstrapConfig.AliOSS.AccessKeySecret }, validateTrimmedMax("alioss.access_key_secret", 512), func(v any) { conf.AliOSSSetting.AccessKeySecret = v.(string) }),
stringDefWithActive("alioss.bucket", "storage", "alioss", "AliOSS bucket", "AliOSS bucket name.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("AliOSS") }, func() any { return conf.AliOSSSetting.Bucket }, func() any { return bootstrapConfig.AliOSS.Bucket }, validateTrimmedMax("alioss.bucket", 255), func(v any) { conf.AliOSSSetting.Bucket = v.(string) }),
stringDefWithActive("alioss.domain", "storage", "alioss", "AliOSS domain", "AliOSS public domain.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("AliOSS") }, func() any { return conf.AliOSSSetting.Domain }, func() any { return bootstrapConfig.AliOSS.Domain }, validateTrimmedMax("alioss.domain", 255), func(v any) { conf.AliOSSSetting.Domain = v.(string) }),
stringDefWithActive("cos.secret_id", "storage", "cos", "COS secret ID", "Tencent COS secret ID.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("COS") }, func() any { return conf.COSSetting.SecretID }, func() any { return bootstrapConfig.COS.SecretID }, validateTrimmedMax("cos.secret_id", 255), func(v any) { conf.COSSetting.SecretID = v.(string) }),
stringDefWithActive("cos.secret_key", "storage", "cos", "COS secret key", "Tencent COS secret key.", ApplyModeRestartRequired, true, true, nil, func() bool { return cfg.If("COS") }, func() any { return conf.COSSetting.SecretKey }, func() any { return bootstrapConfig.COS.SecretKey }, validateTrimmedMax("cos.secret_key", 512), func(v any) { conf.COSSetting.SecretKey = v.(string) }),
stringDefWithActive("cos.region", "storage", "cos", "COS region", "Tencent COS region.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("COS") }, func() any { return conf.COSSetting.Region }, func() any { return bootstrapConfig.COS.Region }, validateTrimmedMax("cos.region", 255), func(v any) { conf.COSSetting.Region = v.(string) }),
stringDefWithActive("cos.bucket", "storage", "cos", "COS bucket", "Tencent COS bucket.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("COS") }, func() any { return conf.COSSetting.Bucket }, func() any { return bootstrapConfig.COS.Bucket }, validateTrimmedMax("cos.bucket", 255), func(v any) { conf.COSSetting.Bucket = v.(string) }),
stringDefWithActive("cos.domain", "storage", "cos", "COS domain", "Tencent COS public domain.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("COS") }, func() any { return conf.COSSetting.Domain }, func() any { return bootstrapConfig.COS.Domain }, validateTrimmedMax("cos.domain", 255), func(v any) { conf.COSSetting.Domain = v.(string) }),
stringDefWithActive("huawei_obs.access_key", "storage", "huawei_obs", "Huawei OBS access key", "Huawei OBS access key.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("HuaweiOBS") }, func() any { return conf.HuaweiOBSSetting.AccessKey }, func() any { return bootstrapConfig.HuaweiOBS.AccessKey }, validateTrimmedMax("huawei_obs.access_key", 255), func(v any) { conf.HuaweiOBSSetting.AccessKey = v.(string) }),
stringDefWithActive("huawei_obs.secret_key", "storage", "huawei_obs", "Huawei OBS secret key", "Huawei OBS secret key.", ApplyModeRestartRequired, true, true, nil, func() bool { return cfg.If("HuaweiOBS") }, func() any { return conf.HuaweiOBSSetting.SecretKey }, func() any { return bootstrapConfig.HuaweiOBS.SecretKey }, validateTrimmedMax("huawei_obs.secret_key", 512), func(v any) { conf.HuaweiOBSSetting.SecretKey = v.(string) }),
stringDefWithActive("huawei_obs.endpoint", "storage", "huawei_obs", "Huawei OBS endpoint", "Huawei OBS endpoint.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("HuaweiOBS") }, func() any { return conf.HuaweiOBSSetting.Endpoint }, func() any { return bootstrapConfig.HuaweiOBS.Endpoint }, validateTrimmedMax("huawei_obs.endpoint", 255), func(v any) { conf.HuaweiOBSSetting.Endpoint = v.(string) }),
stringDefWithActive("huawei_obs.bucket", "storage", "huawei_obs", "Huawei OBS bucket", "Huawei OBS bucket.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("HuaweiOBS") }, func() any { return conf.HuaweiOBSSetting.Bucket }, func() any { return bootstrapConfig.HuaweiOBS.Bucket }, validateTrimmedMax("huawei_obs.bucket", 255), func(v any) { conf.HuaweiOBSSetting.Bucket = v.(string) }),
stringDefWithActive("huawei_obs.domain", "storage", "huawei_obs", "Huawei OBS domain", "Huawei OBS public domain.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("HuaweiOBS") }, func() any { return conf.HuaweiOBSSetting.Domain }, func() any { return bootstrapConfig.HuaweiOBS.Domain }, validateTrimmedMax("huawei_obs.domain", 255), func(v any) { conf.HuaweiOBSSetting.Domain = v.(string) }),
stringDefWithActive("sms_juhe.gateway", "notifications", "sms_juhe", "SMS gateway", "Juhe SMS gateway URL.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("Sms") }, func() any { return conf.SmsJuheSetting.Gateway }, func() any { return bootstrapConfig.SmsJuhe.Gateway }, validateTrimmedMax("sms_juhe.gateway", 255), func(v any) { conf.SmsJuheSetting.Gateway = v.(string) }),
stringDefWithActive("sms_juhe.key", "notifications", "sms_juhe", "SMS key", "Juhe SMS key.", ApplyModeRestartRequired, true, true, nil, func() bool { return cfg.If("Sms") }, func() any { return conf.SmsJuheSetting.Key }, func() any { return bootstrapConfig.SmsJuhe.Key }, validateTrimmedMax("sms_juhe.key", 255), func(v any) { conf.SmsJuheSetting.Key = v.(string) }),
stringDefWithActive("sms_juhe.tpl_id", "notifications", "sms_juhe", "SMS template ID", "Juhe SMS template ID.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("Sms") }, func() any { return conf.SmsJuheSetting.TplID }, func() any { return bootstrapConfig.SmsJuhe.TplID }, validateTrimmedMax("sms_juhe.tpl_id", 255), func(v any) { conf.SmsJuheSetting.TplID = v.(string) }),
stringDefWithActive("sms_juhe.tpl_val", "notifications", "sms_juhe", "SMS template value", "Juhe SMS template value format.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("Sms") }, func() any { return conf.SmsJuheSetting.TplVal }, func() any { return bootstrapConfig.SmsJuhe.TplVal }, validateTrimmedMax("sms_juhe.tpl_val", 255), func(v any) { conf.SmsJuheSetting.TplVal = v.(string) }),
stringDefWithActive("alipay.app_id", "payments", "alipay", "Alipay app ID", "Alipay application ID.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("Alipay") }, func() any { return conf.AlipaySetting.AppID }, func() any { return bootstrapConfig.Alipay.AppID }, validateTrimmedMax("alipay.app_id", 255), func(v any) { conf.AlipaySetting.AppID = v.(string) }),
stringDefWithActive("alipay.private_key", "payments", "alipay", "Alipay private key", "Alipay private key PEM content.", ApplyModeRestartRequired, true, true, nil, func() bool { return cfg.If("Alipay") }, func() any { return conf.AlipaySetting.PrivateKey }, func() any { return bootstrapConfig.Alipay.PrivateKey }, validateTrimmedMax("alipay.private_key", 8192), func(v any) { conf.AlipaySetting.PrivateKey = v.(string) }),
stringDefWithActive("alipay.root_cert_file", "payments", "alipay", "Alipay root cert file", "Path to the Alipay root certificate file.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("Alipay") }, func() any { return conf.AlipaySetting.RootCertFile }, func() any { return bootstrapConfig.Alipay.RootCertFile }, validateTrimmedMax("alipay.root_cert_file", 1024), func(v any) { conf.AlipaySetting.RootCertFile = v.(string) }),
stringDefWithActive("alipay.public_cert_file", "payments", "alipay", "Alipay public cert file", "Path to the Alipay public certificate file.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("Alipay") }, func() any { return conf.AlipaySetting.PublicCertFile }, func() any { return bootstrapConfig.Alipay.PublicCertFile }, validateTrimmedMax("alipay.public_cert_file", 1024), func(v any) { conf.AlipaySetting.PublicCertFile = v.(string) }),
stringDefWithActive("alipay.app_public_cert_file", "payments", "alipay", "Alipay app public cert file", "Path to the app public certificate file.", ApplyModeRestartRequired, false, true, nil, func() bool { return cfg.If("Alipay") }, func() any { return conf.AlipaySetting.AppPublicCertFile }, func() any { return bootstrapConfig.Alipay.AppPublicCertFile }, validateTrimmedMax("alipay.app_public_cert_file", 1024), func(v any) { conf.AlipaySetting.AppPublicCertFile = v.(string) }),
boolDefWithActive("alipay.in_production", "payments", "alipay", "Alipay production mode", "Use Alipay production environment.", ApplyModeRestartRequired, false, true, func() bool { return cfg.If("Alipay") }, func() any { return conf.AlipaySetting.InProduction }, func() any { return bootstrapConfig.Alipay.InProduction }, func(v any) { conf.AlipaySetting.InProduction = v.(bool) }),
}
}
func registryMap() map[string]Definition {
defs := Registry()
res := make(map[string]Definition, len(defs))
for _, def := range defs {
res[def.Key] = def
}
return res
}
func boolDef(key, group, section, label, description string, applyMode ApplyMode, secret, editable bool, current func() any, bootstrap func() any, apply func(any)) Definition {
return boolDefWithActive(key, group, section, label, description, applyMode, secret, editable, func() bool { return true }, current, bootstrap, apply)
}
func boolDefWithActive(key, group, section, label, description string, applyMode ApplyMode, secret, editable bool, active func() bool, current func() any, bootstrap func() any, apply func(any)) Definition {
return Definition{Key: key, Group: group, Section: section, Type: TypeBool, Label: label, Description: description, ApplyMode: applyMode, Secret: secret, Readonly: !editable, Active: active, CurrentValue: current, BootstrapDefault: bootstrap, ValidateValue: func(v any) (any, error) {
b, ok := v.(bool)
if !ok {
return nil, invalidType(key, "bool")
}
return b, nil
}, BootstrapOverride: apply}
}
func intDef(key, group, section, label, description string, applyMode ApplyMode, secret, editable bool, current func() any, bootstrap func() any, validator func(int) error, apply func(any)) Definition {
return Definition{Key: key, Group: group, Section: section, Type: TypeInt, Label: label, Description: description, ApplyMode: applyMode, Secret: secret, Readonly: !editable, Active: func() bool { return true }, CurrentValue: current, BootstrapDefault: bootstrap, ValidateValue: func(v any) (any, error) {
i, ok := asInt(v)
if !ok {
return nil, invalidType(key, "int")
}
if validator != nil {
if err := validator(i); err != nil {
return nil, err
}
}
return i, nil
}, BootstrapOverride: apply}
}
func int64Def(key, group, section, label, description string, applyMode ApplyMode, current func() any, bootstrap func() any, validator func(int64) error, apply func(any)) Definition {
return Definition{Key: key, Group: group, Section: section, Type: TypeInt, Label: label, Description: description, ApplyMode: applyMode, Active: func() bool { return true }, CurrentValue: current, BootstrapDefault: bootstrap, ValidateValue: func(v any) (any, error) {
i, ok := asInt64(v)
if !ok {
return nil, invalidType(key, "int")
}
if validator != nil {
if err := validator(i); err != nil {
return nil, err
}
}
return i, nil
}, BootstrapOverride: apply}
}
func floatDef(key, group, section, label, description string, applyMode ApplyMode, current func() any, bootstrap func() any, validator func(float64) error, apply func(any)) Definition {
return Definition{Key: key, Group: group, Section: section, Type: TypeFloat, Label: label, Description: description, ApplyMode: applyMode, Active: func() bool { return true }, CurrentValue: current, BootstrapDefault: bootstrap, ValidateValue: func(v any) (any, error) {
f, ok := asFloat64(v)
if !ok {
return nil, invalidType(key, "float")
}
if validator != nil {
if err := validator(f); err != nil {
return nil, err
}
}
return f, nil
}, BootstrapOverride: apply}
}
func stringDef(key, group, section, label, description string, applyMode ApplyMode, secret, editable bool, options []Option, current func() any, bootstrap func() any, validator func(string) error, apply func(any)) Definition {
return stringDefWithActive(key, group, section, label, description, applyMode, secret, editable, options, func() bool { return true }, current, bootstrap, validator, apply)
}
func stringDefWithActive(key, group, section, label, description string, applyMode ApplyMode, secret, editable bool, options []Option, active func() bool, current func() any, bootstrap func() any, validator func(string) error, apply func(any)) Definition {
return Definition{Key: key, Group: group, Section: section, Type: TypeString, Label: label, Description: description, ApplyMode: applyMode, Secret: secret, Readonly: !editable, Options: options, Active: active, CurrentValue: current, BootstrapDefault: bootstrap, ValidateValue: func(v any) (any, error) {
s, ok := v.(string)
if !ok {
return nil, invalidType(key, "string")
}
s = strings.TrimSpace(s)
if validator != nil {
if err := validator(s); err != nil {
return nil, err
}
}
return s, nil
}, BootstrapOverride: apply}
}
func validateVisibility(v string) error {
for _, option := range visibilityOption {
if option.Value == v {
return nil
}
}
return xerror.InvalidParams.WithDetails("default_tweet_visibility must be one of public/following/friend/private")
}
func validateRequiredTrimmed(name string, max int) func(string) error {
return func(v string) error {
if v == "" {
return xerror.InvalidParams.WithDetails(name + " must not be empty")
}
if len(v) > max {
return xerror.InvalidParams.WithDetails(fmt.Sprintf("%s length must be <= %d", name, max))
}
return nil
}
}
func validateTrimmedMax(name string, max int) func(string) error {
return func(v string) error {
if len(v) > max {
return xerror.InvalidParams.WithDetails(fmt.Sprintf("%s length must be <= %d", name, max))
}
return nil
}
}
func validateOptionalURL(name string, max int) func(string) error {
return func(v string) error {
if len(v) > max {
return xerror.InvalidParams.WithDetails(fmt.Sprintf("%s length must be <= %d", name, max))
}
if v == "" {
return nil
}
parsed, err := url.ParseRequestURI(v)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return xerror.InvalidParams.WithDetails(name + " must be a valid URL")
}
return nil
}
}
func between(v, min, max int, name string) error {
if v < min || v > max {
return xerror.InvalidParams.WithDetails(fmt.Sprintf("%s must be between %d and %d", name, min, max))
}
return nil
}
func betweenInt64(v, min, max int64, name string) error {
if v < min || v > max {
return xerror.InvalidParams.WithDetails(fmt.Sprintf("%s must be between %d and %d", name, min, max))
}
return nil
}
func invalidType(key, want string) error {
return xerror.InvalidParams.WithDetails(fmt.Sprintf("%s must be %s", key, want))
}
func asInt(v any) (int, bool) {
switch n := v.(type) {
case int:
return n, true
case int32:
return int(n), true
case int64:
return int(n), true
case float64:
if n != float64(int(n)) {
return 0, false
}
return int(n), true
default:
return 0, false
}
}
func asInt64(v any) (int64, bool) {
switch n := v.(type) {
case int:
return int64(n), true
case int32:
return int64(n), true
case int64:
return n, true
case float64:
if n != float64(int64(n)) {
return 0, false
}
return int64(n), true
default:
return 0, false
}
}
func asFloat64(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
default:
return 0, false
}
}
func parseRequestValue(def Definition, raw stdjson.RawMessage) (any, error) {
var target any
switch def.Type {
case TypeBool:
var v bool
if err := stdjson.Unmarshal(raw, &v); err != nil {
return nil, invalidType(def.Key, "bool")
}
target = v
case TypeInt:
var v float64
if err := stdjson.Unmarshal(raw, &v); err != nil {
return nil, invalidType(def.Key, "int")
}
target = v
case TypeFloat:
var v float64
if err := stdjson.Unmarshal(raw, &v); err != nil {
return nil, invalidType(def.Key, "float")
}
target = v
case TypeString:
var v string
if err := stdjson.Unmarshal(raw, &v); err != nil {
return nil, invalidType(def.Key, "string")
}
target = v
default:
return nil, xerror.ServerError.WithDetails("unsupported settings type")
}
return def.ValidateValue(target)
}
func parseStoredValue(def Definition, raw string) (any, error) {
switch def.Type {
case TypeBool:
return strconv.ParseBool(raw)
case TypeInt:
if i, err := strconv.ParseInt(raw, 10, 64); err == nil {
return def.ValidateValue(i)
} else {
return nil, err
}
case TypeFloat:
if f, err := strconv.ParseFloat(raw, 64); err == nil {
return def.ValidateValue(f)
} else {
return nil, err
}
case TypeString:
return def.ValidateValue(raw)
default:
return nil, xerror.ServerError.WithDetails("unsupported settings type")
}
}
func serializeValue(def Definition, value any) string {
switch def.Type {
case TypeBool:
return strconv.FormatBool(value.(bool))
case TypeInt:
switch v := value.(type) {
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
default:
return fmt.Sprintf("%v", value)
}
case TypeFloat:
return strconv.FormatFloat(value.(float64), 'f', -1, 64)
default:
return value.(string)
}
}
func valuesEqual(left, right any) bool {
return fmt.Sprintf("%v", left) == fmt.Sprintf("%v", right)
}
func activeState(def Definition) bool {
if def.Active == nil {
return true
}
return def.Active()
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func logBootstrapApplyError(def Definition, err error) {
logrus.WithError(err).WithField("key", def.Key).Warn("sitesetting: skip invalid persisted override")
}

@ -0,0 +1,486 @@
// Copyright 2026 ROC. All rights reserved.
// Use of this source code is governed by a MIT style
// license that can be found in the LICENSE file.
package sitesetting
import (
"context"
stdjson "encoding/json"
"errors"
"strings"
"time"
"github.com/rocboss/paopao-ce/internal/conf"
"github.com/rocboss/paopao-ce/internal/model/web"
"github.com/rocboss/paopao-ce/pkg/xerror"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type EditableProfile struct {
UseFriendship bool
EnableTrendsBar bool
EnableWallet bool
AllowTweetAttachment bool
AllowTweetAttachmentPrice bool
AllowTweetVideo bool
DefaultTweetMaxLength int
TweetWebEllipsisSize int
TweetMobileEllipsisSize int
DefaultTweetVisibility string
DefaultMsgLoopInterval int
CopyrightTop string
CopyrightLeft string
CopyrightLeftLink string
CopyrightRight string
CopyrightRightLink string
}
type settingRecord struct {
Key string `gorm:"column:key;type:varchar(191);primaryKey"`
Value string `gorm:"column:value;type:text;not null"`
IsEncrypted bool `gorm:"column:is_encrypted;not null"`
CreatedOn int64 `gorm:"column:created_on;not null"`
ModifiedOn int64 `gorm:"column:modified_on;not null"`
DeletedOn int64 `gorm:"column:deleted_on;not null"`
IsDel int8 `gorm:"column:is_del;not null"`
}
func (settingRecord) TableName() string {
if conf.DatabaseSetting == nil {
return "p_" + conf.TableSiteSettings
}
return conf.DatabaseSetting.TablePrefix + conf.TableSiteSettings
}
func (r *settingRecord) BeforeCreate(_ *gorm.DB) error {
now := time.Now().Unix()
r.CreatedOn = now
r.ModifiedOn = now
return nil
}
func (r *settingRecord) BeforeUpdate(tx *gorm.DB) error {
if !tx.Statement.Changed("modified_on") {
r.ModifiedOn = time.Now().Unix()
}
return nil
}
type Service struct {
db *gorm.DB
registry map[string]Definition
codec *secretCodec
}
func NewService(db *gorm.DB) *Service {
ensureBootstrapSnapshot()
return &Service{db: db, registry: registryMap(), codec: newSecretCodec()}
}
func Bootstrap(ctx context.Context, db *gorm.DB) {
ensureBootstrapSnapshot()
if db == nil {
return
}
if err := NewService(db).ApplyPersistedOverrides(ctx); err != nil {
logrus.WithError(err).Warn("sitesetting: bootstrap override load failed; using bootstrap config only")
}
}
func (s *Service) ApplyPersistedOverrides(ctx context.Context) error {
records, err := s.loadOverridesLenient(ctx)
if err != nil {
return err
}
for key, record := range records {
def, ok := s.registry[key]
if !ok || def.BootstrapOverride == nil || def.ApplyMode == ApplyModeBootstrapOnly {
continue
}
value, err := s.parseRecord(def, record)
if err != nil {
logBootstrapApplyError(def, err)
continue
}
def.BootstrapOverride(value)
}
return nil
}
func (s *Service) GetProfile(ctx context.Context) (*web.SiteProfileResp, error) {
_ = ctx
return &web.SiteProfileResp{
UseFriendship: conf.WebProfileSetting.UseFriendship,
EnableTrendsBar: conf.WebProfileSetting.EnableTrendsBar,
EnableWallet: conf.WebProfileSetting.EnableWallet,
AllowTweetAttachment: conf.WebProfileSetting.AllowTweetAttachment,
AllowTweetAttachmentPrice: conf.WebProfileSetting.AllowTweetAttachmentPrice,
AllowTweetVideo: conf.WebProfileSetting.AllowTweetVideo,
AllowUserRegister: conf.WebProfileSetting.AllowUserRegister,
AllowPhoneBind: conf.WebProfileSetting.AllowPhoneBind,
DefaultTweetMaxLength: conf.WebProfileSetting.DefaultTweetMaxLength,
TweetWebEllipsisSize: conf.WebProfileSetting.TweetWebEllipsisSize,
TweetMobileEllipsisSize: conf.WebProfileSetting.TweetMobileEllipsisSize,
DefaultTweetVisibility: conf.WebProfileSetting.DefaultTweetVisibility,
DefaultMsgLoopInterval: conf.WebProfileSetting.DefaultMsgLoopInterval,
CopyrightTop: conf.WebProfileSetting.CopyrightTop,
CopyrightLeft: conf.WebProfileSetting.CopyrightLeft,
CopyrightLeftLink: conf.WebProfileSetting.CopyrightLeftLink,
CopyrightRight: conf.WebProfileSetting.CopyrightRight,
CopyrightRightLink: conf.WebProfileSetting.CopyrightRightLink,
}, nil
}
func (s *Service) UpdateEditableProfile(ctx context.Context, input EditableProfile) (*web.SiteProfileResp, error) {
if err := ValidateEditableProfile(input); err != nil {
return nil, err
}
items := []web.AdminSettingValueInput{
{Key: "web_profile.use_friendship", Value: boolRaw(input.UseFriendship)},
{Key: "web_profile.enable_trends_bar", Value: boolRaw(input.EnableTrendsBar)},
{Key: "web_profile.enable_wallet", Value: boolRaw(input.EnableWallet)},
{Key: "web_profile.allow_tweet_attachment", Value: boolRaw(input.AllowTweetAttachment)},
{Key: "web_profile.allow_tweet_attachment_price", Value: boolRaw(input.AllowTweetAttachmentPrice)},
{Key: "web_profile.allow_tweet_video", Value: boolRaw(input.AllowTweetVideo)},
{Key: "web_profile.default_tweet_max_length", Value: intRaw(input.DefaultTweetMaxLength)},
{Key: "web_profile.tweet_web_ellipsis_size", Value: intRaw(input.TweetWebEllipsisSize)},
{Key: "web_profile.tweet_mobile_ellipsis_size", Value: intRaw(input.TweetMobileEllipsisSize)},
{Key: "web_profile.default_tweet_visibility", Value: stringRaw(input.DefaultTweetVisibility)},
{Key: "web_profile.default_msg_loop_interval", Value: intRaw(input.DefaultMsgLoopInterval)},
{Key: "web_profile.copyright_top", Value: stringRaw(input.CopyrightTop)},
{Key: "web_profile.copyright_left", Value: stringRaw(input.CopyrightLeft)},
{Key: "web_profile.copyright_left_link", Value: stringRaw(input.CopyrightLeftLink)},
{Key: "web_profile.copyright_right", Value: stringRaw(input.CopyrightRight)},
{Key: "web_profile.copyright_right_link", Value: stringRaw(input.CopyrightRightLink)},
}
if _, err := s.SaveValues(ctx, items); err != nil {
return nil, err
}
return s.GetProfile(ctx)
}
func (s *Service) GetSchema() (*web.AdminSettingsSchemaResp, error) {
items := make([]web.AdminSettingSchemaItem, 0, len(s.registry))
for _, def := range Registry() {
item := web.AdminSettingSchemaItem{
Key: def.Key,
Group: def.Group,
Section: def.Section,
Type: string(def.Type),
Label: def.Label,
Description: def.Description,
ApplyMode: string(def.ApplyMode),
Secret: def.Secret,
Readonly: def.Readonly,
Active: activeState(def),
Options: def.Options,
}
bootstrap := def.BootstrapDefault()
if def.Secret {
item.BootstrapConfigured = configuredValue(bootstrap)
} else {
item.BootstrapValue = bootstrap
}
items = append(items, item)
}
return &web.AdminSettingsSchemaResp{Items: items}, nil
}
func (s *Service) GetValues(ctx context.Context) (*web.AdminSettingsValuesResp, error) {
records, err := s.loadOverridesLenient(ctx)
if err != nil {
return nil, err
}
items, pending := s.buildValueItems(records)
return &web.AdminSettingsValuesResp{Items: items, HasPendingRestart: pending}, nil
}
func (s *Service) SaveValues(ctx context.Context, inputs []web.AdminSettingValueInput) (*web.AdminSettingsSaveResp, error) {
if len(inputs) == 0 {
return nil, xerror.InvalidParams.WithDetails("items must not be empty")
}
prepared := make([]preparedValue, 0, len(inputs))
seen := make(map[string]struct{}, len(inputs))
for _, input := range inputs {
if len(input.Value) == 0 {
return nil, xerror.InvalidParams.WithDetails("missing setting value: " + input.Key)
}
def, ok := s.registry[input.Key]
if !ok {
return nil, xerror.InvalidParams.WithDetails("unknown setting key: " + input.Key)
}
if def.Readonly || def.ApplyMode == ApplyModeBootstrapOnly || def.BootstrapOverride == nil {
return nil, xerror.InvalidParams.WithDetails("setting is not editable: " + input.Key)
}
if _, ok := seen[input.Key]; ok {
return nil, xerror.InvalidParams.WithDetails("duplicate setting key: " + input.Key)
}
seen[input.Key] = struct{}{}
value, err := parseRequestValue(def, input.Value)
if err != nil {
return nil, err
}
prepared = append(prepared, preparedValue{Definition: def, Value: value})
}
if err := validatePreparedBatch(prepared); err != nil {
return nil, err
}
if err := s.persistPrepared(ctx, prepared); err != nil {
return nil, err
}
for _, item := range prepared {
if item.ApplyMode == ApplyModeLive {
item.BootstrapOverride(item.Value)
}
}
values, err := s.GetValues(ctx)
if err != nil {
return nil, err
}
updatedKeys := make([]string, 0, len(prepared))
for _, item := range prepared {
updatedKeys = append(updatedKeys, item.Key)
}
return &web.AdminSettingsSaveResp{Items: values.Items, UpdatedKeys: updatedKeys, HasPendingRestart: values.HasPendingRestart}, nil
}
type preparedValue struct {
Definition
Value any
}
func validatePreparedBatch(prepared []preparedValue) error {
defaultTweetMaxLength := preparedInt(prepared, "web_profile.default_tweet_max_length", conf.WebProfileSetting.DefaultTweetMaxLength)
tweetWebEllipsisSize := preparedInt(prepared, "web_profile.tweet_web_ellipsis_size", conf.WebProfileSetting.TweetWebEllipsisSize)
tweetMobileEllipsisSize := preparedInt(prepared, "web_profile.tweet_mobile_ellipsis_size", conf.WebProfileSetting.TweetMobileEllipsisSize)
defaultPageSize := preparedInt(prepared, "app.default_page_size", conf.AppSetting.DefaultPageSize)
maxPageSize := preparedInt(prepared, "app.max_page_size", conf.AppSetting.MaxPageSize)
if tweetWebEllipsisSize < 1 || tweetWebEllipsisSize > defaultTweetMaxLength {
return xerror.InvalidParams.WithDetails("tweet_web_ellipsis_size must be between 1 and default_tweet_max_length")
}
if tweetMobileEllipsisSize < 1 || tweetMobileEllipsisSize > defaultTweetMaxLength {
return xerror.InvalidParams.WithDetails("tweet_mobile_ellipsis_size must be between 1 and default_tweet_max_length")
}
if defaultPageSize < 1 || defaultPageSize > maxPageSize {
return xerror.InvalidParams.WithDetails("default_page_size must be between 1 and max_page_size")
}
return nil
}
func preparedInt(prepared []preparedValue, key string, fallback int) int {
for _, item := range prepared {
if item.Key == key {
if value, ok := item.Value.(int); ok {
return value
}
break
}
}
return fallback
}
func (s *Service) persistPrepared(ctx context.Context, prepared []preparedValue) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, item := range prepared {
serialized := serializeValue(item.Definition, item.Value)
if valuesEqual(item.BootstrapDefault(), item.Value) {
if err := tx.Where("key = ?", item.Key).Delete(&settingRecord{}).Error; err != nil {
return err
}
continue
}
storedValue := serialized
isEncrypted := false
if item.Secret {
encrypted, err := s.codec.Encrypt(serialized)
if err != nil {
return err
}
storedValue = encrypted
isEncrypted = true
}
record := settingRecord{Key: item.Key, Value: storedValue, IsEncrypted: isEncrypted}
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "key"}}, DoUpdates: clause.AssignmentColumns([]string{"value", "is_encrypted", "modified_on", "deleted_on", "is_del"})}).Create(&record).Error; err != nil {
return err
}
}
return nil
})
}
func (s *Service) buildValueItems(records map[string]settingRecord) ([]web.AdminSettingValue, bool) {
items := make([]web.AdminSettingValue, 0, len(s.registry))
hasPendingRestart := false
for _, def := range Registry() {
current := def.CurrentValue()
item := web.AdminSettingValue{Key: def.Key, Source: "bootstrap", Active: activeState(def)}
if def.Secret {
item.Configured = configuredValue(current)
} else {
item.Value = current
item.EffectiveValue = current
}
if record, ok := records[def.Key]; ok {
storedValue, err := s.parseRecord(def, record)
if err == nil {
item.Source = "override"
if def.Secret {
item.Configured = configuredValue(storedValue)
} else {
item.Value = storedValue
item.EffectiveValue = current
}
if def.ApplyMode == ApplyModeRestartRequired && !valuesEqual(storedValue, current) {
item.PendingRestart = true
hasPendingRestart = true
}
}
}
if def.Secret && item.Source == "bootstrap" {
item.Configured = configuredValue(def.BootstrapDefault())
}
items = append(items, item)
}
return items, hasPendingRestart
}
func (s *Service) parseRecord(def Definition, record settingRecord) (any, error) {
raw := record.Value
if record.IsEncrypted {
decrypted, err := s.codec.Decrypt(record.Value)
if err != nil {
return nil, err
}
raw = decrypted
}
return parseStoredValue(def, raw)
}
func (s *Service) loadOverrides(ctx context.Context) (map[string]settingRecord, error) {
var records []settingRecord
err := s.db.WithContext(ctx).Where("is_del = ?", 0).Find(&records).Error
if err != nil {
return nil, err
}
res := make(map[string]settingRecord, len(records))
for _, record := range records {
res[record.Key] = record
}
return res, nil
}
func (s *Service) loadOverridesLenient(ctx context.Context) (map[string]settingRecord, error) {
records, err := s.loadOverrides(ctx)
if err != nil {
if isMissingTableError(err) {
logrus.WithError(err).Warn("sitesetting: overrides table unavailable; falling back to bootstrap values")
return map[string]settingRecord{}, nil
}
return nil, err
}
return records, nil
}
func isMissingTableError(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "no such table") || strings.Contains(text, "doesn't exist") || strings.Contains(text, "does not exist") || errors.Is(err, gorm.ErrRecordNotFound)
}
func configuredValue(v any) bool {
switch value := v.(type) {
case nil:
return false
case string:
return strings.TrimSpace(value) != ""
default:
return true
}
}
func boolRaw(v bool) []byte {
b, _ := stdjson.Marshal(v)
return b
}
func intRaw(v int) []byte {
b, _ := stdjson.Marshal(v)
return b
}
func stringRaw(v string) []byte {
b, _ := stdjson.Marshal(strings.TrimSpace(v))
return b
}
func ValidateEditableProfile(input EditableProfile) error {
return validateProfileInput(input)
}
func validateProfileInput(input EditableProfile) error {
if err := between(input.DefaultTweetMaxLength, 1, 2000, "default_tweet_max_length"); err != nil {
return err
}
if err := between(input.TweetWebEllipsisSize, 1, input.DefaultTweetMaxLength, "tweet_web_ellipsis_size"); err != nil {
return err
}
if err := between(input.TweetMobileEllipsisSize, 1, input.DefaultTweetMaxLength, "tweet_mobile_ellipsis_size"); err != nil {
return err
}
if err := between(input.DefaultMsgLoopInterval, 1000, 60000, "default_msg_loop_interval"); err != nil {
return err
}
if err := validateVisibility(strings.TrimSpace(input.DefaultTweetVisibility)); err != nil {
return err
}
if err := validateRequiredTrimmed("copyright_top", 255)(input.CopyrightTop); err != nil {
return err
}
if err := validateRequiredTrimmed("copyright_left", 255)(input.CopyrightLeft); err != nil {
return err
}
if err := validateRequiredTrimmed("copyright_right", 255)(input.CopyrightRight); err != nil {
return err
}
if err := validateOptionalURL("copyright_left_link", 255)(input.CopyrightLeftLink); err != nil {
return err
}
if err := validateOptionalURL("copyright_right_link", 255)(input.CopyrightRightLink); err != nil {
return err
}
return nil
}
func EditableFromRequest(req *web.SiteSettingsReq) EditableProfile {
return EditableProfile{
UseFriendship: *req.UseFriendship,
EnableTrendsBar: *req.EnableTrendsBar,
EnableWallet: *req.EnableWallet,
AllowTweetAttachment: *req.AllowTweetAttachment,
AllowTweetAttachmentPrice: *req.AllowTweetAttachmentPrice,
AllowTweetVideo: *req.AllowTweetVideo,
DefaultTweetMaxLength: *req.DefaultTweetMaxLength,
TweetWebEllipsisSize: *req.TweetWebEllipsisSize,
TweetMobileEllipsisSize: *req.TweetMobileEllipsisSize,
DefaultTweetVisibility: *req.DefaultTweetVisibility,
DefaultMsgLoopInterval: *req.DefaultMsgLoopInterval,
CopyrightTop: *req.CopyrightTop,
CopyrightLeft: *req.CopyrightLeft,
CopyrightLeftLink: stringValue(req.CopyrightLeftLink),
CopyrightRight: *req.CopyrightRight,
CopyrightRightLink: stringValue(req.CopyrightRightLink),
}
}
func stringValue(v *string) string {
if v == nil {
return ""
}
return *v
}

@ -0,0 +1,222 @@
package sitesetting
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/rocboss/paopao-ce/internal/conf"
"github.com/rocboss/paopao-ce/internal/model/web"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestGetProfileUsesBootstrapDefaultsWhenNoOverride(t *testing.T) {
svc := newTestService(t)
profile, err := svc.GetProfile(context.Background())
if err != nil {
t.Fatalf("GetProfile() error = %v", err)
}
if !profile.AllowUserRegister {
t.Fatalf("AllowUserRegister = false, want bootstrap true")
}
if profile.DefaultTweetVisibility != "friend" {
t.Fatalf("DefaultTweetVisibility = %q, want friend", profile.DefaultTweetVisibility)
}
if profile.CopyrightRight != "fallback-right" {
t.Fatalf("CopyrightRight = %q, want fallback-right", profile.CopyrightRight)
}
}
func TestUpdateEditableProfilePersistsOnlyEditableKeys(t *testing.T) {
svc := newTestService(t)
profile, err := svc.UpdateEditableProfile(context.Background(), EditableProfile{
UseFriendship: false,
EnableTrendsBar: true,
EnableWallet: true,
AllowTweetAttachment: false,
AllowTweetAttachmentPrice: false,
AllowTweetVideo: false,
DefaultTweetMaxLength: 1200,
TweetWebEllipsisSize: 300,
TweetMobileEllipsisSize: 200,
DefaultTweetVisibility: "public",
DefaultMsgLoopInterval: 3000,
CopyrightTop: "top",
CopyrightLeft: "left",
CopyrightLeftLink: "https://left.example.com",
CopyrightRight: "right",
CopyrightRightLink: "https://right.example.com",
})
if err != nil {
t.Fatalf("UpdateEditableProfile() error = %v", err)
}
if !profile.AllowUserRegister {
t.Fatalf("AllowUserRegister = false, want bootstrap true")
}
if !profile.AllowPhoneBind {
t.Fatalf("AllowPhoneBind = false, want bootstrap true")
}
if profile.DefaultTweetVisibility != "public" {
t.Fatalf("DefaultTweetVisibility = %q, want public", profile.DefaultTweetVisibility)
}
values, err := svc.GetValues(context.Background())
if err != nil {
t.Fatalf("GetValues() error = %v", err)
}
if !hasValue(values.Items, "web_profile.enable_wallet", true, false) {
t.Fatalf("web_profile.enable_wallet override not found")
}
}
func TestSaveValuesEncryptsSecretsAtRest(t *testing.T) {
svc := newTestService(t)
conf.AdminSettingsSetting.EncryptionKey = "bootstrap-test-encryption-key"
svc.codec = newSecretCodec()
_, err := svc.SaveValues(context.Background(), []web.AdminSettingValueInput{{
Key: "meili.api_key",
Value: []byte(`"top-secret-key"`),
}})
if err != nil {
t.Fatalf("SaveValues() error = %v", err)
}
var record settingRecord
if err := svc.db.WithContext(context.Background()).First(&record, "key = ?", "meili.api_key").Error; err != nil {
t.Fatalf("load record error = %v", err)
}
if !record.IsEncrypted {
t.Fatalf("IsEncrypted = false, want true")
}
if strings.Contains(record.Value, "top-secret-key") {
t.Fatalf("record.Value stored plaintext = %q", record.Value)
}
}
func TestRestartRequiredValuesReportPendingRestart(t *testing.T) {
svc := newTestService(t)
resp, err := svc.SaveValues(context.Background(), []web.AdminSettingValueInput{{
Key: "meili.host",
Value: []byte(`"pending-restart:7700"`),
}})
if err != nil {
t.Fatalf("SaveValues() error = %v", err)
}
if !resp.HasPendingRestart {
t.Fatal("HasPendingRestart = false, want true")
}
if !hasValue(resp.Items, "meili.host", "pending-restart:7700", true) {
t.Fatalf("meili.host pending_restart not reported")
}
}
func TestSaveValuesRejectsInvalidSingleIntUpdate(t *testing.T) {
svc := newTestService(t)
_, err := svc.SaveValues(context.Background(), []web.AdminSettingValueInput{{
Key: "web_profile.default_tweet_max_length",
Value: []byte(`120`),
}})
if err == nil {
t.Fatal("SaveValues() error = nil, want invalid params error")
}
}
func TestSaveValuesAcceptsCoupledProfileUpdate(t *testing.T) {
svc := newTestService(t)
resp, err := svc.SaveValues(context.Background(), []web.AdminSettingValueInput{
{Key: "web_profile.default_tweet_max_length", Value: []byte(`120`)},
{Key: "web_profile.tweet_web_ellipsis_size", Value: []byte(`120`)},
{Key: "web_profile.tweet_mobile_ellipsis_size", Value: []byte(`120`)},
})
if err != nil {
t.Fatalf("SaveValues() error = %v", err)
}
if !hasValue(resp.Items, "web_profile.default_tweet_max_length", 120, false) {
t.Fatalf("web_profile.default_tweet_max_length override not found")
}
if conf.WebProfileSetting.DefaultTweetMaxLength != 120 {
t.Fatalf("DefaultTweetMaxLength = %d, want 120", conf.WebProfileSetting.DefaultTweetMaxLength)
}
}
func TestBootstrapAppliesPersistedOverrides(t *testing.T) {
svc := newTestService(t)
conf.AdminSettingsSetting.EncryptionKey = "bootstrap-test-encryption-key"
svc.codec = newSecretCodec()
_, err := svc.SaveValues(context.Background(), []web.AdminSettingValueInput{{
Key: "web_profile.enable_wallet",
Value: []byte(`true`),
}})
if err != nil {
t.Fatalf("SaveValues() error = %v", err)
}
conf.WebProfileSetting.EnableWallet = false
Bootstrap(context.Background(), svc.db)
if !conf.WebProfileSetting.EnableWallet {
t.Fatal("Bootstrap() did not apply persisted web_profile.enable_wallet override")
}
}
func newTestService(t *testing.T) *Service {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("os.Getwd() error = %v", err)
}
root := filepath.Clean(filepath.Join(wd, "..", ".."))
if err := os.Chdir(root); err != nil {
t.Fatalf("os.Chdir(%q) error = %v", root, err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
conf.Initial(nil, false)
conf.WebProfileSetting = &conf.WebProfileConf{
UseFriendship: true,
EnableTrendsBar: false,
EnableWallet: false,
AllowTweetAttachment: true,
AllowTweetAttachmentPrice: true,
AllowTweetVideo: true,
AllowUserRegister: true,
AllowPhoneBind: true,
DefaultTweetMaxLength: 2000,
TweetWebEllipsisSize: 400,
TweetMobileEllipsisSize: 300,
DefaultTweetVisibility: "friend",
DefaultMsgLoopInterval: 5000,
CopyrightTop: "fallback-top",
CopyrightLeft: "fallback-left",
CopyrightLeftLink: "",
CopyrightRight: "fallback-right",
CopyrightRightLink: "https://fallback.example.com",
}
bootstrapConfig = nil
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "p_", SingularTable: true}})
if err != nil {
t.Fatalf("gorm.Open() error = %v", err)
}
if err := db.AutoMigrate(&settingRecord{}); err != nil {
t.Fatalf("AutoMigrate() error = %v", err)
}
return NewService(db)
}
func hasValue(items []web.AdminSettingValue, key string, expected any, pending bool) bool {
for _, item := range items {
if item.Key != key {
continue
}
return item.Value == expected && item.PendingRestart == pending
}
return false
}

@ -11,6 +11,11 @@ type Admin struct {
Schema `mir:"v1,chain"`
// ChangeUserStatus 管理·禁言/解封用户
ChangeUserStatus func(Post, web.ChangeUserStatusReq) `mir:"admin/user/status"`
SiteInfo func(Get, web.SiteInfoReq) web.SiteInfoResp `mir:"admin/site/status"`
ChangeUserStatus func(Post, web.ChangeUserStatusReq) `mir:"admin/user/status"`
SiteInfo func(Get, web.SiteInfoReq) web.SiteInfoResp `mir:"admin/site/status"`
GetSiteSettings func(Get) web.SiteSettingsResp `mir:"admin/site/profile"`
UpdateSiteSettings func(Post, web.SiteSettingsReq) web.SiteSettingsResp `mir:"admin/site/profile"`
GetSettingsSchema func(Get) web.AdminSettingsSchemaResp `mir:"admin/settings/schema"`
GetSettingsValues func(Get) web.AdminSettingsValuesResp `mir:"admin/settings/values"`
SaveSettings func(Post, web.AdminSettingsSaveReq) web.AdminSettingsSaveResp `mir:"admin/settings/save"`
}

@ -0,0 +1 @@
DROP TABLE IF EXISTS `p_site_settings`;

@ -0,0 +1,24 @@
CREATE TABLE `p_site_settings` (
`id` BIGINT NOT NULL DEFAULT 1,
`use_friendship` TINYINT NOT NULL DEFAULT 0,
`enable_trends_bar` TINYINT NOT NULL DEFAULT 0,
`enable_wallet` TINYINT NOT NULL DEFAULT 0,
`allow_tweet_attachment` TINYINT NOT NULL DEFAULT 0,
`allow_tweet_attachment_price` TINYINT NOT NULL DEFAULT 0,
`allow_tweet_video` TINYINT NOT NULL DEFAULT 0,
`default_tweet_max_length` INT NOT NULL DEFAULT 2000,
`tweet_web_ellipsis_size` INT NOT NULL DEFAULT 400,
`tweet_mobile_ellipsis_size` INT NOT NULL DEFAULT 300,
`default_tweet_visibility` VARCHAR(32) NOT NULL DEFAULT 'friend',
`default_msg_loop_interval` INT NOT NULL DEFAULT 5000,
`copyright_top` VARCHAR(255) NOT NULL DEFAULT '',
`copyright_left` VARCHAR(255) NOT NULL DEFAULT '',
`copyright_left_link` VARCHAR(255) NOT NULL DEFAULT '',
`copyright_right` VARCHAR(255) NOT NULL DEFAULT '',
`copyright_right_link` VARCHAR(255) NOT NULL DEFAULT '',
`created_on` BIGINT NOT NULL DEFAULT 0,
`modified_on` BIGINT NOT NULL DEFAULT 0,
`deleted_on` BIGINT NOT NULL DEFAULT 0,
`is_del` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='站点配置';

@ -0,0 +1,26 @@
DROP TABLE IF EXISTS `p_site_settings`;
CREATE TABLE `p_site_settings` (
`id` BIGINT NOT NULL DEFAULT 1,
`use_friendship` TINYINT NOT NULL DEFAULT 0,
`enable_trends_bar` TINYINT NOT NULL DEFAULT 0,
`enable_wallet` TINYINT NOT NULL DEFAULT 0,
`allow_tweet_attachment` TINYINT NOT NULL DEFAULT 0,
`allow_tweet_attachment_price` TINYINT NOT NULL DEFAULT 0,
`allow_tweet_video` TINYINT NOT NULL DEFAULT 0,
`default_tweet_max_length` INT NOT NULL DEFAULT 2000,
`tweet_web_ellipsis_size` INT NOT NULL DEFAULT 400,
`tweet_mobile_ellipsis_size` INT NOT NULL DEFAULT 300,
`default_tweet_visibility` VARCHAR(32) NOT NULL DEFAULT 'friend',
`default_msg_loop_interval` INT NOT NULL DEFAULT 5000,
`copyright_top` VARCHAR(255) NOT NULL DEFAULT '',
`copyright_left` VARCHAR(255) NOT NULL DEFAULT '',
`copyright_left_link` VARCHAR(255) NOT NULL DEFAULT '',
`copyright_right` VARCHAR(255) NOT NULL DEFAULT '',
`copyright_right_link` VARCHAR(255) NOT NULL DEFAULT '',
`created_on` BIGINT NOT NULL DEFAULT 0,
`modified_on` BIGINT NOT NULL DEFAULT 0,
`deleted_on` BIGINT NOT NULL DEFAULT 0,
`is_del` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='站点配置';

@ -0,0 +1,49 @@
RENAME TABLE `p_site_settings` TO `p_site_settings_legacy`;
CREATE TABLE `p_site_settings` (
`key` VARCHAR(191) NOT NULL,
`value` TEXT NOT NULL,
`is_encrypted` TINYINT NOT NULL DEFAULT 0,
`created_on` BIGINT NOT NULL DEFAULT 0,
`modified_on` BIGINT NOT NULL DEFAULT 0,
`deleted_on` BIGINT NOT NULL DEFAULT 0,
`is_del` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`key`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Admin settings overrides';
INSERT INTO `p_site_settings` (`key`, `value`, `is_encrypted`, `created_on`, `modified_on`, `deleted_on`, `is_del`)
SELECT `key`, `value`, 0, created_on, modified_on, 0, 0 FROM (
SELECT 'web_profile.use_friendship' AS `key`, IF(use_friendship <> 0, 'true', 'false') AS `value`, created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.enable_trends_bar', IF(enable_trends_bar <> 0, 'true', 'false'), created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.enable_wallet', IF(enable_wallet <> 0, 'true', 'false'), created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.allow_tweet_attachment', IF(allow_tweet_attachment <> 0, 'true', 'false'), created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.allow_tweet_attachment_price', IF(allow_tweet_attachment_price <> 0, 'true', 'false'), created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.allow_tweet_video', IF(allow_tweet_video <> 0, 'true', 'false'), created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.default_tweet_max_length', CAST(default_tweet_max_length AS CHAR), created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.tweet_web_ellipsis_size', CAST(tweet_web_ellipsis_size AS CHAR), created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.tweet_mobile_ellipsis_size', CAST(tweet_mobile_ellipsis_size AS CHAR), created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.default_tweet_visibility', default_tweet_visibility, created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.default_msg_loop_interval', CAST(default_msg_loop_interval AS CHAR), created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_top', copyright_top, created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_left', copyright_left, created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_left_link', copyright_left_link, created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_right', copyright_right, created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_right_link', copyright_right_link, created_on, modified_on FROM `p_site_settings_legacy` WHERE id = 1
) AS legacy_rows;
DROP TABLE `p_site_settings_legacy`;

@ -0,0 +1 @@
DROP TABLE IF EXISTS p_site_settings;

@ -0,0 +1,23 @@
CREATE TABLE p_site_settings (
id BIGINT PRIMARY KEY DEFAULT 1,
use_friendship BOOLEAN NOT NULL DEFAULT false,
enable_trends_bar BOOLEAN NOT NULL DEFAULT false,
enable_wallet BOOLEAN NOT NULL DEFAULT false,
allow_tweet_attachment BOOLEAN NOT NULL DEFAULT false,
allow_tweet_attachment_price BOOLEAN NOT NULL DEFAULT false,
allow_tweet_video BOOLEAN NOT NULL DEFAULT false,
default_tweet_max_length INTEGER NOT NULL DEFAULT 2000,
tweet_web_ellipsis_size INTEGER NOT NULL DEFAULT 400,
tweet_mobile_ellipsis_size INTEGER NOT NULL DEFAULT 300,
default_tweet_visibility VARCHAR(32) NOT NULL DEFAULT 'friend',
default_msg_loop_interval INTEGER NOT NULL DEFAULT 5000,
copyright_top VARCHAR(255) NOT NULL DEFAULT '',
copyright_left VARCHAR(255) NOT NULL DEFAULT '',
copyright_left_link VARCHAR(255) NOT NULL DEFAULT '',
copyright_right VARCHAR(255) NOT NULL DEFAULT '',
copyright_right_link VARCHAR(255) NOT NULL DEFAULT '',
created_on BIGINT NOT NULL DEFAULT 0,
modified_on BIGINT NOT NULL DEFAULT 0,
deleted_on BIGINT NOT NULL DEFAULT 0,
is_del SMALLINT NOT NULL DEFAULT 0
);

@ -0,0 +1,25 @@
DROP TABLE IF EXISTS p_site_settings;
CREATE TABLE p_site_settings (
id BIGINT PRIMARY KEY DEFAULT 1,
use_friendship BOOLEAN NOT NULL DEFAULT false,
enable_trends_bar BOOLEAN NOT NULL DEFAULT false,
enable_wallet BOOLEAN NOT NULL DEFAULT false,
allow_tweet_attachment BOOLEAN NOT NULL DEFAULT false,
allow_tweet_attachment_price BOOLEAN NOT NULL DEFAULT false,
allow_tweet_video BOOLEAN NOT NULL DEFAULT false,
default_tweet_max_length INTEGER NOT NULL DEFAULT 2000,
tweet_web_ellipsis_size INTEGER NOT NULL DEFAULT 400,
tweet_mobile_ellipsis_size INTEGER NOT NULL DEFAULT 300,
default_tweet_visibility VARCHAR(32) NOT NULL DEFAULT 'friend',
default_msg_loop_interval INTEGER NOT NULL DEFAULT 5000,
copyright_top VARCHAR(255) NOT NULL DEFAULT '',
copyright_left VARCHAR(255) NOT NULL DEFAULT '',
copyright_left_link VARCHAR(255) NOT NULL DEFAULT '',
copyright_right VARCHAR(255) NOT NULL DEFAULT '',
copyright_right_link VARCHAR(255) NOT NULL DEFAULT '',
created_on BIGINT NOT NULL DEFAULT 0,
modified_on BIGINT NOT NULL DEFAULT 0,
deleted_on BIGINT NOT NULL DEFAULT 0,
is_del SMALLINT NOT NULL DEFAULT 0
);

@ -0,0 +1,48 @@
ALTER TABLE p_site_settings RENAME TO p_site_settings_legacy;
CREATE TABLE p_site_settings (
key VARCHAR(191) PRIMARY KEY,
value TEXT NOT NULL,
is_encrypted BOOLEAN NOT NULL DEFAULT false,
created_on BIGINT NOT NULL DEFAULT 0,
modified_on BIGINT NOT NULL DEFAULT 0,
deleted_on BIGINT NOT NULL DEFAULT 0,
is_del SMALLINT NOT NULL DEFAULT 0
);
INSERT INTO p_site_settings (key, value, is_encrypted, created_on, modified_on, deleted_on, is_del)
SELECT key, value, false, created_on, modified_on, 0, 0 FROM (
SELECT 'web_profile.use_friendship' AS key, CASE WHEN use_friendship THEN 'true' ELSE 'false' END AS value, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.enable_trends_bar', CASE WHEN enable_trends_bar THEN 'true' ELSE 'false' END, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.enable_wallet', CASE WHEN enable_wallet THEN 'true' ELSE 'false' END, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.allow_tweet_attachment', CASE WHEN allow_tweet_attachment THEN 'true' ELSE 'false' END, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.allow_tweet_attachment_price', CASE WHEN allow_tweet_attachment_price THEN 'true' ELSE 'false' END, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.allow_tweet_video', CASE WHEN allow_tweet_video THEN 'true' ELSE 'false' END, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.default_tweet_max_length', default_tweet_max_length::TEXT, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.tweet_web_ellipsis_size', tweet_web_ellipsis_size::TEXT, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.tweet_mobile_ellipsis_size', tweet_mobile_ellipsis_size::TEXT, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.default_tweet_visibility', default_tweet_visibility, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.default_msg_loop_interval', default_msg_loop_interval::TEXT, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_top', copyright_top, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_left', copyright_left, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_left_link', copyright_left_link, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_right', copyright_right, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_right_link', copyright_right_link, created_on, modified_on FROM p_site_settings_legacy WHERE id = 1
) legacy_rows;
DROP TABLE p_site_settings_legacy;

@ -0,0 +1 @@
DROP TABLE IF EXISTS "p_site_settings";

@ -0,0 +1,24 @@
CREATE TABLE "p_site_settings" (
"id" integer NOT NULL DEFAULT 1,
"use_friendship" integer NOT NULL DEFAULT 0,
"enable_trends_bar" integer NOT NULL DEFAULT 0,
"enable_wallet" integer NOT NULL DEFAULT 0,
"allow_tweet_attachment" integer NOT NULL DEFAULT 0,
"allow_tweet_attachment_price" integer NOT NULL DEFAULT 0,
"allow_tweet_video" integer NOT NULL DEFAULT 0,
"default_tweet_max_length" integer NOT NULL DEFAULT 2000,
"tweet_web_ellipsis_size" integer NOT NULL DEFAULT 400,
"tweet_mobile_ellipsis_size" integer NOT NULL DEFAULT 300,
"default_tweet_visibility" text(32) NOT NULL DEFAULT 'friend',
"default_msg_loop_interval" integer NOT NULL DEFAULT 5000,
"copyright_top" text(255) NOT NULL DEFAULT '',
"copyright_left" text(255) NOT NULL DEFAULT '',
"copyright_left_link" text(255) NOT NULL DEFAULT '',
"copyright_right" text(255) NOT NULL DEFAULT '',
"copyright_right_link" text(255) NOT NULL DEFAULT '',
"created_on" integer NOT NULL DEFAULT 0,
"modified_on" integer NOT NULL DEFAULT 0,
"deleted_on" integer NOT NULL DEFAULT 0,
"is_del" integer NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
);

@ -0,0 +1,26 @@
DROP TABLE IF EXISTS "p_site_settings";
CREATE TABLE "p_site_settings" (
"id" integer NOT NULL DEFAULT 1,
"use_friendship" integer NOT NULL DEFAULT 0,
"enable_trends_bar" integer NOT NULL DEFAULT 0,
"enable_wallet" integer NOT NULL DEFAULT 0,
"allow_tweet_attachment" integer NOT NULL DEFAULT 0,
"allow_tweet_attachment_price" integer NOT NULL DEFAULT 0,
"allow_tweet_video" integer NOT NULL DEFAULT 0,
"default_tweet_max_length" integer NOT NULL DEFAULT 2000,
"tweet_web_ellipsis_size" integer NOT NULL DEFAULT 400,
"tweet_mobile_ellipsis_size" integer NOT NULL DEFAULT 300,
"default_tweet_visibility" text(32) NOT NULL DEFAULT 'friend',
"default_msg_loop_interval" integer NOT NULL DEFAULT 5000,
"copyright_top" text(255) NOT NULL DEFAULT '',
"copyright_left" text(255) NOT NULL DEFAULT '',
"copyright_left_link" text(255) NOT NULL DEFAULT '',
"copyright_right" text(255) NOT NULL DEFAULT '',
"copyright_right_link" text(255) NOT NULL DEFAULT '',
"created_on" integer NOT NULL DEFAULT 0,
"modified_on" integer NOT NULL DEFAULT 0,
"deleted_on" integer NOT NULL DEFAULT 0,
"is_del" integer NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
);

@ -0,0 +1,49 @@
ALTER TABLE "p_site_settings" RENAME TO "p_site_settings_legacy";
CREATE TABLE "p_site_settings" (
"key" text NOT NULL,
"value" text NOT NULL DEFAULT '',
"is_encrypted" integer NOT NULL DEFAULT 0,
"created_on" integer NOT NULL DEFAULT 0,
"modified_on" integer NOT NULL DEFAULT 0,
"deleted_on" integer NOT NULL DEFAULT 0,
"is_del" integer NOT NULL DEFAULT 0,
PRIMARY KEY ("key")
);
INSERT INTO "p_site_settings" ("key", "value", "is_encrypted", "created_on", "modified_on", "deleted_on", "is_del")
SELECT "key", "value", 0, created_on, modified_on, 0, 0 FROM (
SELECT 'web_profile.use_friendship' AS "key", CASE WHEN use_friendship <> 0 THEN 'true' ELSE 'false' END AS "value", created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.enable_trends_bar', CASE WHEN enable_trends_bar <> 0 THEN 'true' ELSE 'false' END, created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.enable_wallet', CASE WHEN enable_wallet <> 0 THEN 'true' ELSE 'false' END, created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.allow_tweet_attachment', CASE WHEN allow_tweet_attachment <> 0 THEN 'true' ELSE 'false' END, created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.allow_tweet_attachment_price', CASE WHEN allow_tweet_attachment_price <> 0 THEN 'true' ELSE 'false' END, created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.allow_tweet_video', CASE WHEN allow_tweet_video <> 0 THEN 'true' ELSE 'false' END, created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.default_tweet_max_length', CAST(default_tweet_max_length AS TEXT), created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.tweet_web_ellipsis_size', CAST(tweet_web_ellipsis_size AS TEXT), created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.tweet_mobile_ellipsis_size', CAST(tweet_mobile_ellipsis_size AS TEXT), created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.default_tweet_visibility', default_tweet_visibility, created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.default_msg_loop_interval', CAST(default_msg_loop_interval AS TEXT), created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_top', copyright_top, created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_left', copyright_left, created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_left_link', copyright_left_link, created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_right', copyright_right, created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
UNION ALL
SELECT 'web_profile.copyright_right_link', copyright_right_link, created_on, modified_on FROM "p_site_settings_legacy" WHERE id = 1
);
DROP TABLE "p_site_settings_legacy";

@ -513,4 +513,16 @@ UNION
SELECT user_id, follow_id he_uid, 10 AS style
FROM p_following WHERE is_del=0;
DROP TABLE IF EXISTS `p_site_settings`;
CREATE TABLE `p_site_settings` (
`key` VARCHAR(191) NOT NULL,
`value` TEXT NOT NULL,
`is_encrypted` TINYINT NOT NULL DEFAULT 0,
`created_on` BIGINT NOT NULL DEFAULT 0,
`modified_on` BIGINT NOT NULL DEFAULT 0,
`deleted_on` BIGINT NOT NULL DEFAULT 0,
`is_del` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`key`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Admin settings overrides';
SET FOREIGN_KEY_CHECKS = 1;

@ -425,3 +425,14 @@ FROM p_contact WHERE status=2 AND is_del=0
UNION
SELECT user_id, follow_id he_uid, 10 AS style
FROM p_following WHERE is_del=0;
DROP TABLE IF EXISTS p_site_settings;
CREATE TABLE p_site_settings (
key VARCHAR(191) PRIMARY KEY,
value TEXT NOT NULL,
is_encrypted BOOLEAN NOT NULL DEFAULT false,
created_on BIGINT NOT NULL DEFAULT 0,
modified_on BIGINT NOT NULL DEFAULT 0,
deleted_on BIGINT NOT NULL DEFAULT 0,
is_del SMALLINT NOT NULL DEFAULT 0
);

@ -734,4 +734,16 @@ ON "p_wallet_statement" (
"user_id" ASC
);
DROP TABLE IF EXISTS "p_site_settings";
CREATE TABLE "p_site_settings" (
"key" text NOT NULL,
"value" text NOT NULL DEFAULT '',
"is_encrypted" integer NOT NULL DEFAULT 0,
"created_on" integer NOT NULL DEFAULT 0,
"modified_on" integer NOT NULL DEFAULT 0,
"deleted_on" integer NOT NULL DEFAULT 0,
"is_del" integer NOT NULL DEFAULT 0,
PRIMARY KEY ("key")
);
PRAGMA foreign_keys = true;

@ -1,4 +1,4 @@
VITE_HOST=""
VITE_HOST="http://10.10.100.113:8008"
# 功能特性开启
VITE_USE_FRIENDSHIP=true
@ -28,11 +28,11 @@ VITE_DEFAULT_COMMENT_MAX_LENGTH=300 # 评论最大长度, 默认300字
VITE_DEFAULT_REPLY_MAX_LENGTH=300 # 评论最大长度, 默认300字
VITE_RIGHT_FOLLOW_TOPIC_MAX_SIZE=6 # 右侧关注话题最大条目数, 默认6条
VITE_RIGHT_HOT_TOPIC_MAX_SIZE=12 # 右侧热门话题最大条目数, 默认12条
VITE_COPYRIGHT_TOP="2023 paopao.info"
VITE_COPYRIGHT_LEFT="Roc's Me"
VITE_COPYRIGHT_TOP="2026 PaoPao"
VITE_COPYRIGHT_LEFT=""
VITE_COPYRIGHT_LEFT_LINK=""
VITE_COPYRIGHT_RIGHT="泡泡(PaoPao)开源社区"
VITE_COPYRIGHT_RIGHT_LINK="https://www.paopao.info"
VITE_COPYRIGHT_RIGHT="Github"
VITE_COPYRIGHT_RIGHT_LINK="https://github.com/rocboss/paopao-ce"
# 图片推文参数
VITE_DEFAULT_TWEET_IMAGE_404="https://paopao-assets.oss-cn-shanghai.aliyuncs.com/public/404.png"

@ -1 +0,0 @@
import{A as e,D as t,O as n,Q as r,W as i,w as a}from"./@css-render-YsNuQTaE.js";import{i as o}from"./vue-router-Dw9lfhBK.js";import{$ as s,m as c,x as l}from"./naive-ui-Dv03sO69.js";import"./@juggle-BrKhuOcS.js";import{r as u}from"./sidebar-B2rODElh.js";import{t as d}from"./main-nav-BW6UKmWQ.js";var f=u(e({__name:`404`,setup(e){let u=o(),f=()=>{u.push({path:`/`})};return(e,o)=>{let u=d,p=s,m=c,h=l;return i(),a(`div`,null,[n(u,{title:`404`}),n(h,{class:`main-content-wrap wrap404`,bordered:``},{default:r(()=>[n(m,{status:`404`,title:`404 资源不存在`,description:`再看看其他的吧`},{footer:r(()=>[n(p,{onClick:f},{default:r(()=>[...o[0]||=[t(`回主页`,-1)]]),_:1})]),_:1})]),_:1})])}}}),[[`__scopeId`,`data-v-21b99e62`]]);export{f as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
import{A as e,Bt as t,C as n,H as r,It as i,K as a,O as o,Q as s,S as c,W as l,h as u,ht as d,lt as f,w as p,x as m}from"./@css-render-YsNuQTaE.js";import{r as h}from"./pinia-TbaIYBWH.js";import{r as g}from"./vue-router-Dw9lfhBK.js";import{X as _,b as v,ot as y,x as b}from"./naive-ui-Dv03sO69.js";import"./@juggle-BrKhuOcS.js";import{l as x,r as S}from"./sidebar-B2rODElh.js";import"./moment-D_9q9veS.js";import{i as C}from"./index-BzZcw6rK.js";import{t as w}from"./main-nav-BW6UKmWQ.js";import{t as T}from"./post-skeleton-CJ8iRGBT.js";var E={key:0,class:`pagination-wrap`},D={key:0,class:`skeleton-wrap`},O={key:1},k={key:0,class:`empty-wrap`},A={class:`bill-line`},j=S(e({__name:`Anouncement`,setup(e){let{collapsedRight:S}=h(x()),j=g(),M=f(!1),N=f([]),P=f(+j.query.p||1),F=f(20),I=f(0),L=e=>{P.value=e};return r(()=>{}),(e,r)=>{let f=w,h=_,g=T,x=y,j=v,R=b;return l(),p(`div`,null,[o(f,{title:`公告`}),o(R,{class:`main-content-wrap`,bordered:``},{footer:s(()=>[I.value>1?(l(),p(`div`,E,[o(h,{page:P.value,"onUpdate:page":L,"page-slot":d(S)?5:8,"page-count":I.value},null,8,[`page`,`page-slot`,`page-count`])])):n(``,!0)]),default:s(()=>[M.value?(l(),p(`div`,D,[o(g,{num:F.value},null,8,[`num`])])):(l(),p(`div`,O,[N.value.length===0?(l(),p(`div`,k,[o(x,{size:`large`,description:`暂无数据`})])):n(``,!0),(l(!0),p(u,null,a(N.value,e=>(l(),c(j,{key:e.id},{default:s(()=>[m(`div`,A,[m(`div`,null,`NO.`+t(e.id),1),m(`div`,null,t(e.reason),1),m(`div`,{class:i({income:e.change_amount>=0,out:e.change_amount<0})},t((e.change_amount>0?`+`:``)+(e.change_amount/100).toFixed(2)),3),m(`div`,null,t(d(C)(e.created_on)),1)])]),_:2},1024))),128))]))]),_:1})])}}}),[[`__scopeId`,`data-v-2279d607`]]);export{j as default};

@ -1 +0,0 @@
import{o as e}from"./rolldown-runtime-Bhmf7a9N.js";import{A as t,Bt as n,C as r,H as i,K as a,O as o,Q as s,S as c,W as l,h as u,ht as d,lt as f,w as p,x as m}from"./@css-render-YsNuQTaE.js";import{r as h}from"./pinia-TbaIYBWH.js";import{r as g}from"./vue-router-Dw9lfhBK.js";import{P as _,U as v,b as y,f as b,ot as x,x as S}from"./naive-ui-Dv03sO69.js";import"./@juggle-BrKhuOcS.js";import{a as C,c as w,l as T,r as E}from"./sidebar-B2rODElh.js";import"./moment-D_9q9veS.js";import{t as D}from"./main-nav-BW6UKmWQ.js";import{t as O}from"./post-skeleton-CJ8iRGBT.js";import{n as k}from"./useUserAction-DVqObL5J.js";import"./usePostContent-DV6QgefX.js";import"./copy-to-clipboard-qdVIK2zo.js";import{t as A}from"./post-item-iSi0SGnI.js";import"./@vue-cLJXtEPP.js";import{t as j}from"./v3-infinite-loading-Dj1QGmV8.js";var M=e(j()),N={key:0,class:`skeleton-wrap`},P={key:1},F={key:0,class:`empty-wrap`},I={class:`load-more-wrap`},L={class:`load-more-spinner`},R=E(t({__name:`Collection`,setup(e){let t=T(),E=w(),{collapsedRight:j,desktopModelShow:R}=h(t),{userInfo:z}=h(E),B=g();v();let V=f(!1),H=f(!1),U=f([]),W=f(+B.query.p||1),G=f(20),K=f(0),q=f(!1),J=f({id:0,avatar:``,username:``,nickname:``,is_admin:!1,is_friend:!0,is_following:!1,created_on:0,follows:0,followings:0,status:1}),Y=e=>{J.value=e,q.value=!0},X=()=>{q.value=!1};function Z(e,t){for(let n in U.value)U.value[n].user_id==e&&(U.value[n].user.is_following=t)}let Q=()=>{V.value=!0,C.v1.user.get.collections({page:W.value,page_size:G.value}).then(e=>{V.value=!1,e.list.length===0&&(H.value=!0),W.value>1?U.value=U.value.concat(e.list):(U.value=e.list,window.scrollTo(0,0)),K.value=Math.ceil(e.pager.total_rows/G.value)}).catch(e=>{V.value=!1,W.value>1&&W.value--})},$=()=>{W.value<K.value||K.value==0?(H.value=!1,W.value++,Q()):H.value=!0};return i(()=>{Q()}),(e,t)=>{let i=D,f=O,h=x,g=A,v=y,C=k,w=S,T=b,E=_;return l(),p(`div`,null,[o(i,{title:`收藏`}),o(w,{class:`main-content-wrap`,bordered:``},{default:s(()=>[V.value&&U.value.length===0?(l(),p(`div`,N,[o(f,{num:G.value},null,8,[`num`])])):(l(),p(`div`,P,[U.value.length===0?(l(),p(`div`,F,[o(h,{size:`large`,description:`暂无数据`})])):r(``,!0),(l(!0),p(u,null,a(U.value,e=>(l(),c(v,{key:e.id},{default:s(()=>[o(g,{post:e,isOwner:d(z).id==e.user_id,isMobile:!d(R),addFollowAction:``,onSendWhisper:Y,onPostFollowAction:Z},null,8,[`post`,`isOwner`,`isMobile`])]),_:2},1024))),128))])),o(C,{show:q.value,user:J.value,onSuccess:X},null,8,[`show`,`user`])]),_:1}),K.value>0?(l(),c(E,{key:0,justify:`center`},{default:s(()=>[o(d(M.default),{class:`load-more`,slots:{complete:`没有更多收藏了`,error:`加载出错`},onInfinite:$},{spinner:s(()=>[m(`div`,I,[H.value?r(``,!0):(l(),c(T,{key:0,size:14})),m(`span`,L,n(H.value?`没有更多收藏了`:`加载更多`),1)])]),_:1})]),_:1})):r(``,!0)])}}}),[[`__scopeId`,`data-v-c376cb92`]]);export{R as default};

@ -1 +0,0 @@
import{A as e,C as t,H as n,K as r,O as i,Q as a,S as o,W as s,h as c,ht as l,lt as u,w as d}from"./@css-render-YsNuQTaE.js";import{r as f}from"./vue-router-Dw9lfhBK.js";import{b as p,ot as m,x as h}from"./naive-ui-Dv03sO69.js";import"./@juggle-BrKhuOcS.js";import{a as g,r as _}from"./sidebar-B2rODElh.js";import"./moment-D_9q9veS.js";import{t as v}from"./main-nav-BW6UKmWQ.js";import{t as y}from"./post-skeleton-CJ8iRGBT.js";import{n as b}from"./useUserAction-DVqObL5J.js";import"./@vue-cLJXtEPP.js";import"./v3-infinite-loading-Dj1QGmV8.js";import{t as x}from"./usePagination-B2kaN_Rm.js";import{t as S}from"./infinite-load-more-CuisQVv6.js";import{t as C}from"./user-card-CSodY5hi.js";var w={key:0,class:`skeleton-wrap`},T={key:1},E={key:0,class:`empty-wrap`},D=_(e({__name:`Contacts`,setup(e){let _=f(),{loading:D,noMore:O,page:k,pageSize:A,totalPage:j}=x(20),M=u([]),N=u(!1),P=u({id:0,avatar:``,username:``,nickname:``,is_admin:!1,is_friend:!0,is_following:!1,created_on:0,follows:0,followings:0,status:1});k.value=+_.query.p||1;let F=e=>{P.value=e,N.value=!0},I=()=>{N.value=!1},L=()=>{k.value<j.value||j.value==0?(O.value=!1,k.value++,R()):O.value=!0};n(()=>{R()});let R=(e=!1)=>{M.value.length===0&&(D.value=!0),g.v1.user.get.contacts({page:k.value,page_size:A.value}).then(t=>{D.value=!1,t.list.length===0&&(O.value=!0),k.value>1?M.value=M.value.concat(t.list):(M.value=t.list,e&&setTimeout(()=>{window.scrollTo(0,99999)},50)),j.value=Math.ceil(t.pager.total_rows/A.value)}).catch(e=>{D.value=!1,k.value>1&&k.value--})};return(e,n)=>{let u=v,f=y,g=m,_=p,x=b,k=h;return s(),d(`div`,null,[i(u,{title:`好友`}),i(k,{class:`main-content-wrap`,bordered:``},{default:a(()=>[l(D)&&M.value.length===0?(s(),d(`div`,w,[i(f,{num:l(A)},null,8,[`num`])])):(s(),d(`div`,T,[M.value.length===0?(s(),d(`div`,E,[i(g,{size:`large`,description:`暂无数据`})])):t(``,!0),(s(!0),d(c,null,r(M.value,e=>(s(),o(_,{class:`list-item`,key:e.user_id},{default:a(()=>[i(C,{type:`contact`,contact:e,onSendWhisper:F},null,8,[`contact`])]),_:2},1024))),128))])),i(x,{show:N.value,user:P.value,onSuccess:I},null,8,[`show`,`user`])]),_:1}),i(S,{"total-page":l(j),"no-more":l(O),"complete-text":`没有更多好友了`,onLoadMore:L},null,8,[`total-page`,`no-more`])])}}}),[[`__scopeId`,`data-v-98a9eb10`]]);export{D as default};

@ -1 +0,0 @@
import{o as e}from"./rolldown-runtime-Bhmf7a9N.js";import{A as t,Bt as n,C as r,H as i,K as a,O as o,Q as s,S as c,W as l,b as u,h as d,ht as f,lt as p,w as m,x as h}from"./@css-render-YsNuQTaE.js";import{r as g}from"./vue-router-Dw9lfhBK.js";import{P as _,b as v,c as ee,f as y,l as b,ot as x,x as S}from"./naive-ui-Dv03sO69.js";import"./@juggle-BrKhuOcS.js";import{a as C,r as w}from"./sidebar-B2rODElh.js";import"./moment-D_9q9veS.js";import{t as T}from"./main-nav-BW6UKmWQ.js";import{t as E}from"./post-skeleton-CJ8iRGBT.js";import{n as D,t as O}from"./useUserAction-DVqObL5J.js";import"./@vue-cLJXtEPP.js";import{t as k}from"./v3-infinite-loading-Dj1QGmV8.js";import{t as A}from"./usePagination-B2kaN_Rm.js";import{t as j}from"./user-card-CSodY5hi.js";var M=e(k()),N={key:0,class:`skeleton-wrap`},P={key:1},F={key:0,class:`empty-wrap`},I={class:`load-more-wrap`},L={class:`load-more-spinner`},R=w(t({__name:`Following`,setup(e){let t=g(),w=p([]),k=t.query.n||`粉丝详情`,R=t.query.s||``,z=p(t.query.t||`follows`);p(!1);let{loading:B,noMore:V,page:H,pageSize:U,totalPage:W,reset:G,nextPage:K}=A(20),{showWhisper:q,whisperReceiver:J,onSendWhisper:Y,whisperSuccess:X}=O.useWhisper();function Z(e){w.value=[],G(),z.value=e}let Q=u(()=>z.value==`follows`?`没有更多关注了`:`没有更多粉丝了`),te=()=>{K($)},ne=e=>{Z(e),$()},$=()=>{z.value===`follows`?ie(R):z.value===`followings`&&ae(R)},re=()=>{z.value===`follows`&&(Z(`follows`),$())},ie=(e,t=!1)=>{w.value.length===0&&(B.value=!0),C.v1.user.get.follows({username:e,page:H.value,page_size:U.value}).then(e=>{B.value=!1,e.list.length===0&&(V.value=!0),H.value>1?w.value=w.value.concat(e.list):(w.value=e.list,t&&setTimeout(()=>{window.scrollTo(0,99999)},50)),W.value=Math.ceil(e.pager.total_rows/U.value)}).catch(e=>{B.value=!1,H.value>1&&H.value--})},ae=(e,t=!1)=>{w.value.length===0&&(B.value=!0),C.v1.user.get.followings({username:e,page:H.value,page_size:U.value}).then(e=>{B.value=!1,e.list.length===0&&(V.value=!0),H.value>1?w.value=w.value.concat(e.list):(w.value=e.list,t&&setTimeout(()=>{window.scrollTo(0,99999)},50)),W.value=Math.ceil(e.pager.total_rows/U.value)}).catch(e=>{B.value=!1,H.value>1&&H.value--})};return i(()=>{$()}),(e,t)=>{let i=T,u=b,p=ee,g=E,C=x,O=v,A=D,R=S,H=y,G=_;return l(),m(d,null,[h(`div`,null,[o(i,{title:f(k),back:!0},null,8,[`title`]),o(R,{class:`main-content-wrap`,bordered:``},{default:s(()=>[o(p,{type:`line`,animated:``,"default-value":z.value,"onUpdate:value":ne},{default:s(()=>[o(u,{name:`follows`,tab:`正在关注`}),o(u,{name:`followings`,tab:`我的粉丝`})]),_:1},8,[`default-value`]),f(B)&&w.value.length===0?(l(),m(`div`,N,[o(g,{num:f(U)},null,8,[`num`])])):(l(),m(`div`,P,[w.value.length===0?(l(),m(`div`,F,[o(C,{size:`large`,description:`暂无数据`})])):r(``,!0),(l(!0),m(d,null,a(w.value,e=>(l(),c(O,{key:e.user_id},{default:s(()=>[o(j,{type:`follow`,contact:e,onSendWhisper:f(Y),onUnfollowSuccess:re},null,8,[`contact`,`onSendWhisper`])]),_:2},1024))),128))])),o(A,{show:f(q),user:f(J),onSuccess:f(X)},null,8,[`show`,`user`,`onSuccess`])]),_:1})]),f(W)>0?(l(),c(G,{key:0,justify:`center`},{default:s(()=>[o(f(M.default),{class:`load-more`,slots:{complete:Q.value,error:`加载出错`},onInfinite:te},{spinner:s(()=>[h(`div`,I,[f(V)?r(``,!0):(l(),c(H,{key:0,size:14})),h(`span`,L,n(f(V)?Q.value:`加载更多`),1)])]),_:1},8,[`slots`])]),_:1})):r(``,!0)],64)}}}),[[`__scopeId`,`data-v-ce50f282`]]);export{R as default};

@ -1 +0,0 @@
.compose-wrap{box-sizing:border-box;width:100%;padding:16px}.compose-wrap .compose-line{flex-direction:row;display:flex}.compose-wrap .compose-line .compose-user{align-items:center;width:42px;height:42px;display:flex}.compose-wrap .compose-line.compose-options{justify-content:space-between;margin-top:6px;padding-left:42px;display:flex}.compose-wrap .compose-line.compose-options .submit-wrap{align-items:center;display:flex}.compose-wrap .compose-line.compose-options .submit-wrap .text-statistic{width:20px;height:20px;margin-right:8px;transform:rotate(180deg)}.compose-wrap .link-wrap{margin-left:42px;margin-right:42px}.compose-wrap .eye-wrap{margin-left:64px}.compose-wrap .login-only-wrap{justify-content:center;width:100%;display:flex}.compose-wrap .login-only-wrap button{width:50%;margin:0 4px}.compose-wrap .login-wrap{justify-content:center;width:100%;display:flex}.compose-wrap .login-wrap .login-banner{opacity:.8;margin-bottom:12px}.compose-wrap .login-wrap button{margin:0 4px}.attachment-list-wrap{margin-top:12px;margin-left:42px}.attachment-list-wrap .n-upload-file-info__thumbnail{overflow:hidden}.dark .compose-wrap{background-color:#101014bf}.tiny-slide-bar .tiny-slide-bar__list>div.tiny-slide-bar__select .slide-bar-item .slide-bar-item-title[data-v-bb68ff14]{color:#18a058;opacity:.8}.tiny-slide-bar .tiny-slide-bar__list>div:hover .slide-bar-item[data-v-bb68ff14]{cursor:pointer}.tiny-slide-bar .tiny-slide-bar__list>div:hover .slide-bar-item .slide-bar-item-avatar[data-v-bb68ff14],.tiny-slide-bar .tiny-slide-bar__list>div:hover .slide-bar-item .slide-bar-item-title[data-v-bb68ff14]{color:#18a058;opacity:.8}.style-wrap[data-v-bb68ff14]{opacity:.8;margin-top:10px;margin-bottom:4px;margin-left:16px}.style-wrap .style-item.hover[data-v-bb68ff14]{cursor:pointer}.tiny-slide-bar[data-v-bb68ff14]{margin-top:-30px;margin-bottom:-30px}.tiny-slide-bar .slide-bar-item[data-v-bb68ff14]{flex-direction:column;justify-content:center;align-items:center;width:64px;min-height:170px;margin-top:8px;display:flex}.tiny-slide-bar .slide-bar-item .slide-bar-item-title[data-v-bb68ff14]{justify-content:center;height:40px;margin-top:4px;font-size:12px}.load-more[data-v-bb68ff14]{margin:20px}.load-more .load-more-wrap[data-v-bb68ff14]{flex-direction:row;justify-content:center;align-items:center;gap:14px;display:flex}.load-more .load-more-wrap .load-more-spinner[data-v-bb68ff14]{opacity:.65;font-size:14px}.dark .main-content-wrap[data-v-bb68ff14],.dark .pagination-wrap[data-v-bb68ff14],.dark .empty-wrap[data-v-bb68ff14],.dark .skeleton-wrap[data-v-bb68ff14]{background-color:#101014bf}.dark .tiny-slide-bar .tiny-slide-bar__list>div.tiny-slide-bar__select .slide-bar-item .slide-bar-item-title[data-v-bb68ff14],.dark .tiny-slide-bar .tiny-slide-bar__list>div:hover .slide-bar-item .slide-bar-item-title[data-v-bb68ff14]{color:#63e2b7;opacity:.8}.dark .tiny-slide-bar[data-v-bb68ff14]{--ti-slider-progress-box-arrow-hover-text-color:#f2f2f2;--ti-slider-progress-box-arrow-normal-text-color:gray}

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
var e=function(e){return e[e.TITLE=1]=`TITLE`,e[e.TEXT=2]=`TEXT`,e[e.IMAGEURL=3]=`IMAGEURL`,e[e.VIDEOURL=4]=`VIDEOURL`,e[e.AUDIOURL=5]=`AUDIOURL`,e[e.LINKURL=6]=`LINKURL`,e[e.ATTACHMENT=7]=`ATTACHMENT`,e[e.CHARGEATTACHMENT=8]=`CHARGEATTACHMENT`,e}({}),t=function(e){return e[e.PUBLIC=0]=`PUBLIC`,e[e.PRIVATE=1]=`PRIVATE`,e[e.FRIEND=2]=`FRIEND`,e[e.Following=3]=`Following`,e}({}),n=function(e){return e[e.NO=0]=`NO`,e[e.YES=1]=`YES`,e}({});export{t as n,n as r,e as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
import{A as e,Bt as t,C as n,D as r,H as i,J as a,K as o,O as s,Q as c,S as l,T as u,W as d,X as f,b as p,h as m,ht as h,lt as g,w as _}from"./@css-render-YsNuQTaE.js";import{r as v}from"./pinia-TbaIYBWH.js";import{$ as y,G as b,P as x,W as S,at as C,c as w,f as T,l as E,ot as D,s as O,tt as k,x as A}from"./naive-ui-Dv03sO69.js";import"./@juggle-BrKhuOcS.js";import{c as j,l as M,n as N,r as P}from"./sidebar-B2rODElh.js";import{a as F}from"./@vicons-CZeOmejd.js";import{E as I,S as L,d as R,j as z,v as B}from"./index-BzZcw6rK.js";import{t as V}from"./main-nav-BW6UKmWQ.js";var H={key:0,class:`tag-item`},U={key:0,class:`tag-quote`},W={key:1,class:`tag-quote tag-follow`},G={key:0,class:`options`},K=e({__name:`tag-item`,props:{tag:{},showAction:{type:Boolean},checkFollowing:{type:Boolean},checkPin:{type:Boolean}},setup(e){let o=g(!1),u=e,f=p(()=>u.tag.user?u.tag.user.avatar:N),m=p(()=>{let e=[];return u.tag.is_following===0?e.push({label:`关注`,key:`follow`}):(u.tag.is_pin===0?e.push({label:`钉住`,key:`pin`}):e.push({label:`取消钉住`,key:`unpin`}),u.tag.is_top===0?e.push({label:`置顶`,key:`stick`}):e.push({label:`取消置顶`,key:`unstick`}),e.push({label:`取消关注`,key:`unfollow`})),e}),v=e=>{switch(e){case`follow`:R({topic_id:u.tag.id}).then(e=>{u.tag.is_following=1,window.$message.success(`关注成功`)}).catch(e=>{console.log(e)});break;case`unfollow`:z({topic_id:u.tag.id}).then(e=>{u.tag.is_following=0,window.$message.success(`取消关注`)}).catch(e=>{console.log(e)});break;case`pin`:L({topic_id:u.tag.id}).then(e=>{u.tag.is_pin=1,window.$message.success(`钉住成功`)}).catch(e=>{console.log(e)});break;case`unpin`:L({topic_id:u.tag.id}).then(e=>{u.tag.is_pin=0,window.$message.success(`取消钉住`)}).catch(e=>{console.log(e)});break;case`stick`:I({topic_id:u.tag.id}).then(e=>{u.tag.is_top=e.top_status,window.$message.success(`置顶成功`)}).catch(e=>{console.log(e)});break;case`unstick`:I({topic_id:u.tag.id}).then(e=>{u.tag.is_top=e.top_status,window.$message.success(`取消置顶`)}).catch(e=>{console.log(e)});break;default:break}};return i(()=>{o.value=!1}),(i,o)=>{let u=a(`router-link`),p=k,g=C,x=b,w=y,T=S,E=O;return!e.checkFollowing&&!e.checkPin||e.checkFollowing&&e.tag.is_following===1||e.checkPin&&e.tag.is_following===1&&e.tag.is_pin===1?(d(),_(`div`,H,[s(E,null,{header:c(()=>[(d(),l(g,{type:`success`,size:`large`,round:``,key:e.tag.id},{avatar:c(()=>[s(p,{src:f.value},null,8,[`src`])]),default:c(()=>[s(u,{class:`hash-link`,to:{name:`home`,query:{q:e.tag.tag,t:`tag`}}},{default:c(()=>[r(` #`+t(e.tag.tag),1)]),_:1},8,[`to`]),e.showAction?n(``,!0):(d(),_(`span`,U,`(`+t(e.tag.quote_num)+`)`,1)),e.showAction?(d(),_(`span`,W,`(`+t(e.tag.quote_num)+`)`,1)):n(``,!0)]),_:1}))]),"header-extra":c(()=>[e.showAction?(d(),_(`div`,G,[s(T,{placement:`bottom-end`,trigger:`click`,size:`small`,options:m.value,onSelect:v},{default:c(()=>[s(w,{type:`success`,quaternary:``,circle:``,block:``},{icon:c(()=>[s(x,null,{default:c(()=>[s(h(F))]),_:1})]),_:1})]),_:1},8,[`options`])])):n(``,!0)]),_:1})])):n(``,!0)}}}),q={key:0,class:`empty-wrap`},J=P(e({__name:`Topic`,setup(e){let a=M(),{userLogined:y}=v(j()),b=g([]),S=g(`hot`),O=g(!1),k=g(!1),N=g(!1),P=g(!1);f(k,()=>{k.value||(window.$message.success(`保存成功`),a.doRefreshTopicFollow())});let F=p({get:()=>{let e=`编辑`;return k.value&&(e=`保存`),e},set:e=>{}}),I=()=>{O.value=!0,B({type:S.value,num:50}).then(e=>{b.value=e.topics,O.value=!1}).catch(e=>{b.value=[],console.log(e),O.value=!1})},L=e=>{S.value=e,N.value=e===`follow`,P.value=e===`pin`,I()};return i(()=>{I()}),(e,i)=>{let a=V,f=E,p=C,g=w,v=K,S=x,j=D,M=T,I=A;return d(),_(`div`,null,[s(a,{title:`话题`}),s(I,{class:`main-content-wrap tags-wrap`,bordered:``},{default:c(()=>[s(g,{type:`line`,animated:``,"onUpdate:value":L},u({default:c(()=>[s(f,{name:`hot`,tab:`热门`}),s(f,{name:`new`,tab:`最新`}),h(y)?(d(),l(f,{key:0,name:`follow`,tab:`关注`})):n(``,!0),h(y)?(d(),l(f,{key:1,name:`pin`,tab:`钉住`})):n(``,!0)]),_:2},[h(y)?{name:`suffix`,fn:c(()=>[s(p,{checked:k.value,"onUpdate:checked":i[0]||=e=>k.value=e,checkable:``},{default:c(()=>[r(t(F.value),1)]),_:1},8,[`checked`])]),key:`0`}:void 0]),1024),s(M,{show:O.value},{default:c(()=>[s(S,null,{default:c(()=>[(d(!0),_(m,null,o(b.value,e=>(d(),l(v,{tag:e,showAction:h(y)&&k.value,checkFollowing:N.value,checkPin:P.value},null,8,[`tag`,`showAction`,`checkFollowing`,`checkPin`]))),256))]),_:1}),b.value.length===0?(d(),_(`div`,q,[s(j,{size:`large`,description:`暂无数据`})])):n(``,!0)]),_:1},8,[`show`])]),_:1})])}}}),[[`__scopeId`,`data-v-5410fcc5`]]);export{J as default};

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
import{o as e}from"./rolldown-runtime-Bhmf7a9N.js";import{$ as t,A as n,Bt as r,C as i,D as a,H as o,It as ee,K as s,O as c,Q as l,S as u,W as d,d as f,h as p,ht as m,lt as h,p as te,w as g,x as _}from"./@css-render-YsNuQTaE.js";import{r as v}from"./pinia-TbaIYBWH.js";import{r as y}from"./vue-router-Dw9lfhBK.js";import{$ as ne,G as re,H as ie,P as ae,Q as oe,X as se,_ as b,b as x,d as ce,et as le,ot as ue,x as de}from"./naive-ui-Dv03sO69.js";import"./@juggle-BrKhuOcS.js";import{a as S,c as C,l as w,r as T}from"./sidebar-B2rODElh.js";import{M as fe}from"./@vicons-CZeOmejd.js";import"./moment-D_9q9veS.js";import{N as E,i as pe}from"./index-BzZcw6rK.js";import{t as D}from"./main-nav-BW6UKmWQ.js";import{t as O}from"./post-skeleton-CJ8iRGBT.js";import{t as k}from"./qrcode-DSuf5wHF.js";import"./dijkstrajs-B3GuVsqA.js";var A=e(k()),j={class:`balance-wrap`},M={class:`balance-line`},N={class:`balance-opts`},P={key:0,class:`pagination-wrap`},F={key:0,class:`skeleton-wrap`},I={key:1},L={key:0,class:`empty-wrap`},R={class:`bill-line`},z={key:0,class:`amount-options`},B={key:1,style:{"margin-top":`10px`}},me={class:`qrcode-wrap`},he={class:`pay-tips`},ge={class:`pay-sub-tips`},V=T(n({__name:`Wallet`,setup(e){let n=w(),T=C(),{collapsedRight:k}=v(n),{userInfo:V}=v(T),H=y(),U=h(!1),W=h(100),G=h(!1),K=h(``),q=h(!1),J=h([]),Y=h(+H.query.p||1),X=h(20),Z=h(0),_e=h([100,200,300,500,1e3,3e3,5e3,1e4,5e4]),Q=()=>{q.value=!0,S.v1.user.get.wallet.bills({page:Y.value,page_size:X.value}).then(e=>{q.value=!1,J.value=e.list,Z.value=Math.ceil(e.pager.total_rows/X.value),window.scrollTo(0,0)}).catch(e=>{q.value=!1})},ve=e=>{Y.value=e,Q()},$=()=>{let e=localStorage.getItem(`PAOPAO_TOKEN`)||``;e?E(e).then(e=>{T.updateUserinfo(e),n.triggerAuth(!1),Q()}).catch(e=>{n.triggerAuth(!0),T.userLogout()}):(n.triggerAuth(!0),T.userLogout())},ye=()=>{U.value=!0},be=e=>{G.value=!0,S.v1.user.post.recharge({amount:W.value}).then(e=>{G.value=!1,K.value=e.pay,A.toCanvas(document.querySelector(`#qrcode-container`),e.pay,{width:150,margin:2});let t=setInterval(()=>{S.v1.user.get.recharge({id:e.id}).then(e=>{e.status===`TRADE_SUCCESS`&&(clearInterval(t),window.$message.success(`充值成功`),U.value=!1,K.value=``,$())}).catch(e=>{console.log(e)})},2e3)}).catch(e=>{G.value=!1})},xe=()=>{V.value.balance==0?window.$message.warning(`您暂无可提现资金`):window.$message.warning(`该功能即将开放`)};return o(()=>{$()}),(e,n)=>{let o=D,h=b,v=ce,y=ne,S=ae,C=se,w=O,T=ue,E=x,A=de,H=re,Q=le,$=oe,Se=ie;return d(),g(`div`,null,[c(o,{title:`钱包`}),c(A,{class:`main-content-wrap`,bordered:``},{footer:l(()=>[Z.value>1?(d(),g(`div`,P,[c(C,{page:Y.value,"onUpdate:page":ve,"page-slot":m(k)?5:8,"page-count":Z.value},null,8,[`page`,`page-slot`,`page-count`])])):i(``,!0)]),default:l(()=>[_(`div`,j,[_(`div`,M,[c(v,{label:`账户余额 (元)`},{default:l(()=>[c(h,{from:0,to:(m(V).balance||0)/100,duration:500,precision:2},null,8,[`to`])]),_:1}),_(`div`,N,[c(S,{vertical:``},{default:l(()=>[c(y,{size:`small`,secondary:``,type:`primary`,onClick:ye},{default:l(()=>[...n[1]||=[a(` 充值 `,-1)]]),_:1}),c(y,{size:`small`,secondary:``,type:`tertiary`,onClick:xe},{default:l(()=>[...n[2]||=[a(` 提现 `,-1)]]),_:1})]),_:1})])])]),q.value?(d(),g(`div`,F,[c(w,{num:X.value},null,8,[`num`])])):(d(),g(`div`,I,[J.value.length===0?(d(),g(`div`,L,[c(T,{size:`large`,description:`暂无数据`})])):i(``,!0),(d(!0),g(p,null,s(J.value,e=>(d(),u(E,{key:e.id},{default:l(()=>[_(`div`,R,[_(`div`,null,`NO.`+r(e.id),1),_(`div`,null,r(e.reason),1),_(`div`,{class:ee({income:e.change_amount>=0,out:e.change_amount<0})},r((e.change_amount>0?`+`:``)+(e.change_amount/100).toFixed(2)),3),_(`div`,null,r(m(pe)(e.created_on)),1)])]),_:2},1024))),128))]))]),_:1}),c(Se,{show:U.value,"onUpdate:show":n[0]||=e=>U.value=e},{default:l(()=>[c($,{bordered:!1,title:`请选择充值金额`,role:`dialog`,"aria-modal":`true`,style:{width:`100%`,"max-width":`330px`}},{default:l(()=>[K.value.length===0?(d(),g(`div`,z,[c(S,{align:`baseline`},{default:l(()=>[(d(!0),g(p,null,s(_e.value,e=>(d(),u(y,{key:e,size:`small`,secondary:``,type:W.value===e?`info`:`default`,onClick:te(t=>W.value=e,[`stop`])},{default:l(()=>[a(r(e/100)+``,1)]),_:2},1032,[`type`,`onClick`]))),128))]),_:1})])):i(``,!0),W.value>0&&K.value.length===0?(d(),g(`div`,B,[c(y,{loading:G.value,strong:``,secondary:``,type:`info`,style:{width:`100%`},onClick:be},{icon:l(()=>[c(H,null,{default:l(()=>[c(m(fe))]),_:1})]),default:l(()=>[n[3]||=a(` 前往支付 `,-1)]),_:1},8,[`loading`])])):i(``,!0),t(_(`div`,me,[n[5]||=_(`canvas`,{id:`qrcode-container`},null,-1),_(`div`,he,` 请使用支付宝扫码支付`+r((W.value/100).toFixed(2))+``,1),_(`div`,ge,[c(Q,{value:100,type:`info`,dot:``,processing:``}),n[4]||=_(`span`,{style:{"margin-left":`6px`}},` 支付结果实时同步中... `,-1)])],512),[[f,K.value.length>0]])]),_:1})]),_:1},8,[`show`])])}}}),[[`__scopeId`,`data-v-f2cb897a`]]);export{V as default};

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
import{t as e}from"./rolldown-runtime-Bhmf7a9N.js";var t=e(((e,t)=>{t.exports=function(){var e=document.getSelection();if(!e.rangeCount)return function(){};for(var t=document.activeElement,n=[],r=0;r<e.rangeCount;r++)n.push(e.getRangeAt(r));switch(t.tagName.toUpperCase()){case`INPUT`:case`TEXTAREA`:t.blur();break;default:t=null;break}return e.removeAllRanges(),function(){e.type===`Caret`&&e.removeAllRanges(),e.rangeCount||n.forEach(function(t){e.addRange(t)}),t&&t.focus()}}})),n=e(((e,n)=>{var r=t(),i={"text/plain":`Text`,"text/html":`Url`,default:`Text`},a=`Copy to clipboard: #{key}, Enter`;function o(e){var t=(/mac os x/i.test(navigator.userAgent)?``:`Ctrl`)+`+C`;return e.replace(/#{\s*key\s*}/g,t)}function s(e,t){var n,s,c,l,u,d,f=!1;t||={},n=t.debug||!1;try{if(c=r(),l=document.createRange(),u=document.getSelection(),d=document.createElement(`span`),d.textContent=e,d.ariaHidden=`true`,d.style.all=`unset`,d.style.position=`fixed`,d.style.top=0,d.style.clip=`rect(0, 0, 0, 0)`,d.style.whiteSpace=`pre`,d.style.webkitUserSelect=`text`,d.style.MozUserSelect=`text`,d.style.msUserSelect=`text`,d.style.userSelect=`text`,d.addEventListener(`copy`,function(r){if(r.stopPropagation(),t.format)if(r.preventDefault(),r.clipboardData===void 0){n&&console.warn(`unable to use e.clipboardData`),n&&console.warn(`trying IE specific stuff`),window.clipboardData.clearData();var a=i[t.format]||i.default;window.clipboardData.setData(a,e)}else r.clipboardData.clearData(),r.clipboardData.setData(t.format,e);t.onCopy&&(r.preventDefault(),t.onCopy(r.clipboardData))}),document.body.appendChild(d),l.selectNodeContents(d),u.addRange(l),!document.execCommand(`copy`))throw Error(`copy command was unsuccessful`);f=!0}catch(r){n&&console.error(`unable to copy using execCommand: `,r),n&&console.warn(`trying IE specific stuff`);try{window.clipboardData.setData(t.format||`text`,e),t.onCopy&&t.onCopy(window.clipboardData),f=!0}catch(r){n&&console.error(`unable to copy using clipboardData: `,r),n&&console.error(`falling back to prompt`),s=o(`message`in t?t.message:a),window.prompt(s,e)}}finally{u&&(typeof u.removeRange==`function`?u.removeRange(l):u.removeAllRanges()),d&&document.body.removeChild(d),c()}return f}n.exports=s}));export{n as t};

@ -1 +0,0 @@
var e=e=>e>=1e3?(e/1e3).toFixed(1)+``:e>=1e4?(e/1e4).toFixed(1)+``:e;export{e as t};

@ -1 +0,0 @@
import{t as e}from"./rolldown-runtime-Bhmf7a9N.js";var t=e(((e,t)=>{var n={single_source_shortest_paths:function(e,t,r){var i={},a={};a[t]=0;var o=n.PriorityQueue.make();o.push(t,0);for(var s,c,l,u,d,f,p,m,h;!o.empty();)for(l in s=o.pop(),c=s.value,u=s.cost,d=e[c]||{},d)d.hasOwnProperty(l)&&(f=d[l],p=u+f,m=a[l],h=a[l]===void 0,(h||m>p)&&(a[l]=p,o.push(l,p),i[l]=c));if(r!==void 0&&a[r]===void 0){var g=[`Could not find a path from `,t,` to `,r,`.`].join(``);throw Error(g)}return i},extract_shortest_path_from_predecessor_list:function(e,t){for(var n=[],r=t;r;)n.push(r),e[r],r=e[r];return n.reverse(),n},find_path:function(e,t,r){var i=n.single_source_shortest_paths(e,t,r);return n.extract_shortest_path_from_predecessor_list(i,r)},PriorityQueue:{make:function(e){var t=n.PriorityQueue,r={},i;for(i in e||={},t)t.hasOwnProperty(i)&&(r[i]=t[i]);return r.queue=[],r.sorter=e.sorter||t.default_sorter,r},default_sorter:function(e,t){return e.cost-t.cost},push:function(e,t){var n={value:e,cost:t};this.queue.push(n),this.queue.sort(this.sorter)},pop:function(){return this.queue.shift()},empty:function(){return this.queue.length===0}}};t!==void 0&&(t.exports=n)}));export{t};

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
import{o as e}from"./rolldown-runtime-Bhmf7a9N.js";import{A as t,Bt as n,C as r,O as i,Q as a,S as o,W as s,ht as c,x as l}from"./@css-render-YsNuQTaE.js";import{P as u,f as d}from"./naive-ui-Dv03sO69.js";import{r as f}from"./sidebar-B2rODElh.js";import{t as p}from"./v3-infinite-loading-Dj1QGmV8.js";var m=e(p()),h={class:`load-more-wrap`},g={class:`load-more-spinner`},_=f(t({__name:`infinite-load-more`,props:{totalPage:{},noMore:{type:Boolean},completeText:{default:`没有更多了`}},emits:[`load-more`],setup(e,{emit:t}){let f=t,p=()=>{f(`load-more`)};return(t,f)=>{let _=d,v=u;return e.totalPage>0?(s(),o(v,{key:0,justify:`center`},{default:a(()=>[i(c(m.default),{class:`load-more`,slots:{complete:e.completeText,error:`加载出错`},onInfinite:p},{spinner:a(()=>[l(`div`,h,[e.noMore?r(``,!0):(s(),o(_,{key:0,size:14})),l(`span`,g,n(e.noMore?e.completeText:`加载更多`),1)])]),_:1},8,[`slots`])]),_:1})):r(``,!0)}}}),[[`__scopeId`,`data-v-fadcc2b3`]]);export{_ as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
import{A as e,Bt as t,C as n,D as r,H as i,O as a,Q as o,S as s,W as c,h as l,ht as u,lt as d,w as f,x as p}from"./@css-render-YsNuQTaE.js";import{r as m}from"./pinia-TbaIYBWH.js";import{i as h}from"./vue-router-Dw9lfhBK.js";import{$ as g,G as _,I as v,L as y,Q as b,u as x,z as S}from"./naive-ui-Dv03sO69.js";import{t as C}from"./vooks-C54Sdpwj.js";import{l as w,t as T}from"./sidebar-B2rODElh.js";import{c as E,l as D,s as O,u as k}from"./@vicons-CZeOmejd.js";var A={key:0},j={class:`navbar`},M=e({__name:`main-nav`,props:{title:{default:``},back:{type:Boolean,default:!1},theme:{type:Boolean,default:!0}},setup(e){let M=w(),{desktopModelShow:N,drawerModelShow:P,theme:F}=m(M),I=h(),L=d(!1),R=d(`left`),z=e,B=e=>{e?(localStorage.setItem(`PAOPAO_THEME`,`dark`),M.triggerTheme(`dark`)):(localStorage.setItem(`PAOPAO_THEME`,`light`),M.triggerTheme(`light`))},V=()=>{window.history.length<=1?I.push({path:`/`}):I.go(-1)},H=()=>{L.value=!0};return i(()=>{localStorage.getItem(`PAOPAO_THEME`)||B(C()===`dark`),N.value||(window.$message=S())}),(i,d)=>{let m=T,h=v,S=y,C=_,w=g,M=x,N=b;return c(),f(l,null,[u(P)?(c(),f(`div`,A,[a(S,{show:L.value,"onUpdate:show":d[0]||=e=>L.value=e,width:212,placement:R.value,resizable:``},{default:o(()=>[a(h,null,{default:o(()=>[a(m)]),_:1})]),_:1},8,[`show`,`placement`])])):n(``,!0),a(N,{size:`small`,bordered:!0,class:`nav-title-card`},{header:o(()=>[p(`div`,j,[u(P)&&!e.back?(c(),s(w,{key:0,class:`drawer-btn`,onClick:H,quaternary:``,circle:``,size:`medium`},{icon:o(()=>[a(C,null,{default:o(()=>[a(u(E))]),_:1})]),_:1})):n(``,!0),e.back?(c(),s(w,{key:1,class:`back-btn`,onClick:V,quaternary:``,circle:``,size:`small`},{icon:o(()=>[a(C,null,{default:o(()=>[a(u(k))]),_:1})]),_:1})):n(``,!0),r(` `+t(z.title)+` `,1),z.theme?(c(),s(M,{key:2,value:u(F)===`dark`,"onUpdate:value":B,size:`small`,class:`theme-switch-wrap`},{"checked-icon":o(()=>[a(C,{component:u(D)},null,8,[`component`])]),"unchecked-icon":o(()=>[a(C,{component:u(O)},null,8,[`component`])]),_:1},8,[`value`])):n(``,!0)])]),_:1})],64)}}});export{M as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
import{A as e,K as t,O as n,W as r,h as i,w as a,x as o}from"./@css-render-YsNuQTaE.js";import{p as s}from"./naive-ui-Dv03sO69.js";import{r as c}from"./sidebar-B2rODElh.js";var l={class:`user`},u={class:`content`},d=c(e({__name:`post-skeleton`,props:{num:{default:1}},setup(e){return(c,d)=>{let f=s;return r(!0),a(i,null,t(Array(e.num),e=>(r(),a(`div`,{class:`skeleton-item`,key:e},[o(`div`,l,[n(f,{circle:``,size:`small`})]),o(`div`,u,[n(f,{text:``,repeat:3}),n(f,{text:``,style:{width:`60%`}})])]))),128)}}}),[[`__scopeId`,`data-v-c4e511bd`]]);export{d as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
import{lt as e}from"./@css-render-YsNuQTaE.js";function t(t=20){let n=e(!1),r=e(!1),i=e(1),a=e(t),o=e(0);return{loading:n,noMore:r,page:i,pageSize:a,totalPage:o,reset:()=>{n.value=!1,r.value=!1,i.value=1,o.value=0},nextPage:e=>{i.value<o.value||o.value==0?(r.value=!1,i.value++,e()):r.value=!0}}}export{t};

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
import{A as e,Bt as t,D as n,O as r,Q as i,S as a,W as o,lt as s,x as c}from"./@css-render-YsNuQTaE.js";import{$ as l,E as u,H as d,K as f,it as p,rt as m}from"./naive-ui-Dv03sO69.js";import{a as h,r as g}from"./sidebar-B2rODElh.js";var _={class:`whisper-wrap`},v={class:`whisper-line`},y={class:`whisper-line send-wrap`},b=g(e({__name:`whisper`,props:{show:{type:Boolean,default:!1},user:{}},emits:[`success`],setup(e,{emit:g}){let b=e,x=s(``),S=s(!1),C=g,w=()=>{C(`success`)},T=()=>{S.value=!0,h.v1.user.post.whisper({user_id:b.user.id,content:x.value}).then(e=>{window.$message.success(`发送成功`),S.value=!1,x.value=``,w()}).catch(e=>{S.value=!1})};return(s,h)=>{let g=u,b=f,C=p,E=m,D=l,O=d;return o(),a(O,{show:e.show,"onUpdate:show":w,class:`whisper-card`,preset:`card`,size:`small`,title:`私信`,"mask-closable":!1,bordered:!1,style:{width:`360px`}},{default:i(()=>[c(`div`,_,[r(C,{"show-icon":!1},{default:i(()=>[h[1]||=n(` 即将发送私信给: `,-1),r(b,{style:{"max-width":`100%`}},{default:i(()=>[r(g,{type:`success`},{default:i(()=>[n(t(e.user.nickname)+`@`+t(e.user.username),1)]),_:1})]),_:1})]),_:1}),c(`div`,v,[r(E,{type:`textarea`,placeholder:`请输入私信内容(请勿发送不和谐内容,否则将会被封号)`,autosize:{minRows:5,maxRows:10},value:x.value,"onUpdate:value":h[0]||=e=>x.value=e,maxlength:`200`,"show-count":``},null,8,[`value`])]),c(`div`,y,[r(D,{strong:``,secondary:``,type:`primary`,loading:S.value,onClick:T},{default:i(()=>[...h[2]||=[n(` 发送 `,-1)]]),_:1},8,[`loading`])])])]),_:1},8,[`show`])}}}),[[`__scopeId`,`data-v-2a2273dc`]]),x=class{static followAction(e,t,n,r){return new Promise((i,a)=>{e.success({title:`提示`,content:`确定`+(r?`取消关注 @`:`关注 @`)+n+` 吗?`,positiveText:`确定`,negativeText:`取消`,onPositiveClick:()=>{r?h.v1.user.post.unfollow({user_id:t}).then(e=>{window.$message.success(`操作成功`),i(!1)}).catch(e=>{a(e)}):h.v1.user.post.follow({user_id:t}).then(e=>{window.$message.success(`关注成功`),i(!0)}).catch(e=>{a(e)})}})})}static useWhisper(){let e=s(!1),t=s({id:0,avatar:``,username:``,nickname:``,is_admin:!1,is_friend:!0,is_following:!1,created_on:0,follows:0,followings:0,status:1});return{showWhisper:e,whisperReceiver:t,onSendWhisper:n=>{t.value=n,e.value=!0},whisperSuccess:()=>{e.value=!1}}}};export{b as n,x as t};

@ -1 +0,0 @@
import{A as e,Bt as t,C as n,D as r,J as i,M as a,O as o,Q as s,S as c,W as l,b as u,ht as d,p as f,w as p,x as m}from"./@css-render-YsNuQTaE.js";import{$ as h,G as g,U as _,W as v,at as y,s as b,tt as x}from"./naive-ui-Dv03sO69.js";import{r as S}from"./sidebar-B2rODElh.js";import{_ as C,at as w,k as T,o as E}from"./@vicons-CZeOmejd.js";import{t as D}from"./index-BzZcw6rK.js";import{t as O}from"./useUserAction-DVqObL5J.js";var k={class:`user-card`},A={class:`nickname-wrap`},j={class:`username-wrap`},M={class:`user-info`},N={class:`info-item`},P={class:`info-item`},F={class:`item-header-extra`},I=S(e({__name:`user-card`,props:{contact:{},type:{default:`contact`}},emits:[`send-whisper`,`unfollow-success`],setup(e,{emit:S}){let I=_(),L=e,R=u(()=>L.type===`follow`),z=u(()=>L.type===`follow`),B=S,V=e=>()=>a(g,null,{default:()=>a(e)}),H=()=>{let e=L.contact.is_following;O.followAction(I,L.contact.user_id,L.contact.username,L.contact.is_following).then(t=>{L.contact.is_following=t,e&&!t&&B(`unfollow-success`)}).catch(e=>{console.log(e)})},U=u(()=>{let e=[{label:`私信 @`+L.contact.username,key:`whisper`,icon:V(T)}];return z.value&&(L.contact.is_following?e.push({label:`取消关注 @`+L.contact.username,key:`unfollow`,icon:V(C)}):e.push({label:`关注 @`+L.contact.username,key:`follow`,icon:V(w)})),e}),W=e=>{switch(e){case`follow`:case`unfollow`:H();break;case`whisper`:B(`send-whisper`,{id:L.contact.user_id,avatar:L.contact.avatar,username:L.contact.username,nickname:L.contact.nickname,is_admin:!1,is_friend:!0,is_following:!1,created_on:0,follows:0,followings:0,status:1});break;default:break}};return(a,u)=>{let _=x,S=i(`router-link`),C=y,w=h,T=v,O=b;return l(),p(`div`,k,[o(O,{"content-indented":``},{avatar:s(()=>[o(_,{size:54,src:e.contact.avatar},null,8,[`src`])]),header:s(()=>[m(`span`,A,[o(S,{onClick:u[0]||=f(()=>{},[`stop`]),class:`username-link`,to:{name:`user`,query:{s:e.contact.username}}},{default:s(()=>[r(t(e.contact.nickname),1)]),_:1},8,[`to`])]),m(`span`,j,` @`+t(e.contact.username),1),R.value&&e.contact.is_following?(l(),c(C,{key:0,class:`top-tag`,type:`success`,size:`small`,round:``},{default:s(()=>[...u[1]||=[r(` 已关注 `,-1)]]),_:1})):n(``,!0),m(`div`,M,[m(`span`,N,` UID. `+t(e.contact.user_id),1),m(`span`,P,t(d(D)(e.contact.created_on))+`\xA0加入 `,1)])]),"header-extra":s(()=>[m(`div`,F,[o(T,{placement:`bottom-end`,trigger:`click`,size:`small`,options:U.value,onSelect:W},{default:s(()=>[o(w,{quaternary:``,circle:``},{icon:s(()=>[o(d(g),null,{default:s(()=>[o(d(E))]),_:1})]),_:1})]),_:1},8,[`options`])])]),_:1})])}}}),[[`__scopeId`,`data-v-f1712b5f`]]);export{I as t};

@ -1 +0,0 @@
import{a as e,t}from"./rolldown-runtime-Bhmf7a9N.js";import{Ct as n,l as r,s as i,zt as a}from"./@css-render-YsNuQTaE.js";import{n as o,t as s}from"./@vue-cLJXtEPP.js";var c=t((t=>{Object.defineProperty(t,`__esModule`,{value:!0});var c=(o(),e(s)),l=(i(),e(r)),u=(n(),e(a));function d(e){var t=Object.create(null);if(e)for(var n in e)t[n]=e[n];return t.default=e,Object.freeze(t)}var f=d(l),p=Object.create(null);function m(e,t){if(!u.isString(e))if(e.nodeType)e=e.innerHTML;else return u.NOOP;let n=u.genCacheKey(e,t),r=p[n];if(r)return r;if(e[0]===`#`){let t=document.querySelector(e);e=t?t.innerHTML:``}let i=u.extend({hoistStatic:!0,onError:void 0,onWarn:u.NOOP},t);!i.isCustomElement&&typeof customElements<`u`&&(i.isCustomElement=e=>!!customElements.get(e));let{code:a}=c.compile(e,i),o=Function(`Vue`,a)(f);return o._rc=!0,p[n]=o}l.registerRuntimeCompiler(m),t.compile=m,Object.keys(l).forEach(function(e){e!==`default`&&!Object.prototype.hasOwnProperty.call(t,e)&&(t[e]=l[e])})})),l=t(((e,t)=>{t.exports=c()})),u=t(((e,t)=>{(function(n,r){typeof e==`object`&&typeof t<`u`?r(e,l()):typeof define==`function`&&define.amd?define([`exports`,`vue`],r):(n=typeof globalThis<`u`?globalThis:n||self,r(n.V3InfiniteLoading={},n.Vue))})(e,function(e,t){function n(e,t=null){if(!e)return!1;let n=e.getBoundingClientRect(),r=t?t.getBoundingClientRect():{top:0,left:0,bottom:window.innerHeight,right:window.innerWidth};return n.bottom>=r.top&&n.top<=r.bottom&&n.right>=r.left&&n.left<=r.right}async function r(e){return e?(await t.nextTick(),e.value instanceof HTMLElement?e.value:e.value?document.querySelector(e.value):null):null}function i(e){let t=`0px 0px ${e.distance}px 0px`;e.top&&(t=`${e.distance}px 0px 0px 0px`);let n=new IntersectionObserver(t=>{t[0].isIntersecting&&(e.firstload&&e.emit(),e.firstload=!0)},{root:e.parentEl,rootMargin:t});return e.infiniteLoading.value&&n.observe(e.infiniteLoading.value),n}async function a(e,n){if(await t.nextTick(),!e.top)return;let r=e.parentEl||document.documentElement;r.scrollTop=r.scrollHeight-n}let o=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n},s={},c=e=>(t.pushScopeId(`data-v-d3e37633`),e=e(),t.popScopeId(),e),l={class:`container`},u=[c(()=>t.createElementVNode(`div`,{class:`spinner`},null,-1))];function d(e,n){return t.openBlock(),t.createElementBlock(`div`,l,u)}let f=o(s,[[`render`,d],[`__scopeId`,`data-v-d3e37633`]]),p={class:`state-error`};e.default=o(t.defineComponent({__name:`InfiniteLoading`,props:{top:{type:Boolean,default:!1},target:{},distance:{default:0},identifier:{},firstload:{type:Boolean,default:!0},slots:{}},emits:[`infinite`],setup(e,{emit:o}){let s=e,c=null,l=0,u=t.ref(null),d=t.ref(``),{top:m,firstload:h,distance:g}=s,{identifier:_,target:v}=t.toRefs(s),y={infiniteLoading:u,top:m,firstload:h,distance:g,parentEl:null,emit(){l=(y.parentEl||document.documentElement).scrollHeight,b.loading(),o(`infinite`,b)}},b={loading(){d.value=`loading`},async loaded(){d.value=`loaded`,await a(y,l),n(u.value,y.parentEl)&&y.emit()},async complete(){d.value=`complete`,await a(y,l),c?.disconnect()},error(){d.value=`error`}};function x(){c?.disconnect(),c=i(y)}return t.watch(_,x),t.onMounted(async()=>{y.parentEl=await r(v),x()}),t.onUnmounted(()=>c?.disconnect()),(e,n)=>(t.openBlock(),t.createElementBlock(`div`,{ref_key:`infiniteLoading`,ref:u,class:`v3-infinite-loading`},[t.withDirectives(t.createElementVNode(`div`,null,[t.renderSlot(e.$slots,`spinner`,{},()=>[t.createVNode(f)],!0)],512),[[t.vShow,d.value==`loading`]]),d.value==`complete`?t.renderSlot(e.$slots,`complete`,{key:0},()=>[t.createElementVNode(`span`,null,t.toDisplayString(e.slots?.complete||`No more results!`),1)],!0):t.createCommentVNode(``,!0),d.value==`error`?t.renderSlot(e.$slots,`error`,{key:1,retry:y.emit},()=>[t.createElementVNode(`span`,p,[t.createElementVNode(`span`,null,t.toDisplayString(e.slots?.error||`Oops something went wrong!`),1),t.createElementVNode(`button`,{class:`retry`,onClick:n[0]||=(...e)=>y.emit&&y.emit(...e)},`retry`)])],!0):t.createCommentVNode(``,!0)],512))}}),[[`__scopeId`,`data-v-4bdee133`]]),Object.defineProperties(e,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}})})}));export{u as t};

@ -1 +0,0 @@
import{B as e,ct as t,lt as n,z as r}from"./@css-render-YsNuQTaE.js";import{st as i}from"./naive-ui-Dv03sO69.js";var a=0,o=typeof window<`u`&&window.matchMedia!==void 0,s=n(null),c,l;function u(e){e.matches&&(s.value=`dark`)}function d(e){e.matches&&(s.value=`light`)}function f(){c=window.matchMedia(`(prefers-color-scheme: dark)`),l=window.matchMedia(`(prefers-color-scheme: light)`),c.matches?s.value=`dark`:l.matches?s.value=`light`:s.value=null,c.addEventListener?(c.addEventListener(`change`,u),l.addEventListener(`change`,d)):c.addListener&&(c.addListener(u),l.addListener(d))}function p(){`removeEventListener`in c?(c.removeEventListener(`change`,u),l.removeEventListener(`change`,d)):`removeListener`in c&&(c.removeListener(u),l.removeListener(d)),c=void 0,l=void 0}var m=!0;function h(){return o?(a===0&&f(),(m&&=i())&&(r(()=>{a+=1}),e(()=>{--a,a===0&&p()})),t(s)):t(s)}export{h as t};

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
import{A as e,Bt as t,D as n,O as r,Q as i,S as a,W as o,lt as s,x as c}from"./@css-render-YsNuQTaE.js";import{$ as l,E as u,H as d,K as f,it as p,rt as m}from"./naive-ui-Dv03sO69.js";import{a as h,r as g}from"./sidebar-B2rODElh.js";var _={class:`whisper-wrap`},v={class:`whisper-line`},y={class:`whisper-line send-wrap`},b=g(e({__name:`whisper-add-friend`,props:{show:{type:Boolean,default:!1},user:{}},emits:[`success`],setup(e,{emit:g}){let b=e,x=s(``),S=s(!1),C=g,w=()=>{C(`success`)},T=()=>{S.value=!0,h.v1.friend.post.requesting({user_id:b.user.id,greetings:x.value}).then(e=>{window.$message.success(`发送成功`),S.value=!1,x.value=``,w()}).catch(e=>{S.value=!1})};return(s,h)=>{let g=u,b=f,C=p,E=m,D=l,O=d;return o(),a(O,{show:e.show,"onUpdate:show":w,class:`whisper-card`,preset:`card`,size:`small`,title:`申请添加朋友`,"mask-closable":!1,bordered:!1,style:{width:`360px`}},{default:i(()=>[c(`div`,_,[r(C,{"show-icon":!1},{default:i(()=>[h[1]||=n(` 发送添加朋友申请给: `,-1),r(b,{style:{"max-width":`100%`}},{default:i(()=>[r(g,{type:`success`},{default:i(()=>[n(t(e.user.nickname)+`@`+t(e.user.username),1)]),_:1})]),_:1})]),_:1}),c(`div`,v,[r(E,{type:`textarea`,placeholder:`请输入真挚的问候语`,autosize:{minRows:5,maxRows:10},value:x.value,"onUpdate:value":h[0]||=e=>x.value=e,maxlength:`120`,"show-count":``},null,8,[`value`])]),c(`div`,y,[r(D,{strong:``,secondary:``,type:`primary`,loading:S.value,onClick:T},{default:i(()=>[...h[2]||=[n(` 发送 `,-1)]]),_:1},8,[`loading`])])])]),_:1},8,[`show`])}}}),[[`__scopeId`,`data-v-9dc7c1f1`]]);export{b as t};

@ -8,23 +8,27 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0" />
<link rel="manifest" href="/manifest.json" />
<title></title>
<script type="module" crossorigin src="/assets/index-BzZcw6rK.js"></script>
<script type="module" crossorigin src="/assets/index-MT_Q5xjN.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-Bhmf7a9N.js">
<link rel="modulepreload" crossorigin href="/assets/@css-render-YsNuQTaE.js">
<link rel="modulepreload" crossorigin href="/assets/@vicons-CZeOmejd.js">
<link rel="modulepreload" crossorigin href="/assets/axios-DKxZWLrQ.js">
<link rel="modulepreload" crossorigin href="/assets/@css-render-wY4Tj5P_.js">
<link rel="modulepreload" crossorigin href="/assets/@vicons-BO69EUcN.js">
<link rel="modulepreload" crossorigin href="/assets/axios-DfcA82ON.js">
<link rel="modulepreload" crossorigin href="/assets/@emotion-CU1dD7u8.js">
<link rel="modulepreload" crossorigin href="/assets/@juggle-BrKhuOcS.js">
<link rel="modulepreload" crossorigin href="/assets/async-validator-CMpIue6_.js">
<link rel="modulepreload" crossorigin href="/assets/css-render-CfBkj-3z.js">
<link rel="modulepreload" crossorigin href="/assets/date-fns-HSvC9ggH.js">
<link rel="modulepreload" crossorigin href="/assets/evtd-BSFY9q6N.js">
<link rel="modulepreload" crossorigin href="/assets/lodash-es-BfZ3RYx_.js">
<link rel="modulepreload" crossorigin href="/assets/naive-ui-Dv03sO69.js">
<link rel="modulepreload" crossorigin href="/assets/pinia-TbaIYBWH.js">
<link rel="modulepreload" crossorigin href="/assets/vue-router-Dw9lfhBK.js">
<link rel="modulepreload" crossorigin href="/assets/sidebar-B2rODElh.js">
<link rel="modulepreload" crossorigin href="/assets/moment-D_9q9veS.js">
<link rel="modulepreload" crossorigin href="/assets/lodash-es-BMBcz_ne.js">
<link rel="modulepreload" crossorigin href="/assets/naive-ui-D44ht_T-.js">
<link rel="modulepreload" crossorigin href="/assets/pinia-GoyZDj3l.js">
<link rel="modulepreload" crossorigin href="/assets/vue-router-B__tG800.js">
<link rel="modulepreload" crossorigin href="/assets/sidebar-IrbbBqwJ.js">
<link rel="modulepreload" crossorigin href="/assets/auth-CMmmYXbQ.js">
<link rel="modulepreload" crossorigin href="/assets/post-oF0itKtw.js">
<link rel="modulepreload" crossorigin href="/assets/site-Djkx07Nf.js">
<link rel="modulepreload" crossorigin href="/assets/moment-Bm1RY_CN.js">
<link rel="modulepreload" crossorigin href="/assets/formatTime-CsGwQTZC.js">
<link rel="stylesheet" crossorigin href="/assets/sidebar-CwCVJc7v.css">
<link rel="stylesheet" crossorigin href="/assets/index-D47VUixF.css">
<link rel="stylesheet" crossorigin href="/assets/vfonts-Bnl8eXTc.css">

@ -67,6 +67,7 @@ import {
PeopleOutline,
WalletOutline,
SettingsOutline,
ConstructOutline,
LogOutOutline,
} from '@vicons/ionicons5';
import { Hash } from '@vicons/tabler';
@ -194,6 +195,14 @@ const menuOptions = computed(() => {
icon: () => h(SettingsOutline),
href: '/setting',
});
if (userInfo.value.is_admin) {
options.push({
label: '',
key: 'admin-settings',
icon: () => h(ConstructOutline),
href: '/admin/settings',
});
}
return userInfo.value.id > 0
? options
@ -419,4 +428,4 @@ window.$message = useMessage();
// display: block !important;
// }
}
}</style>
}</style>

@ -98,6 +98,14 @@ const routes = [
},
component: () => import('@/views/Setting.vue'),
},
{
path: '/admin/settings',
name: 'admin-settings',
meta: {
title: '',
},
component: () => import('@/views/AdminSettings.vue'),
},
{
path: '/404',
name: '404',

@ -18,7 +18,7 @@ export const useStoreProfile = defineStore("profile", () => {
defaultTweetVisibility: 'friend',
defaultMsgLoopInterval: 5000,
copyrightTop: '2023 paopao.info',
copyrightLeft: "Roc's Me",
copyrightLeft: 'Roc\'s Me',
copyrightLeftLink: '',
copyrightRight: '(PaoPao)',
copyrightRightLink: 'https://www.paopao.info',
@ -86,32 +86,32 @@ export const useStoreProfile = defineStore("profile", () => {
profile.value.enableWallet = data.enable_wallet ?? profile.value.enableWallet;
profile.value.allowTweetAttachment =
data.allow_tweet_attachment ?? profile.value.allowTweetAttachment;
data.allow_tweet_attachment ?? profile.value.allowTweetAttachment;
profile.value.allowTweetAttachmentPrice =
data.allow_tweet_attachment_price ?? profile.value.allowTweetAttachmentPrice;
data.allow_tweet_attachment_price ?? profile.value.allowTweetAttachmentPrice;
profile.value.allowTweetVideo = data.allow_tweet_video ?? profile.value.allowTweetVideo;
profile.value.allowUserRegister =
data.allow_user_register ?? profile.value.allowUserRegister;
data.allow_user_register ?? profile.value.allowUserRegister;
profile.value.allowPhoneBind = data.allow_phone_bind ?? profile.value.allowPhoneBind;
profile.value.defaultTweetMaxLength =
data.default_tweet_max_length ?? profile.value.defaultTweetMaxLength;
data.default_tweet_max_length ?? profile.value.defaultTweetMaxLength;
profile.value.tweetWebEllipsisSize =
data.tweet_web_ellipsis_size ?? profile.value.tweetWebEllipsisSize;
data.tweet_web_ellipsis_size ?? profile.value.tweetWebEllipsisSize;
profile.value.tweetMobileEllipsisSize =
data.tweet_mobile_ellipsis_size ?? profile.value.tweetMobileEllipsisSize;
data.tweet_mobile_ellipsis_size ?? profile.value.tweetMobileEllipsisSize;
profile.value.defaultTweetVisibility =
data.default_tweet_visibility ?? profile.value.defaultTweetVisibility;
data.default_tweet_visibility ?? profile.value.defaultTweetVisibility;
profile.value.defaultMsgLoopInterval =
data.default_msg_loop_interval ?? profile.value.defaultMsgLoopInterval;
data.default_msg_loop_interval ?? profile.value.defaultMsgLoopInterval;
profile.value.copyrightTop = data.copyright_top ?? profile.value.copyrightTop;
profile.value.copyrightLeft = data.copyright_left ?? profile.value.copyrightLeft;

@ -7,12 +7,28 @@ declare namespace Api {
user: {
/** 管理·用户禁言/解禁 */
status: (params: NetParams.UserStatusReq) => Promise<NetReq.UserChangeStatus>;
},
site: {
/** 管理·更新系统配置 */
profile: (params: NetParams.SiteProfileReq) => Promise<NetReq.SiteProfileResp>;
},
settings: {
/** 管理·保存通用配置 */
save: (params: NetParams.SettingsSaveReq) => Promise<NetReq.SettingsSaveResp>;
}
},
get: {
site: {
/** 获取站点状态信息 */
status: () => Promise<NetReq.SiteInfoResp>;
/** 管理·获取系统配置 */
profile: () => Promise<NetReq.SiteProfileResp>;
},
settings: {
/** 管理·获取通用配置 schema */
schema: () => Promise<NetReq.SettingsSchemaResp>;
/** 管理·获取通用配置当前值 */
values: () => Promise<NetReq.SettingsValuesResp>;
}
}
}
@ -24,19 +40,115 @@ declare namespace Api {
status: number;
}
interface SiteProfileReq {
use_friendship: boolean;
enable_trends_bar: boolean;
enable_wallet: boolean;
allow_tweet_attachment: boolean;
allow_tweet_attachment_price: boolean;
allow_tweet_video: boolean;
default_tweet_max_length: number;
tweet_web_ellipsis_size: number;
tweet_mobile_ellipsis_size: number;
default_tweet_visibility: 'public' | 'following' | 'friend' | 'private';
default_msg_loop_interval: number;
copyright_top: string;
copyright_left: string;
copyright_left_link: string;
copyright_right: string;
copyright_right_link: string;
}
interface SettingValueInput {
key: string;
value: string | number | boolean;
}
interface SettingsSaveReq {
items: SettingValueInput[];
}
}
namespace NetReq {
interface UserChangeStatus {}
interface SettingOption {
label: string;
value: string | number | boolean;
}
interface SettingsSchemaItem {
key: string;
group: string;
section: string;
type: 'bool' | 'int' | 'float' | 'string';
label: string;
description: string;
apply_mode: 'live' | 'restart_required' | 'bootstrap_only';
secret: boolean;
readonly: boolean;
active: boolean;
bootstrap_value?: string | number | boolean;
bootstrap_configured?: boolean;
options?: SettingOption[];
}
interface SettingsSchemaResp {
items: SettingsSchemaItem[];
}
interface SettingsValueItem {
key: string;
value?: string | number | boolean;
effective_value?: string | number | boolean;
source: 'bootstrap' | 'override' | string;
pending_restart: boolean;
configured?: boolean;
active: boolean;
}
interface SettingsValuesResp {
items: SettingsValueItem[];
has_pending_restart: boolean;
}
interface SettingsSaveResp {
items: SettingsValueItem[];
updated_keys: string[];
has_pending_restart: boolean;
}
interface SiteInfoResp {
register_user_count: number;
online_user_count: number;
history_max_online: number;
server_up_time: number;
}
interface SiteProfileResp {
use_friendship: boolean;
enable_trends_bar: boolean;
enable_wallet: boolean;
allow_tweet_attachment: boolean;
allow_tweet_attachment_price: boolean;
allow_tweet_video: boolean;
allow_user_register: boolean;
allow_phone_bind: boolean;
default_tweet_max_length: number;
tweet_web_ellipsis_size: number;
tweet_mobile_ellipsis_size: number;
default_tweet_visibility: 'public' | 'following' | 'friend' | 'private';
default_msg_loop_interval: number;
copyright_top: string;
copyright_left: string;
copyright_left_link: string;
copyright_right: string;
copyright_right_link: string;
readonly_fields: string[];
}
}
}
}
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save