Compare commits

..
4 Commits
Author SHA1 Message Date
ywxapp 1d49e6f5ee feat: 公共表更名 common/member 前缀 + 插件命令自动加载 + 版本 1.1.1
- 18 张公共表更名(addon/attachment/configure/links/spider_log/spider_stat/sms/notice/ad/task/prop/medal/help/card -> common_*,member_wallets->member_wallet,score_rule/score_log -> member_*,addon_config->common_addonconf),模型全部对齐新表名,Db 直引用清零
- Attachment 模型补  表名绑定,修复富文本上传查 wxapp_attachment 1146 隐患
- install.sql + 迁移 SQL:backend_admin/backend_role delete_at 默认 0,修复软删除(NULL != 0)误过滤导致后台菜单为空
- AppService::boot() 支持插件 info.php 声明 commands 自动注册插件命令(psr-4 自动加载,坏类名自动跳过)
- 各插件(haonav/mqttbroker/wxchat/articles/blog/forum 等)字段与配置同步调整
- 框架版本 1.1.0 -> 1.1.1
2026-08-20 20:19:15 +08:00
ywxapp 303f548664 fix: 修正打包脚本插件目录名(addon)、重建框架文件清单、修复 install.sql(sms 表冲突/菜单归属/权限名拼写) 2026-08-16 17:42:18 +08:00
ywxapp aded1c718b chore: 清理无引用的残留视图与静态文件(保留 ywxapp 孤儿类) 2026-08-16 17:02:42 +08:00
ywxapp 1579810183 docs: 重写 README 为 FastAdmin 风格设计使用文档 2026-08-16 16:58:38 +08:00
414 changed files with 15142 additions and 6828 deletions
View File
+241 -1470
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -13,7 +13,7 @@ namespace addon\appmall;
use think\facade\Config;
use think\facade\Db;
use ywxapp\AddonBase;
use ywxapp\Addons;
use ywxapp\model\BaseModel;
/**
@@ -23,7 +23,7 @@ use ywxapp\model\BaseModel;
* 安装时由框架自动导入 install.sql 建表(appmall_addon_list / appmall_addon_submissions / appmall_developers);
* 卸载时清理这些服务端表。
*/
class Addon extends addon
class Addon extends Addons
{
public function install(): bool
+1 -1
View File
@@ -15,7 +15,7 @@ use addon\appmall\library\AppmallSchema;
* 开发者自助注册 / 查询令牌 / 收益查询 / 提现;完全公开(noNeedLogin=*),
* 以开发者令牌(token)而非会员登录态认证。
* 路由前缀:/appmall/developer/*(由 AppService::loadAddonRoutes 在 boot 阶段包 Route::group('appmall') 注册到全局路由表,不隶属于任何 thinkphp 应用)。
* 视图:addon/appmall/view/frontend/developer/。
* 视图:addons/appmall/view/frontend/developer/。
*
* 注:AddonFrontend 基类 _initialize 把 view_path 设成【主应用】view/frontend/(与插件目录不符),
* 故此处必须在 initialize() 显式把 view_path 锁回插件目录,否则 fetch 找不到模板。
+1 -1
View File
@@ -20,7 +20,7 @@ use ywxapp\controller\FrontendBase;
* 列出已发布的主框架版本,提供「整包 / 增量补丁 / 完整安装包」下载入口。
* 下载走对外 API /appmall/api/framework/download(无需登录,可选签名)。
* 路由前缀:/appmall/framework/*(由 AppService::loadAddonRoutes 外层包 Route::group('appmall'))。
* 视图:addon/appmall/view/frontend/framework/。
* 视图:addons/appmall/view/frontend/framework/。
*/
class Framework extends FrontendBase
{
+1 -1
View File
@@ -20,7 +20,7 @@ use addon\appmall\service\MarketService;
* 列出应用市场上线的插件 / 模板,卡片展示 logo、价格、评分、下载量等。
* 数据来自中心站本地库(appmall_addon_list),复用 MarketService::catalog()。
* 路由前缀:/appmall/store/*(由 AppService::loadAddonRoutes 外层包 Route::group('appmall'))。
* 视图:addon/appmall/view/frontend/store/。
* 视图:addons/appmall/view/frontend/store/。
*/
class Store extends FrontendBase
{
+1 -1
View File
@@ -21,7 +21,7 @@ use ywxapp\library\StreamZipResponse;
*
* 由原核心 app/api/controller/Market.php 剥离而来,部署在「插件服务器(中心站)」的 market 插件内。
* 接口契约对齐 ywxapp 客户端 ywxapp\service\AddonService
* - GET /api/addon/lists 插件列表(addon::market 使用)
* - GET /api/addon/lists 插件列表(Addons::market 使用)
* - POST /api/addon/valid 安装前授权校验(AddonService::valid 使用,multipart
* - GET /api/index 下载插件 zipAddonService::download 使用,/api/index/index 规范化)
* - POST /api/addon/submit 开发者提交插件(待审核)
+4 -3
View File
@@ -2,11 +2,11 @@
return [
'name' => 'appmall',
'title' => '插件应用市场(服务端)',
'title' => '应用市场',
'intro' => '应用服务中心(插件商店 + 主框架在线升级)服务端:插件市场列表 / 下载 / 授权校验 / 开发者提交与审核,以及主框架核心包版本发布与升级。部署在「中心站」,客户端通过 config ywxapp.api_url 连接。',
'author' => 'YwxApp',
'website' => 'https://www.ywxapp.cn',
'version' => '1.0.19',
'version' => '1.0.21',
'state' => 1,
'license' => false,
'events' => [
@@ -15,5 +15,6 @@ return [
],
'services' => [
],
'install_time' => 1785173040,
'install_time' => 1786292662,
'update_time' => 1786808656,
];
+216 -197
View File
@@ -1,215 +1,234 @@
-- ============================================================
-- 应用市场(appmall)插件安装表结构
-- 事实源:addon/appmall/library/AppmallSchema.php::ensure()
-- 与运行时自愈 DDL 一致;扩展列已合并进建表,全新安装即完整
-- 插件应用市场(服务端)建表脚本
-- 由框架插件安装流程(AddonService::executeInstall)自动导入。
-- 部署「插件服务器(中心站)」并启用 market 插件后即生效
-- 接口契约见 addons/appmall/controller/api/Market.php
-- ============================================================
CREATE TABLE IF NOT EXISTS `appmall_addon_orders` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL DEFAULT 0,
`aid` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_list.id',
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '下单站点',
`amount` decimal(10,2) NOT NULL DEFAULT 0,
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0=待支付 1=已支付 2=已退款',
`trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号',
`pay_time` int unsigned DEFAULT 0,
`create_at` int unsigned DEFAULT 0,
`update_at` int unsigned DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_uid` (`uid`),
KEY `idx_trade` (`trade_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件购买订单表';
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_addon_list` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '插件标识符,如 mqttbroker',
`type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板',
`developer_id` int unsigned DEFAULT '0' COMMENT '发布者(wxapp_appmall_developers.id),用于收益归属',
`title` varchar(100) NOT NULL COMMENT '插件名称',
`version` varchar(20) NOT NULL COMMENT '版本号',
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格,0 为免费',
`author` varchar(100) DEFAULT '' COMMENT '作者',
`description` text COMMENT '简介/描述',
`changelog` text COMMENT '更新日志',
`require_framework` varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0',
`logo` varchar(255) DEFAULT '' COMMENT '封面图 URL',
`category` varchar(50) DEFAULT '' COMMENT '分类(如:工具/电商/社交)',
`tags` varchar(255) DEFAULT '' COMMENT '标签(逗号分隔)',
`screenshots` text COMMENT '截图 URL(逗号分隔或 JSON 数组)',
`rating` decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0',
`file_path` varchar(255) NOT NULL COMMENT 'zip 物理路径(非 Web 可访问目录)',
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256 校验值',
`status` tinyint DEFAULT 1 COMMENT '1=上架 0=下架',
`download_count` int DEFAULT 0 COMMENT '下载次数',
`create_at` int DEFAULT 0 COMMENT '创建时间',
`update_at` int DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name_version` (`name`, `version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件市场商品表';
CREATE TABLE IF NOT EXISTS `appmall_addon_licenses` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL,
`aid` int unsigned NOT NULL,
`license_key` varchar(64) NOT NULL COMMENT '授权码',
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '绑定站点',
`domain` varchar(255) DEFAULT NULL COMMENT '绑定域名(站点授权)',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1=有效 0=已吊销',
`expire_time` int unsigned DEFAULT 0 COMMENT '0为永久',
`download_count` int DEFAULT 0 COMMENT '下载次数限制',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `license_key` (`license_key`),
UNIQUE KEY `uk_uid_aid` (`uid`, `aid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件授权表';
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_addon_submissions` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`developer_id` int unsigned DEFAULT '0' COMMENT '提交者(wxapp_appmall_developers.id',
`name` varchar(50) NOT NULL COMMENT '插件标识符',
`type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板',
`title` varchar(100) NOT NULL COMMENT '插件名称',
`author` varchar(100) DEFAULT '' COMMENT '作者',
`description` text COMMENT '简介/描述',
`changelog` text COMMENT '更新日志',
`require_framework` varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0',
`version` varchar(20) NOT NULL COMMENT '版本号',
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
`category` varchar(50) DEFAULT '' COMMENT '分类',
`tags` varchar(255) DEFAULT '' COMMENT '标签(逗号分隔)',
`logo` varchar(255) DEFAULT '' COMMENT '封面图',
`file_path` varchar(255) NOT NULL COMMENT '待审 zip 物理路径',
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256',
`status` tinyint DEFAULT 0 COMMENT '0=待审核 1=已通过 2=已驳回',
`reject_reason` varchar(255) DEFAULT '' COMMENT '驳回原因',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name_version` (`name`, `version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件市场提交审核表';
CREATE TABLE IF NOT EXISTS `appmall_addon_download_logs` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`license_id` int unsigned NOT NULL DEFAULT 0,
`uid` int unsigned NOT NULL DEFAULT 0,
`aid` int unsigned NOT NULL DEFAULT 0,
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台安装者(特权放行审计)',
`ip` varchar(45) DEFAULT '',
`create_at` int unsigned DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_license` (`license_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件下载日志表';
CREATE TABLE IF NOT EXISTS `appmall_throttle` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`action` varchar(30) NOT NULL COMMENT '限流动作(buy/notify/download/submit',
`ident` varchar(80) NOT NULL COMMENT '限流标识(uid 或 IP',
`count` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口内已请求次数',
`expire_at` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口过期时间戳',
`create_at` int unsigned DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_action_ident` (`action`, `ident`),
KEY `idx_expire` (`expire_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口频率限制表';
CREATE TABLE IF NOT EXISTS `appmall_addon_review_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`submission_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_submissions.id',
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '操作管理员ID',
`action` varchar(20) NOT NULL COMMENT '操作:submit/approve/reject',
`from_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更前状态',
`to_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更后状态',
`reason` varchar(255) NOT NULL DEFAULT '' COMMENT '审核意见/驳回理由',
`create_at` int unsigned NOT NULL DEFAULT 0 COMMENT '操作时间',
PRIMARY KEY (`id`),
KEY `idx_submission` (`submission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件审核历史日志(可视化时间线数据源)';
CREATE TABLE IF NOT EXISTS `appmall_developers` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL COMMENT '开发者名称',
`email` varchar(120) NOT NULL COMMENT '邮箱(接收令牌/通知)',
`token` varchar(64) NOT NULL COMMENT '开发者令牌(发布插件时校验)',
`status` tinyint DEFAULT 0 COMMENT '0=待审核 1=正常 -1=禁用',
`balance` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '可提现余额',
`frozen_balance` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '待结算/冻结金额',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_email` (`email`),
UNIQUE KEY `uk_token` (`token`)
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_developers` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL COMMENT '开发者名称',
`email` varchar(120) NOT NULL COMMENT '邮箱(接收令牌/通知)',
`token` varchar(64) NOT NULL COMMENT '开发者令牌(发布插件时校验)',
`status` tinyint DEFAULT 0 COMMENT '0=待审核 1=正常 -1=禁用',
`balance` decimal(10,2) DEFAULT '0.00' COMMENT '可提现余额',
`frozen_balance` decimal(10,2) DEFAULT '0.00' COMMENT '待结算/冻结金额',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_email` (`email`),
UNIQUE KEY `uk_token` (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场开发者表';
CREATE TABLE IF NOT EXISTS `appmall_addon_revenues` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`developer_id` int unsigned NOT NULL DEFAULT '0' COMMENT '受益开发者',
`order_id` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_orders.id',
`aid` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_list.id',
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT '购买用户',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额',
`commission` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '平台抽成',
`income` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '开发者应得',
`status` tinyint DEFAULT 0 COMMENT '0=待结算 1=已结算',
`create_at` int DEFAULT 0,
`settle_at` int DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_developer` (`developer_id`),
KEY `idx_order` (`order_id`)
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_addon_revenues` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`developer_id` int unsigned NOT NULL DEFAULT '0' COMMENT '受益开发者',
`order_id` int unsigned NOT NULL DEFAULT '0' COMMENT 'wxapp_appmall_addon_orders.id',
`aid` int unsigned NOT NULL DEFAULT '0' COMMENT 'wxapp_appmall_addon_list.id',
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT '购买用户',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额',
`commission` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '平台抽成',
`income` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '开发者应得',
`status` tinyint DEFAULT 0 COMMENT '0=待结算 1=已结算',
`create_at` int DEFAULT 0,
`settle_at` int DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_developer` (`developer_id`),
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件销售收益账本';
CREATE TABLE IF NOT EXISTS `appmall_addon_withdrawals` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`developer_id` int unsigned NOT NULL DEFAULT '0',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '提现金额',
`channel` varchar(20) DEFAULT 'alipay' COMMENT '提现渠道',
`account` varchar(100) DEFAULT '' COMMENT '收款账号',
`status` tinyint DEFAULT 0 COMMENT '0=待处理 1=已打款 -1=驳回',
`remark` varchar(255) DEFAULT '',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_developer` (`developer_id`)
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_addon_withdrawals` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`developer_id` int unsigned NOT NULL DEFAULT '0',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '提现金额',
`channel` varchar(20) DEFAULT 'alipay' COMMENT '提现渠道',
`account` varchar(100) DEFAULT '' COMMENT '收款账号',
`status` tinyint DEFAULT 0 COMMENT '0=待处理 1=已打款 -1=驳回',
`remark` varchar(255) DEFAULT '',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_developer` (`developer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发者提现单';
CREATE TABLE IF NOT EXISTS `appmall_addon_submissions` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id',
`name` varchar(50) NOT NULL COMMENT '插件标识符',
`type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板',
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '名称',
`author` varchar(100) DEFAULT '' COMMENT '作者',
`description` text COMMENT '简介',
`version` varchar(20) NOT NULL COMMENT '版本号',
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
`logo` varchar(255) DEFAULT '' COMMENT '封面',
`category` varchar(50) DEFAULT '' COMMENT '分类',
`tags` varchar(255) DEFAULT '' COMMENT '标签',
`file_path` varchar(255) NOT NULL COMMENT '待审包路径',
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256',
`changelog` text COMMENT '更新日志',
`require_framework` varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0',
`status` tinyint DEFAULT 0 COMMENT '0待审 1通过 2驳回',
`reject_reason` varchar(255) DEFAULT '' COMMENT '驳回原因',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name_ver` (`name`, `version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件提交审核表';
-- ============================================================
-- 以下三张表是「购买/授权闭环」的运行表(订单 / 授权 / 下载日志)。
-- 旧版 install.sql 漏建,导致新装中心站一调 buy/notify/下载就报「表不存在」。
-- 与 docs/appmall_schema.sql、客户端 app/api/controller/v1/Addon.php::ensureTables() 字段保持一致。
-- ============================================================
CREATE TABLE IF NOT EXISTS `appmall_addon_list` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '标识符',
`type` varchar(20) NOT NULL DEFAULT 'addon' COMMENT '商品类型:addon=插件 template=模板',
`developer_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_developers.id',
`title` varchar(100) NOT NULL COMMENT '名称',
`version` varchar(20) NOT NULL COMMENT '版本号',
`price` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
`author` varchar(100) DEFAULT '' COMMENT '作者',
`description` text COMMENT '简介/描述',
`logo` varchar(255) DEFAULT '' COMMENT '封面图 URL',
`file_path` varchar(255) NOT NULL COMMENT '存储路径(非公开)',
`file_hash` varchar(64) NOT NULL COMMENT 'SHA256校验值',
`category` varchar(50) DEFAULT '' COMMENT '分类',
`tags` varchar(255) DEFAULT '' COMMENT '标签(逗号分隔)',
`screenshots` text COMMENT '截图URL(逗号分隔或JSON',
`rating` decimal(2,1) DEFAULT '0.0' COMMENT '评分(0.0~5.0',
`status` tinyint DEFAULT 1 COMMENT '1上架 0下架',
`download_count` int DEFAULT 0 COMMENT '下载次数',
`changelog` text COMMENT '更新日志',
`require_framework` varchar(20) DEFAULT '' COMMENT '最低兼容框架版本,如 1.2.0',
`create_at` int DEFAULT 0 COMMENT '创建时间',
`update_at` int DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场插件列表';
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_addon_licenses` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL COMMENT '用户ID',
`aid` int unsigned NOT NULL COMMENT '对应 appmall_addon_list.id',
`license_key` varchar(64) NOT NULL COMMENT '授权码',
`site_id` int unsigned NOT NULL DEFAULT '0' COMMENT '绑定站点(wxapp_appmall_sites.id)0=未绑定',
`domain` varchar(255) DEFAULT NULL COMMENT '绑定域名(站点授权,对齐 Discuz!)',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '1=有效 0=已吊销(退款)',
`expire_time` int unsigned DEFAULT '0' COMMENT '0 为永久',
`download_count` int DEFAULT '0' COMMENT '下载次数限制',
`create_at` int DEFAULT 0 COMMENT '创建时间',
`update_at` int DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `license_key` (`license_key`),
UNIQUE KEY `uk_uid_aid` (`uid`, `aid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件授权表';
CREATE TABLE IF NOT EXISTS `appmall_sites` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`site_token` varchar(64) NOT NULL DEFAULT '' COMMENT '站点令牌(客户端 install 时生成/上报)',
`domain` varchar(255) NOT NULL DEFAULT '' COMMENT '主域名',
`owner_uid` int unsigned NOT NULL DEFAULT '0' COMMENT '站点所有者用户ID',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '1=正常 0=禁用',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_domain` (`domain`),
KEY `idx_token` (`site_token`)
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_addon_orders` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL DEFAULT '0',
`aid` int unsigned NOT NULL DEFAULT '0' COMMENT 'appmall_addon_list.id',
`site_id` int unsigned NOT NULL DEFAULT '0' COMMENT '下单站点',
`amount` decimal(10,2) NOT NULL DEFAULT '0' COMMENT '订单金额',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '0=待支付 1=已支付 2=已退款',
`trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号',
`pay_time` int unsigned DEFAULT '0' COMMENT '支付时间',
`create_at` int unsigned DEFAULT 0,
`update_at` int unsigned DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_uid` (`uid`),
KEY `idx_trade` (`trade_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件购买订单表';
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_addon_download_logs` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`license_id` int unsigned NOT NULL DEFAULT '0',
`uid` int unsigned NOT NULL DEFAULT '0',
`aid` int unsigned NOT NULL DEFAULT '0',
`operator_id` int unsigned NOT NULL DEFAULT '0' COMMENT '运营后台下载审计(Addons::downloadInstall 透传,0=会员自助下载)',
`ip` varchar(45) DEFAULT '',
`create_at` int unsigned DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_license` (`license_id`),
KEY `idx_operator` (`operator_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件下载日志表';
-- 市场客户端站点(授权绑定的维度,对齐 Discuz!「授权绑定站点域名」)
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_sites` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`site_token` varchar(64) NOT NULL DEFAULT '' COMMENT '站点令牌(客户端 install 时生成/上报)',
`domain` varchar(255) NOT NULL DEFAULT '' COMMENT '主域名',
`owner_uid` int unsigned NOT NULL DEFAULT '0' COMMENT '站点所有者用户ID',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '1=正常 0=禁用',
`create_at` int DEFAULT 0,
`update_at` int DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_domain` (`domain`),
KEY `idx_token` (`site_token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场客户端站点(授权绑定维度)';
CREATE TABLE IF NOT EXISTS `appmall_framework_list` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`version` varchar(20) NOT NULL COMMENT '框架版本号',
`title` varchar(100) DEFAULT '' COMMENT '版本标题',
`changelog` text COMMENT '更新日志',
`file_path` varchar(255) DEFAULT '' COMMENT '整包 zip 物理路径(非 Web 可访问目录)',
`file_hash` varchar(64) DEFAULT '' COMMENT '整包 SHA256 校验值',
`patch_path` varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径(与整包同版本共存)',
`patch_hash` varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值',
`patch_from` varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本',
`type` tinyint DEFAULT 0 COMMENT '兼容旧客户端:0=含整包 1=仅补丁',
`from_version` varchar(20) DEFAULT '' COMMENT '兼容旧客户端:补丁基础版本镜像',
`status` tinyint DEFAULT 0 COMMENT '0=草稿 1=已发布(可下载)',
`download_count` int DEFAULT 0 COMMENT '下载次数',
`create_at` int DEFAULT 0 COMMENT '创建时间',
`update_at` int DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_version` (`version`)
-- ============================================================
-- 主框架升级(原 upgrade 插件,已合并进 market
-- 客户端 ywxapp\service\FrameworkService 经 /api/framework/* 拉取版本与核心包。
-- ============================================================
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_framework_list` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`version` varchar(20) NOT NULL COMMENT '框架版本号',
`title` varchar(100) DEFAULT '' COMMENT '版本标题',
`changelog` text COMMENT '更新日志',
`file_path` varchar(255) DEFAULT '' COMMENT '整包 zip 物理路径(非 Web 可访问目录)',
`file_hash` varchar(64) DEFAULT '' COMMENT '整包 SHA256 校验值',
`patch_path` varchar(255) DEFAULT '' COMMENT '增量补丁 zip 物理路径(与整包同版本共存)',
`patch_hash` varchar(64) DEFAULT '' COMMENT '补丁 SHA256 校验值',
`patch_from` varchar(20) DEFAULT '' COMMENT '补丁适用的基础版本',
`type` tinyint DEFAULT 0 COMMENT '兼容旧客户端:0=含整包 1=仅补丁(逻辑以 patch_path 是否为空为准)',
`from_version` varchar(20) DEFAULT '' COMMENT '兼容旧客户端:补丁基础版本镜像',
`install_path` varchar(255) DEFAULT '' COMMENT '完整安装包 zip 物理路径(全新部署用,含占位符 .env,非 Web 可访问目录)',
`install_hash` varchar(64) DEFAULT '' COMMENT '完整安装包 SHA256 校验值',
`status` tinyint DEFAULT 0 COMMENT '0=草稿 1=已发布(可下载)',
`download_count` int DEFAULT 0 COMMENT '下载次数',
`create_at` int DEFAULT 0 COMMENT '创建时间',
`update_at` int DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_version` (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架版本表';
CREATE TABLE IF NOT EXISTS `appmall_framework_download_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`version` varchar(20) NOT NULL COMMENT '下载的版本号',
`uid` int DEFAULT 0 COMMENT '下载用户ID(来自客户端)',
`ip` varchar(45) DEFAULT '' COMMENT '下载来源IP',
`create_at` int DEFAULT 0 COMMENT '下载时间',
PRIMARY KEY (`id`),
KEY `idx_version` (`version`)
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_framework_download_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`version` varchar(20) NOT NULL COMMENT '下载的版本号',
`uid` int DEFAULT 0 COMMENT '下载用户ID(来自客户端)',
`ip` varchar(45) DEFAULT '' COMMENT '下载来源IP',
`create_at` int DEFAULT 0 COMMENT '下载时间',
PRIMARY KEY (`id`),
KEY `idx_version` (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主框架下载日志表';
-- 接口频率限制(buy/notify/download/submit 等防刷)
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_throttle` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`action` varchar(30) NOT NULL COMMENT '限流动作(buy/notify/download/submit',
`ident` varchar(80) NOT NULL COMMENT '限流标识(uid 或 IP',
`count` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口内已请求次数',
`expire_at` int unsigned NOT NULL DEFAULT 0 COMMENT '窗口过期时间戳',
`create_at` int unsigned DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_action_ident` (`action`, `ident`),
KEY `idx_expire` (`expire_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口频率限制表';
CREATE TABLE IF NOT EXISTS `__PREFIX__appmall_addon_review_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`submission_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_submissions.id',
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '操作管理员ID',
`action` varchar(20) NOT NULL COMMENT '操作:submit/approve/reject',
`from_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更前状态',
`to_status` tinyint NOT NULL DEFAULT 0 COMMENT '变更后状态',
`reason` varchar(255) NOT NULL DEFAULT '' COMMENT '审核意见/驳回理由',
`create_at` int unsigned NOT NULL DEFAULT 0 COMMENT '操作时间',
PRIMARY KEY (`id`),
KEY `idx_submission` (`submission_id`),
KEY `idx_create` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件审核历史日志(可视化时间线数据源)';
+7 -5
View File
@@ -1,40 +1,42 @@
{
"top_title": "应用中心",
"superior": "service_center",
"backend": [
{
"name": "appmall",
"title": "应用市场",
"type": 1,
"icon": "fa fa-shopping-cart",
"centerOnly": true,
"sublist": [
"child": [
{
"name": "addonreview",
"title": "插件审核",
"type": 2,
"route": "/appmall/backend/addonreview/index"
},
{
"name": "developer",
"title": "开发者管理",
"type": 2,
"route": "/appmall/backend/developer/index"
},
{
"name": "revenuelist",
"title": "收益账本",
"type": 2,
"route": "/appmall/backend/revenue/index"
},
{
"name": "withdrawallist",
"title": "提现管理",
"type": 2,
"route": "/appmall/backend/revenue/withdrawals"
}
]
},
{
"icon": "fa fa-cloud-download",
"centerOnly": true,
"name": "frameworklist",
"title": "框架管理",
"type": 2,
"route": "/appmall/backend/framework/index"
}
],
+1 -1
View File
@@ -38,7 +38,7 @@ class AddonLicense extends BaseModel
*/
public function addon()
{
return $this->belongsTo(\ywxapp\model\AddonModel::class, 'aid', 'id');
return $this->belongsTo(\ywxapp\model\CommonAddon::class, 'aid', 'id');
}
/**
+15 -16
View File
@@ -3,7 +3,7 @@
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-07-21 13:36:46
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-08-16 11:31:01
* @LastEditTime: 2026-08-04 22:21:46
* @Description: 应用市场插件统一路由(对外 API + 主应用业务路由)
* @FilePath: \ywxapp_dev\addon\appmall\route\app.php
* @CustomString: Copyright (c) 2026 YwxApp
@@ -70,26 +70,25 @@ Route::group('backend', function () {
Route::post('framework/delete', [Framework::class, 'delete']);
Route::get('framework/download', [Framework::class, 'download']);
});
// ---------- 开发者中心(独立前台应用,公开注册/令牌/收益/提现,非会员中心):统一前缀 /appmall/developer ----------
// 仅【中心站】暴露:开发者自助注册/收益/提现依赖 appmall_developers / appmall_addon_revenues / appmall_addon_withdrawals 等
// 运营表,客户机(ywxapp.api_url 指向其他服务器,见 is_market_client())作为中心站客户端不持有这些表,故不在路由层注册,
// 访问即 404。配合 Developer 控制器 initialize() 的运行时兜底(即便路由缓存陈旧或被直连也能拦截)。
// ---------- 开发者中心(独立前台,令牌认证):/appmall/Developer/* ----------
Route::group('Developer', function () {
Route::get('index', [DeveloperCenter::class, 'index']);
Route::post('save', [DeveloperCenter::class, 'save']);
Route::get('mine', [DeveloperCenter::class, 'mine']);
Route::post('lookup', [DeveloperCenter::class, 'lookup']);
Route::get('dashboard', [DeveloperCenter::class, 'dashboard']);
Route::get('console', [DeveloperCenter::class, 'console']);
Route::post('overview', [DeveloperCenter::class, 'overview']);
Route::post('apps', [DeveloperCenter::class, 'apps']);
Route::post('price', [DeveloperCenter::class, 'price']);
Route::post('earnings', [DeveloperCenter::class, 'earnings']);
Route::post('withdraw', [DeveloperCenter::class, 'withdraw']);
});
Route::get('developer/index', [DeveloperCenter::class, 'index']);
Route::post('developer/save', [DeveloperCenter::class, 'save']);
Route::get('developer/mine', [DeveloperCenter::class, 'mine']);
Route::post('developer/lookup', [DeveloperCenter::class, 'lookup']);
Route::get('developer/dashboard', [DeveloperCenter::class, 'dashboard']);
Route::get('developer/console', [DeveloperCenter::class, 'console']);
Route::post('developer/overview', [DeveloperCenter::class, 'overview']);
Route::post('developer/apps', [DeveloperCenter::class, 'apps']);
Route::post('developer/price', [DeveloperCenter::class, 'price']);
Route::post('developer/earnings', [DeveloperCenter::class, 'earnings']);
Route::post('developer/withdraw', [DeveloperCenter::class, 'withdraw']);
// ---------- 对外 API(统一前缀 /appmall/api):远程客户机经 Remote/Addon/FrameworkService 调用 ----------
// 由 AppService::loadAddonRoutes() 的 Route::group('appmall') 补全插件名前缀 → /appmall/api/*
//(与 /appmall/backend、/appmall/developer 同源同构,均为「插件名开头」约定)。
+23
View File
@@ -0,0 +1,23 @@
-- ============================================================
-- addons/appmall/upgrade.sql
-- 用于已安装插件、但缺少新字段/配置的老库手动升级。
-- 在数据库客户端执行本文件即可。
--
-- 重要说明:
-- 1) 框架「在线升级」(AddonService::onlineUpgrade) 会自动重导 install.sql
-- CREATE TABLE IF NOT EXISTS 全量幂等),已覆盖 appmall 全部表结构;
-- 本文件仅作为 DBA 手动补列 / 历史库修复的参考。
-- 2) 框架插件升级流程的 importsql() 白名单仅允许 CREATE/INSERT,不会自动执行
-- 本文件的 ALTER;如需在升级时自动收敛缺失列,请走插件 Addon::upgrade() 钩子
-- (内部用 SchemaGuard::ensureColumn 先探测后追加,重复执行安全)。
-- 3) 纯 ALTER 语句重复执行可能报「列已存在」,属正常,可忽略。
-- ============================================================
SET NAMES utf8mb4;
-- 1. 下载日志表:运营后台下载审计字段(install.sql 已含,此处供老库补齐)
-- 注意:本文件为 DBA 手动执行脚本,不会被框架自动替换前缀;
-- 执行前请将下方 `__PREFIX__` 替换为当前数据库表前缀(如 wxapp_)。
ALTER TABLE `__PREFIX__appmall_addon_download_logs`
ADD COLUMN `operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '运营后台下载审计(0=会员自助下载)' AFTER `uid`;
-- 2. 未来新增列在此追加(同样直接 ALTER,重复执行忽略报错即可)
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="zh-CN">
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<title>审核详情 - 应用市场管理</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/assets/layui/css/layui.css" rel="stylesheet">
<link href="/assets/ywxapp/css/wxapp.css" rel="stylesheet">
<style>
body { background: #f2f3f5; padding: 15px; }
.info-card { background: #fff; border-radius: 4px; padding: 15px 20px; margin-bottom: 15px; }
.info-card h3 { margin: 0 0 12px; font-size: 15px; border-left: 3px solid #1e9fff; padding-left: 8px; }
.info-row { display: flex; flex-wrap: wrap; }
.info-item { width: 50%; padding: 6px 0; box-sizing: border-box; }
.info-item.full { width: 100%; }
.info-item .label { color: #999; margin-right: 6px; }
.info-item .value { color: #333; }
.step-wrap { background: #fff; border-radius: 4px; padding: 20px; margin-bottom: 15px; }
.timeline-empty { color: #999; text-align: center; padding: 20px 0; }
</style>
</head>
<body>
<div class="info-card">
<h3>提交信息</h3>
<div class="info-row" id="infoRow">
<div class="timeline-empty">加载中…</div>
</div>
</div>
<div class="step-wrap">
<h3 style="margin:0 0 18px;font-size:15px;border-left:3px solid #1e9fff;padding-left:8px;">审核状态流</h3>
<div class="layui-row layui-col-space20" id="stepRow">
<div class="timeline-empty">加载中…</div>
</div>
</div>
<div class="info-card">
<h3>审核历史时间线</h3>
<ul class="layui-timeline" id="timeline">
<li class="layui-timeline-item"><i class="layui-icon layui-timeline-axis layui-icon-loading"></i><div class="layui-timeline-content layui-text"><div class="timeline-empty">加载中…</div></div></li>
</ul>
</div>
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="{$site.app}"></script>
<script>
layui.use(['jquery', 'http', 'util'], function () {
var $ = layui.jquery, http = layui.http, util = layui.util;
var id = {$id};
function statusText(s) {
switch (parseInt(s, 10)) {
case 0: return '待审核';
case 1: return '已通过';
case 2: return '已驳回';
default: return '未知(' + s + ')';
}
}
function actionText(a) {
switch (a) {
case 'submit': return '提交审核';
case 'approve': return '审核通过';
case 'reject': return '审核驳回';
default: return a;
}
}
function fmt(ts) {
if (!ts) return '';
return util.toDateString(ts * 1000, 'yyyy-MM-dd HH:mm:ss');
}
http.get('detail', { id: id }).then(function (r) {
if (r.code !== 0 || !r.data) { $('#infoRow').html('<div class="timeline-empty">加载失败</div>'); return; }
var d = r.data;
var html = ''
+ '<div class="info-item"><span class="label">ID</span><span class="value">' + (d.id || '') + '</span></div>'
+ '<div class="info-item"><span class="label">标识:</span><span class="value">' + (d.name || '') + '</span></div>'
+ '<div class="info-item"><span class="label">名称:</span><span class="value">' + (d.title || '') + '</span></div>'
+ '<div class="info-item"><span class="label">类型:</span><span class="value">' + (d.type === 'template' ? '模板' : '插件') + '</span></div>'
+ '<div class="info-item"><span class="label">作者:</span><span class="value">' + (d.author || '') + '</span></div>'
+ '<div class="info-item"><span class="label">版本:</span><span class="value">' + (d.version || '') + '</span></div>'
+ '<div class="info-item"><span class="label">价格:</span><span class="value">' + (d.price > 0 ? '¥' + d.price : '免费') + '</span></div>'
+ '<div class="info-item"><span class="label">当前状态:</span><span class="value">' + statusText(d.status) + '</span></div>'
+ '<div class="info-item"><span class="label">提交时间:</span><span class="value">' + fmt(d.create_at) + '</span></div>'
+ '<div class="info-item"><span class="label">上架状态:</span><span class="value">' + (d.published ? '已上架' : '未上架') + '</span></div>'
+ '<div class="info-item full"><span class="label">简介:</span><span class="value">' + (d.description || '-') + '</span></div>'
+ '<div class="info-item full"><span class="label">驳回原因:</span><span class="value">' + (d.reject_reason || '-') + '</span></div>';
$('#infoRow').html(html);
// 步骤条:提交 → 审核(通过/驳回)
var steps = [
{ title: '提交审核', done: true },
{ title: '运营审核', done: true, current: d.status != 0, reject: d.status == 2, pass: d.status == 1 }
];
var stepHtml = '';
steps.forEach(function (s) {
var cls = 'layui-bg-blue';
if (s.reject) cls = 'layui-bg-red';
else if (!s.done) cls = 'layui-bg-gray';
var icon = '&#xe605;';
if (s.reject) icon = '&#x1006;';
else if (!s.done) icon = '&#xe615;';
stepHtml += ''
+ '<div class="layui-col-xs6 layui-col-md6" style="text-align:center;">'
+ ' <button class="layui-btn layui-btn-circle ' + cls + '">' + icon + '</button>'
+ ' <div style="margin-top:6px;">' + s.title + '</div>'
+ '</div>';
});
$('#stepRow').html('<div class="layui-col-xs12" style="display:flex;align-items:center;justify-content:center;">' + stepHtml + '</div>');
}).catch(function () { $('#infoRow').html('<div class="timeline-empty">请求失败</div>'); });
http.get('history', { id: id }).then(function (r) {
var arr = (r.code === 0 && r.data) ? r.data : [];
if (!arr.length) {
$('#timeline').html('<li class="layui-timeline-item"><i class="layui-icon layui-timeline-axis layui-icon-face-surprised"></i><div class="layui-timeline-content layui-text"><div class="timeline-empty">暂无审核记录</div></div></li>');
return;
}
var html = '';
arr.forEach(function (item) {
var color = item.action === 'reject' ? '#ff5722' : (item.action === 'approve' ? '#16b777' : '#1e9fff');
html += ''
+ '<li class="layui-timeline-item">'
+ ' <i class="layui-icon layui-timeline-axis" style="color:' + color + ';">&#xe63f;</i>'
+ ' <div class="layui-timeline-content layui-text">'
+ ' <h4>' + actionText(item.action) + ' <span class="layui-badge layui-bg-gray">' + statusText(item.from_status) + ' → ' + statusText(item.to_status) + '</span></h4>'
+ ' <p>操作人ID' + (item.operator_id || 0) + (item.reason ? ';意见:' + item.reason : '') + '</p>'
+ ' <p style="color:#999;">' + fmt(item.create_at) + '</p>'
+ ' </div>'
+ '</li>';
});
$('#timeline').html(html);
}).catch(function () {
$('#timeline').html('<li class="layui-timeline-item"><i class="layui-icon layui-timeline-axis layui-icon-face-surprised"></i><div class="layui-timeline-content layui-text"><div class="timeline-empty">请求失败</div></div></li>');
});
});
</script>
</body>
</html>
+107 -79
View File
@@ -1,93 +1,121 @@
<div class="layui-card" style="margin:20px;">
<div class="layui-card-header">插件审核(开发者上架申请)</div>
<div class="layui-card-body">
<div class="layui-form layui-row" style="margin-bottom:12px;">
<div class="layui-inline">
<select id="statusFilter">
<option value="">全部状态</option>
<option value="0">待审核</option>
<option value="1">已发布</option>
<option value="2">已驳回</option>
</select>
</div>
<div class="layui-inline">
<button class="layui-btn" id="searchBtn">搜索</button>
</div>
</div>
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>插件审核 - 应用市场管理</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/assets/layui/css/layui.css" rel="stylesheet">
<link href="/assets/ywxapp/css/wxapp.css" rel="stylesheet">
<style>body{background:#f2f3f5;padding:15px;}</style>
</head>
<body>
<form class="layui-form" lay-filter="filterForm">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline" style="width:140px;">
<select name="status">
<option value="">全部</option>
<option value="0">待审核</option>
<option value="1">已通过</option>
<option value="2">已驳回</option>
</select>
</div>
</div>
<button class="layui-btn" lay-submit lay-filter="search">搜索</button>
</div>
</form>
<script type="text/html" id="toolTpl">
<table id="dataTable" lay-filter="dataTable"></table>
<script type="text/html" id="statusTpl">
{{# if(d.status == 0){ }}<span class="layui-badge layui-bg-orange">待审核</span>
{{# } else if(d.status == 1){ }}<span class="layui-badge layui-bg-green">已通过</span>
{{# } else { }}<span class="layui-badge">已驳回</span>{{# } }}
</script>
<script type="text/html" id="operateTpl">
<a class="layui-btn layui-btn-xs" href="download?id={{ d.id }}" target="_blank">下载包</a>
{{# if(d.status == 0){ }}
<a class="layui-btn layui-btn-xs" lay-event="audit">通过</a>
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="approve">通过并发布</a>
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="reject">驳回</a>
{{# } else { }}
<span class="layui-badge {{ d.status == 1 ? 'layui-bg-green' : '' }}">
{{ d.status == 1 ? '已发布' : '已驳回' }}
</span>
{{# } }}
</script>
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="price">改价</a>
<a class="layui-btn layui-btn-xs" lay-event="detail">查看详情</a>
</script>
<script>
layui.use(['table', 'jquery', 'layer'], function () {
var table = layui.table, $ = layui.jquery, layer = layui.layer;
table.render({
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="{$site.app}"></script>
<script>
layui.use(['table', 'form', 'layer', 'jquery', 'http'], function () {
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.jquery, http = layui.http;
table.render({
elem: '#dataTable',
url: '{:url("/appmall/backend/addonreview/index")}',
url: 'index',
page: true,
response: { statusName: 'code', msgName: 'message', countName: 'count', dataName: 'data' },
parseData: function (res) {
return {
code: res.code,
msg: res.message,
count: res.count || 0,
data: res.data || []
};
},
where: { status: $('#statusFilter').val() },
limits: [10, 20, 50],
limit: 10,
cols: [[
{field: 'id', title: 'ID', width: 70},
{field: 'title', title: '插件名称', width: 160},
{field: 'name', title: '标识', width: 140},
{field: 'version', title: '版本', width: 90},
{field: 'developer_id', title: '开发者ID', width: 100},
{field: 'price', title: '价格', width: 90, templet: function(d){return d.price ? d.price : '免费';}},
{field: 'status', title: '状态', width: 90, templet: function(d){
if(d.status==0) return '<span class="layui-badge">待审核</span>';
if(d.status==1) return '<span class="layui-badge layui-bg-green">已发布</span>';
return '<span class="layui-badge layui-bg-red">已驳回</span>';
}},
{field: 'reject_reason', title: '驳回原因', width: 160},
{field: 'create_at', title: '提交时间', width: 170, templet: function(d){return layui.util.toDateString(d.create_at*1000);}},
{title: '操作', width: 160, templet: '#toolTpl', fixed: 'right'}
]]
});
{ field: 'id', title: 'ID', width: 70 },
{ field: 'name', title: '标识', width: 120 },
{ field: 'type', title: '类型', width: 80, templet: function (d) {
return d.type === 'template'
? '<span class="layui-badge" style="background:#722ed1;">模板</span>'
: '<span class="layui-badge layui-bg-blue">插件</span>';
} },
{ field: 'title', title: '名称', minWidth: 140 },
{ field: 'author', title: '作者', width: 120 },
{ field: 'version', title: '版本', width: 90 },
{ field: 'price', title: '价格', width: 90, templet: function (d) { return d.price > 0 ? '¥' + d.price : '免费'; } },
{ field: 'status', title: '状态', width: 90, templet: '#statusTpl' },
{ field: 'reject_reason', title: '驳回原因', minWidth: 160 },
{ field: 'create_at', title: '提交时间', width: 170, templet: function (d) { return d.create_at ? new Date(d.create_at * 1000).toLocaleString() : ''; } },
{ title: '操作', width: 240, toolbar: '#operateTpl', fixed: 'right' }
]],
response: { statusName: 'code', statusCode: 0, msgName: 'message', countName: 'count', dataName: 'data' }
});
$('#searchBtn').on('click', function () {
table.reload('dataTable', { where: { status: $('#statusFilter').val() }, page: { curr: 1 } });
});
form.on('submit(search)', function (data) {
table.reload('dataTable', { where: data.field, page: { curr: 1 } });
return false;
});
table.on('tool(dataTable)', function (obj) {
var d = obj.data;
if (obj.event === 'audit') {
layer.confirm('确认通过该插件上架申请', function (index) {
$.post('{:url("/appmall/backend/addonreview/audit")}', { id: d.id }, function (r) {
layer.msg(r.msg || '操作完成');
if (r.code === 0) { table.reload('dataTable'); }
layer.close(index);
}, 'json');
});
} else if (obj.event === 'reject') {
layer.prompt({ title: '填写驳回原因', formType: 2 }, function (val, index) {
$.post('{:url("/appmall/backend/addonreview/reject")}', { id: d.id, reason: val }, function (r) {
layer.msg(r.msg || '操作完成');
if (r.code === 0) { table.reload('dataTable'); }
layer.close(index);
}, 'json');
});
table.on('tool(dataTable)', function (elem) {
var d = elem.data;
if (elem.event === 'approve') {
layer.confirm('确认通过该插件并发布到市场', function () {
http.post('approve', { id: d.id }).then(function (r) {
if (r.code === 0) { layer.msg('已通过并发布', { icon: 1 }); table.reload('dataTable'); }
else { layer.msg(r.message || '操作失败', { icon: 2 }); }
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
});
} else if (elem.event === 'reject') {
layer.prompt({ title: '驳回原因', formType: 2 }, function (val, idx) {
http.post('reject', { id: d.id, reason: val }).then(function (r) {
if (r.code === 0) { layer.close(idx); layer.msg('已驳回', { icon: 1 }); table.reload('dataTable'); }
else { layer.msg(r.message || '操作失败', { icon: 2 }); }
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
});
} else if (elem.event === 'detail') {
layer.open({
type: 2,
title: '审核详情 #' + d.id,
area: ['720px', '80%'],
content: 'detailview?id=' + d.id
});
} else if (elem.event === 'price') {
layer.prompt({ title: '修改「' + d.name + '」价格(0 为免费,同步全部版本)', value: d.price || '0' }, function (val, idx) {
var p = parseFloat(val);
if (isNaN(p) || p < 0) { layer.msg('请输入不小于 0 的价格', { icon: 2 }); return; }
http.post('updatePrice', { name: d.name, price: p }).then(function (r) {
if (r.code === 0) { layer.close(idx); layer.msg(r.message || '价格已更新', { icon: 1 }); table.reload('dataTable'); }
else { layer.msg(r.message || '操作失败', { icon: 2 }); }
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
});
}
});
});
});
</script>
</script>
</body>
</html>
@@ -0,0 +1,681 @@
<!--
* @Author: YwxApp <ywx@ywxapp.cn>
* @Description: 开发者中心控制台(Discuz! 应用中心风格:顶栏 + 左侧菜单 + 内容区)
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>开发者中心 - YwxApp 应用市场</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/assets/layui/css/layui.css" rel="stylesheet">
<link href="/assets/ywxapp/css/wxapp.css" rel="stylesheet">
<style>
:root {
--brand: #1677ff;
--brand-dark: #0e5fd8;
--bg: #f0f2f5;
--card: #fff;
--text: #1f2329;
--muted: #8a919f;
--line: #ebedf0;
--ok: #18a058;
--warn: #d48806;
--danger: #d4380d;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 14px; }
a { color: var(--brand); text-decoration: none; }
/* 顶栏 */
.topbar { height: 56px; background: #fff; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding: 0 22px; position: sticky; top: 0; z-index: 10; }
.topbar .brand { font-size: 17px; font-weight: 700; color: var(--text); display: flex; align-items: center; gap: 8px; }
.topbar .brand .logo { width: 26px; height: 26px; border-radius: 6px; background: linear-gradient(135deg, var(--brand), #36cfc9); color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 15px; }
.topbar .brand small { font-weight: 400; color: var(--muted); font-size: 13px; margin-left: 2px; }
.topbar .spacer { flex: 1; }
.topbar .who { color: var(--muted); font-size: 13px; margin-right: 14px; }
.topbar .who b { color: var(--text); }
/* 布局 */
.layout { display: flex; min-height: calc(100vh - 56px); }
.sidebar { width: 210px; background: #fff; border-right: 1px solid var(--line); padding: 14px 0; flex-shrink: 0; }
.sidebar .menu { list-style: none; margin: 0; padding: 0; }
.sidebar .menu li { padding: 12px 22px; cursor: pointer; color: #4e5969; display: flex; align-items: center; gap: 10px; font-size: 14px; border-left: 3px solid transparent; transition: all .15s; }
.sidebar .menu li .ic { font-size: 16px; width: 18px; text-align: center; }
.sidebar .menu li:hover { background: #f7f9fc; color: var(--brand); }
.sidebar .menu li.active { background: #eef4ff; color: var(--brand); border-left-color: var(--brand); font-weight: 600; }
.content { flex: 1; padding: 22px 26px; min-width: 0; }
.panel { display: none; }
.panel.active { display: block; animation: fade .2s; }
@keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.page-title { font-size: 18px; font-weight: 700; margin: 0 0 4px; }
.page-sub { color: var(--muted); margin: 0 0 18px; font-size: 13px; }
/* 卡片/统计 */
.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; }
.stat-card { background: var(--card); border-radius: 10px; padding: 16px 18px; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
.stat-card .lbl { color: var(--muted); font-size: 13px; }
.stat-card .num { font-size: 26px; font-weight: 700; margin-top: 6px; }
.stat-card .num.ok { color: var(--ok); }
.stat-card .num.brand { color: var(--brand); }
.card { background: var(--card); border-radius: 10px; padding: 18px 20px; box-shadow: 0 1px 3px rgba(0,0,0,.04); margin-bottom: 16px; }
.card h3 { margin: 0 0 14px; font-size: 15px; display: flex; align-items: center; gap: 8px; }
.card h3 .bar { width: 4px; height: 15px; background: var(--brand); border-radius: 2px; display: inline-block; }
/* 表格 */
table.dev-table { width: 100%; border-collapse: collapse; font-size: 13px; }
table.dev-table th, table.dev-table td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--line); }
table.dev-table th { color: var(--muted); font-weight: 500; background: #fafbfc; }
table.dev-table tr:hover td { background: #fafcff; }
.app-name { font-weight: 600; }
.app-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
.tag { display: inline-block; padding: 2px 9px; border-radius: 20px; font-size: 12px; }
.tag.up { background: #e8f7ee; color: var(--ok); }
.tag.down { background: #f1f2f4; color: var(--muted); }
.tag.wait { background: #fff7e6; color: var(--warn); }
.tag.reject { background: #fff1f0; color: var(--danger); }
.tag.free { background: #eef4ff; color: var(--brand); }
.tag.t-addon { background: #eef4ff; color: var(--brand); }
.tag.t-tpl { background: #f4eefe; color: #722ed1; }
.empty { text-align: center; color: var(--muted); padding: 40px 0; }
/* 令牌闸门 */
.gate { max-width: 420px; margin: 12vh auto 0; background: #fff; border-radius: 12px; padding: 34px 36px; box-shadow: 0 4px 20px rgba(0,0,0,.08); }
.gate h2 { margin: 0 0 6px; text-align: center; }
.gate p { text-align: center; color: var(--muted); margin: 0 0 22px; font-size: 13px; }
.btn { display: inline-flex; align-items: center; gap: 6px; }
.layui-btn { border-radius: 6px; }
.toolbar { margin-bottom: 14px; display: flex; justify-content: space-between; align-items: center; }
.copybox { background: #f6f8fa; border: 1px dashed #d0d7de; border-radius: 6px; padding: 10px 12px; font-family: monospace; word-break: break-all; margin: 8px 0; }
.kv { display: flex; padding: 9px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
.kv:last-child { border-bottom: none; }
.kv .k { width: 110px; color: var(--muted); flex-shrink: 0; }
.kv .v { flex: 1; word-break: break-all; }
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
.hidden { display: none !important; }
.act-link { color: var(--brand); cursor: pointer; }
@media (max-width: 880px) { .stat-row { grid-template-columns: repeat(2, 1fr); } .sidebar { width: 64px; } .sidebar .menu li span.txt { display: none; } }
</style>
</head>
<body>
<!-- 顶栏 -->
<div class="topbar" id="topbar">
<div class="brand"><span class="logo">Y</span>YwxApp 应用市场<small>开发者中心</small></div>
<div class="spacer"></div>
<div class="who" id="who"></div>
<a href="javascript:;" class="layui-btn layui-btn-sm layui-btn-primary" id="logoutBtn">退出</a>
</div>
<!-- 令牌闸门 -->
<div id="gate" class="gate hidden">
<h2>开发者令牌登录</h2>
<p>请输入你的开发者令牌以进入控制台(可在「查询我的令牌」页取回)</p>
<form class="layui-form" id="gateForm">
<div class="layui-form-item">
<input type="text" name="token" id="gateToken" required lay-verify="required" placeholder="粘贴开发者令牌" class="layui-input">
</div>
<div class="layui-form-item">
<button class="layui-btn layui-btn-fluid layui-btn-normal" lay-submit lay-filter="gate">进入控制台</button>
</div>
</form>
<p class="hint" style="text-align:center;">
还没有令牌?<a href="{:url('index')}">立即申请入驻</a> ·
<a href="{:url('mine')}">找回令牌</a>
</p>
</div>
<!-- 控制台主体 -->
<div class="layout hidden" id="console">
<div class="sidebar">
<ul class="menu" id="menu">
<li data-tab="overview" class="active"><span class="ic layui-icon layui-icon-console"></span><span class="txt">概览</span></li>
<li data-tab="apps"><span class="ic layui-icon layui-icon-app"></span><span class="txt">我的应用</span></li>
<li data-tab="submit"><span class="ic layui-icon layui-icon-upload"></span><span class="txt">提交应用</span></li>
<li data-tab="earn"><span class="ic layui-icon layui-icon-rmb"></span><span class="txt">收益账单</span></li>
<li data-tab="withdraw"><span class="ic layui-icon layui-icon-transfer"></span><span class="txt">提现</span></li>
<li data-tab="account"><span class="ic layui-icon layui-icon-user"></span><span class="txt">账户设置</span></li>
</ul>
</div>
<div class="content">
<!-- 概览 -->
<section class="panel active" id="panel-overview">
<h2 class="page-title">概览</h2>
<p class="page-sub">你的应用与市场表现一览</p>
<div class="stat-row" id="statRow">
<div class="stat-card"><div class="lbl">已上架应用</div><div class="num brand" id="stApps">-</div></div>
<div class="stat-card"><div class="lbl">待审核提交</div><div class="num warn" id="stPending">-</div></div>
<div class="stat-card"><div class="lbl">总下载量</div><div class="num" id="stDl">-</div></div>
<div class="stat-card"><div class="lbl">可提现余额</div><div class="num ok" id="stBalance">-</div></div>
</div>
<div class="card">
<h3><span class="bar"></span>近期提交</h3>
<table class="dev-table">
<thead><tr><th>应用</th><th>版本</th><th>状态</th><th>时间</th></tr></thead>
<tbody id="recentBody"><tr><td colspan="4" class="empty">加载中…</td></tr></tbody>
</table>
</div>
</section>
<!-- 我的应用 -->
<section class="panel" id="panel-apps">
<h2 class="page-title">我的应用</h2>
<p class="page-sub">查看你已上架/下架的应用,以及待审核的提交记录</p>
<div class="toolbar">
<span class="hint">如需发布新版本,请前往「提交应用」上传 zip 包</span>
<a href="javascript:;" class="layui-btn layui-btn-sm layui-btn-normal" data-goto="submit">+ 提交新应用</a>
</div>
<div class="card">
<h3><span class="bar"></span>已上架 / 下架应用</h3>
<table class="dev-table">
<thead><tr><th>应用</th><th>版本</th><th>价格</th><th>下载</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead>
<tbody id="listedBody"><tr><td colspan="7" class="empty">加载中…</td></tr></tbody>
</table>
</div>
<div class="card">
<h3><span class="bar"></span>提交记录</h3>
<table class="dev-table">
<thead><tr><th>应用</th><th>版本</th><th>状态</th><th>说明</th><th>更新时间</th></tr></thead>
<tbody id="subBody"><tr><td colspan="5" class="empty">加载中…</td></tr></tbody>
</table>
</div>
</section>
<!-- 提交应用 -->
<section class="panel" id="panel-submit">
<h2 class="page-title">商品提交</h2>
<p class="page-sub" id="submitSub">上传插件 zip 包(根目录需含 info.php),提交后等待官方审核</p>
<div class="card">
<form class="layui-form" id="submitForm">
<div class="layui-form-item">
<label class="layui-form-label">应用类型</label>
<div class="layui-input-inline" style="width:200px;">
<select name="type" id="appType">
<option value="addon">插件</option>
<option value="template">模板</option>
</select>
</div>
<div class="layui-form-mid layui-word-aux">插件含 info.php;模板含 templates/名称/template.json</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">插件标识</label>
<div class="layui-input-inline" style="width:260px;">
<input type="text" name="name" required lay-verify="required" placeholder="如 mqttbroker(仅字母数字下划线)" class="layui-input">
</div>
<div class="layui-form-mid layui-word-aux" id="nameHint">与 info.php 中 name 一致</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">版本号</label>
<div class="layui-input-inline" style="width:200px;">
<input type="text" name="version" required lay-verify="required" placeholder="如 1.0.0" class="layui-input">
</div>
<div class="layui-form-mid layui-word-aux">格式 x.y.z,且未重复提交</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">应用名称</label>
<div class="layui-input-block">
<input type="text" name="title" required lay-verify="required" placeholder="如 MQTT 消息代理" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">作者</label>
<div class="layui-input-block">
<input type="text" name="author" placeholder="开发者/工作室名称" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">分类</label>
<div class="layui-input-inline" style="width:200px;">
<select name="category">
<option value="工具">工具</option>
<option value="电商">电商</option>
<option value="社交">社交</option>
<option value="内容">内容</option>
<option value="教育">教育</option>
<option value="其他">其他</option>
</select>
</div>
<label class="layui-form-label">价格(¥)</label>
<div class="layui-input-inline" style="width:140px;">
<input type="number" name="price" value="0" step="0.01" min="0" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">标签</label>
<div class="layui-input-block">
<input type="text" name="tags" placeholder="逗号分隔,如 物联网,通信" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">封面图URL</label>
<div class="layui-input-block">
<input type="text" name="logo" placeholder="可选,应用封面图地址" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">简介</label>
<div class="layui-input-block">
<textarea name="description" placeholder="应用简介/功能描述" class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">更新日志</label>
<div class="layui-input-block">
<textarea name="changelog" placeholder="如:1.0.1 修复某某问题;1.0.0 首次发布" class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">最低框架版本</label>
<div class="layui-input-block">
<input type="text" name="require_framework" placeholder="如 1.2.0(留空表示不限)" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">插件包</label>
<div class="layui-input-block">
<input type="file" name="file" id="pkgFile" accept=".zip" required lay-verify="required" class="layui-input">
<div class="hint" id="pkgHint">仅支持 .zip,插件包根目录需含 info.php,将进行安全扫描</div>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="doSubmit">提交审核</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
</section>
<!-- 收益账单 -->
<section class="panel" id="panel-earn">
<h2 class="page-title">收益账单</h2>
<p class="page-sub">查看销售收益与结算情况</p>
<div class="stat-row">
<div class="stat-card"><div class="lbl">可提现余额</div><div class="num ok" id="eaBalance">¥0.00</div></div>
<div class="stat-card"><div class="lbl">冻结中(提现待打款)</div><div class="num warn" id="eaFrozen">¥0.00</div></div>
</div>
<div class="card">
<h3><span class="bar"></span>收益明细</h3>
<table class="dev-table">
<thead><tr><th>订单号</th><th>销售额</th><th>抽成</th><th>应得</th><th>状态</th><th>时间</th></tr></thead>
<tbody id="revBody"><tr><td colspan="6" class="empty">加载中…</td></tr></tbody>
</table>
</div>
</section>
<!-- 提现 -->
<section class="panel" id="panel-withdraw">
<h2 class="page-title">提现</h2>
<p class="page-sub">将可提现余额提现至你的收款账号</p>
<div class="card" style="max-width:560px;">
<form class="layui-form" id="withdrawForm">
<div class="layui-form-item">
<label class="layui-form-label">提现金额</label>
<div class="layui-input-inline" style="width:200px;">
<input type="number" name="amount" step="0.01" min="0.01" required lay-verify="required" placeholder="0.00" class="layui-input">
</div>
<div class="layui-form-mid layui-word-aux">不可超过可提现余额</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">提现渠道</label>
<div class="layui-input-inline" style="width:200px;">
<select name="channel">
<option value="alipay">支付宝</option>
<option value="wechat">微信</option>
<option value="bank">银行卡</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">收款账号</label>
<div class="layui-input-block">
<input type="text" name="account" required lay-verify="required" placeholder="支付宝/微信账号或银行卡号" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn layui-btn-normal" lay-submit lay-filter="doWithdraw">提交提现申请</button>
</div>
</div>
</form>
</div>
</section>
<!-- 账户设置 -->
<section class="panel" id="panel-account">
<h2 class="page-title">账户设置</h2>
<p class="page-sub">管理你的开发者身份与令牌</p>
<div class="card" style="max-width:620px;">
<div class="kv"><div class="k">开发者名称</div><div class="v" id="acName">-</div></div>
<div class="kv"><div class="k">邮箱</div><div class="v" id="acEmail">-</div></div>
<div class="kv"><div class="k">账户状态</div><div class="v" id="acStatus">-</div></div>
<div class="kv"><div class="k">开发者令牌</div><div class="v">
<div class="copybox" id="acToken">-</div>
<button class="layui-btn layui-btn-sm layui-btn-normal" id="copyToken">复制令牌</button>
<span class="hint">发布插件到官方市场时需使用此令牌(APPMARKET_DEV_TOKEN)。</span>
</div></div>
<div class="kv"><div class="k">操作</div><div class="v">
<a href="{:url('mine')}">丢失令牌?前往找回</a>
</div></div>
</div>
</section>
</div>
</div>
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="{$site.app}"></script>
<script>
layui.use(['form', 'jquery', 'layer', 'http'], function () {
var form = layui.form, $ = layui.jquery, layer = layui.layer, http = layui.http;
var TOKEN_KEY = 'appmall_dev_token';
var token = '';
// ---- 工具 ----
function getParam(name) {
var m = location.search.match(new RegExp('[?&]' + name + '=([^&]*)'));
return m ? decodeURIComponent(m[1]) : '';
}
function maskToken(t) {
if (!t || t.length < 12) return t;
return t.substr(0, 6) + '****' + t.substr(-4);
}
function money(v) { return '¥' + (parseFloat(v || 0)).toFixed(2); }
function fmtTime(ts) {
if (!ts) return '-';
var d = new Date(ts * 1000);
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2)
+ ' ' + ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2);
}
// Result 系接口成功 code=0Market::submit 成功 code=1
function req(url, data, successCode) {
successCode = (successCode === undefined) ? 0 : successCode;
return http.post(url, data).then(function (r) {
if (r && r.code === successCode) return r;
return Promise.reject((r && r.message) || (r && r.msg) || '请求失败');
});
}
// ---- 令牌闸门 ----
function showGate() { $('#gate').removeClass('hidden'); $('#console').addClass('hidden'); $('#topbar').addClass('hidden'); }
function enterConsole(tk) {
token = tk;
try { localStorage.setItem(TOKEN_KEY, tk); } catch (e) {}
$('#gate').addClass('hidden');
$('#topbar').removeClass('hidden');
$('#console').removeClass('hidden');
loadOverview();
loadAccount();
}
// 初始化:URL > localStorage
var initToken = getParam('token') || (function () { try { return localStorage.getItem(TOKEN_KEY) || ''; } catch (e) { return ''; } })();
if (!initToken) {
showGate();
} else {
// 校验令牌有效性
req('/appmall/developer/overview', { token: initToken }).then(function (r) {
enterConsole(initToken);
}).catch(function (msg) {
showGate();
layer.msg(msg || '令牌无效', { icon: 2 });
});
}
form.on('submit(gate)', function (data) {
var tk = (data.field.token || '').trim();
if (!tk) { layer.msg('请输入令牌', { icon: 2 }); return false; }
layer.load(1);
req('/appmall/developer/overview', { token: tk }).then(function () {
layer.closeAll();
enterConsole(tk);
}).catch(function (msg) {
layer.closeAll();
layer.msg(msg || '令牌无效', { icon: 2 });
});
return false;
});
$('#logoutBtn').on('click', function () {
try { localStorage.removeItem(TOKEN_KEY); } catch (e) {}
location.href = '{:url("index")}';
});
// ---- 左侧菜单 ----
$('#menu').on('click', 'li', function () {
var tab = $(this).data('tab');
$('#menu li').removeClass('active');
$(this).addClass('active');
$('.panel').removeClass('active');
$('#panel-' + tab).addClass('active');
if (tab === 'overview') loadOverview();
else if (tab === 'apps') loadApps();
else if (tab === 'earn') loadEarnings();
else if (tab === 'account') loadAccount();
});
$('[data-goto]').on('click', function () {
var tab = $(this).data('goto');
$('#menu li[data-tab="' + tab + '"]').click();
});
// ---- 概览 ----
function loadOverview() {
req('/appmall/developer/overview', { token: token }).then(function (r) {
var d = r.data || {};
$('#who').html('欢迎,<b>' + (d.username || '开发者') + '</b>');
$('#stApps').text(d.app_count || 0);
$('#stPending').text(d.pending_count || 0);
$('#stDl').text(d.total_downloads || 0);
$('#stBalance').text(money(d.balance));
var rows = d.recent || [];
if (!rows.length) {
$('#recentBody').html('<tr><td colspan="4" class="empty">暂无提交记录</td></tr>');
return;
}
var html = '';
rows.forEach(function (it) {
var st = subStatus(it.status);
html += '<tr><td class="app-name">' + esc(it.title) + typeTag(it.type) + '</td><td>' + esc(it.version) + '</td><td>' + st + '</td><td>' + fmtTime(it.update_at) + '</td></tr>';
});
$('#recentBody').html(html);
}).catch(function (msg) { layer.msg(msg, { icon: 2 }); });
}
// ---- 我的应用 ----
function loadApps() {
req('/appmall/developer/apps', { token: token }).then(function (r) {
var d = r.data || {};
var listed = d.listed || [], subs = d.submissions || [];
if (!listed.length) {
$('#listedBody').html('<tr><td colspan="7" class="empty">你还没有已上架的应用</td></tr>');
} else {
var h = '';
listed.forEach(function (it) {
var price = (parseFloat(it.price) > 0) ? money(it.price) : '<span class="tag free">免费</span>';
var st = it.status == 1
? '<span class="tag up">上架中</span>'
: '<span class="tag down">已下架</span>';
h += '<tr>'
+ '<td class="app-name">' + esc(it.title) + typeTag(it.type) + '<div class="app-meta">' + esc(it.name) + (it.category ? ' · ' + esc(it.category) : '') + '</div></td>'
+ '<td>' + esc(it.version) + '</td>'
+ '<td>' + price + '</td>'
+ '<td>' + (it.download_count || 0) + '</td>'
+ '<td>' + st + '</td>'
+ '<td>' + fmtTime(it.update_at) + '</td>'
+ '<td><a class="act-link" data-act="price" data-name="' + esc(it.name) + '" data-price="' + (it.price || 0) + '">改价</a></td></tr>';
});
$('#listedBody').html(h);
}
if (!subs.length) {
$('#subBody').html('<tr><td colspan="5" class="empty">暂无提交记录</td></tr>');
} else {
var h2 = '';
subs.forEach(function (it) {
var st = subStatus(it.status);
var note = it.status == 2 ? (it.reject_reason ? esc(it.reject_reason) : '已驳回,可重新提交') : '-';
h2 += '<tr>'
+ '<td class="app-name">' + esc(it.title) + typeTag(it.type) + '<div class="app-meta">' + esc(it.name) + '</div></td>'
+ '<td>' + esc(it.version) + '</td>'
+ '<td>' + st + '</td>'
+ '<td>' + note + '</td>'
+ '<td>' + fmtTime(it.update_at) + '</td></tr>';
});
$('#subBody').html(h2);
}
}).catch(function (msg) { layer.msg(msg, { icon: 2 }); });
}
// 改价(无需重新打包/重新提审,同步该应用全部版本,市场即时生效)
$('#listedBody').on('click', '[data-act="price"]', function () {
var name = $(this).data('name');
var cur = $(this).data('price') || 0;
layer.prompt({ title: '修改「' + name + '」价格(0 为免费,同步全部版本)', value: String(cur) }, function (val, idx) {
var p = parseFloat(val);
if (isNaN(p) || p < 0) { layer.msg('请输入不小于 0 的价格', { icon: 2 }); return; }
req('/appmall/developer/price', { token: token, name: name, price: p }).then(function (r) {
layer.close(idx);
layer.msg(r.message || '价格已更新', { icon: 1 });
loadApps();
}).catch(function (msg) { layer.msg(msg, { icon: 2 }); });
});
});
function subStatus(s) {
if (s == 0) return '<span class="tag wait">待审核</span>';
if (s == 1) return '<span class="tag up">已通过</span>';
if (s == 2) return '<span class="tag reject">已驳回</span>';
return '<span class="tag down">未知</span>';
}
function typeTag(t) {
return t === 'template'
? ' <span class="tag t-tpl">模板</span>'
: ' <span class="tag t-addon">插件</span>';
}
// 提交类型切换:动态改写包要求与标识提示
$('#appType').on('change', function () {
var isTpl = $(this).val() === 'template';
$('#submitSub').text(isTpl
? '上传模板 zip 包(须含 templates/名称/template.json),提交后等待官方审核'
: '上传插件 zip 包(根目录需含 info.php),提交后等待官方审核');
$('#pkgHint').text(isTpl
? '仅支持 .zip,模板包须含 templates/<名称>/template.json,将进行安全扫描(模板包禁含 PHP 文件)'
: '仅支持 .zip,插件包根目录需含 info.php,将进行安全扫描');
$('#nameHint').text(isTpl ? '与 template.json 中标识(文件夹名)一致' : '与 info.php 中 name 一致');
});
// ---- 提交应用 ----
form.on('submit(doSubmit)', function () {
var file = document.getElementById('pkgFile').files[0];
if (!file) { layer.msg('请选择插件 zip 包', { icon: 2 }); return false; }
var fd = new FormData();
fd.append('file', file);
fd.append('token', token);
fd.append('type', $('select[name="type"]').val());
fd.append('name', $('input[name="name"]').val());
fd.append('version', $('input[name="version"]').val());
fd.append('title', $('input[name="title"]').val());
fd.append('author', $('input[name="author"]').val());
fd.append('category', $('select[name="category"]').val());
fd.append('tags', $('input[name="tags"]').val());
fd.append('price', $('input[name="price"]').val());
fd.append('logo', $('input[name="logo"]').val());
fd.append('description', $('textarea[name="description"]').val());
fd.append('changelog', $('textarea[name="changelog"]').val());
fd.append('require_framework', $('input[name="require_framework"]').val());
layer.load(1);
http.upload('/appmall/api/addon/submit', file, {
token: token,
type: $('select[name="type"]').val(),
name: $('input[name="name"]').val(),
version: $('input[name="version"]').val(),
title: $('input[name="title"]').val(),
author: $('input[name="author"]').val(),
category: $('select[name="category"]').val(),
tags: $('input[name="tags"]').val(),
price: $('input[name="price"]').val(),
logo: $('input[name="logo"]').val(),
description: $('textarea[name="description"]').val()
}).then(function (r) {
layer.closeAll();
if (r && r.code === 1) {
layer.msg(r.msg || '提交成功,等待审核', { icon: 1 });
$('#submitForm')[0].reset();
form.render();
} else {
layer.msg((r && (r.msg || r.message)) || '提交失败', { icon: 2 });
}
}).catch(function () { layer.closeAll(); layer.msg('请求失败', { icon: 2 }); });
return false;
});
// ---- 收益 ----
function loadEarnings() {
req('/appmall/developer/earnings', { token: token }).then(function (r) {
var d = r.data || {};
$('#eaBalance').text(money(d.balance));
$('#eaFrozen').text(money(d.frozen_balance));
var list = d.revenues || [];
if (!list.length) {
$('#revBody').html('<tr><td colspan="6" class="empty">暂无收益记录</td></tr>');
return;
}
var html = '';
list.forEach(function (it) {
var st = it.status == 1 ? '<span style="color:#18a058;">已结算</span>' : '<span style="color:#d48806;">待结算</span>';
html += '<tr>'
+ '<td>' + (it.order_id || '-') + '</td>'
+ '<td>' + money(it.amount) + '</td>'
+ '<td>' + money(it.commission) + '</td>'
+ '<td>' + money(it.income) + '</td>'
+ '<td>' + st + '</td>'
+ '<td>' + fmtTime(it.create_at) + '</td></tr>';
});
$('#revBody').html(html);
}).catch(function (msg) { layer.msg(msg, { icon: 2 }); });
}
// ---- 提现 ----
form.on('submit(doWithdraw)', function (data) {
data.field.token = token;
var amt = parseFloat(data.field.amount);
if (!(amt > 0)) { layer.msg('请输入正确的提现金额', { icon: 2 }); return false; }
req('/appmall/developer/withdraw', data.field).then(function (r) {
layer.msg(r.message || '提交成功', { icon: 1 });
$('#withdrawForm')[0].reset(); form.render();
loadEarnings();
}).catch(function (msg) { layer.msg(msg, { icon: 2 }); });
return false;
});
// ---- 账户 ----
function loadAccount() {
req('/appmall/developer/overview', { token: token }).then(function (r) {
var d = r.data || {};
$('#acName').text(d.username || '-');
$('#acEmail').text(d.email || '-');
$('#acStatus').html('<span class="tag up">正常</span>');
$('#acToken').text(token);
}).catch(function () {});
}
$('#copyToken').on('click', function () {
var t = document.getElementById('acToken').innerText;
var ta = $('<textarea>').val(t).appendTo('body').select();
try { document.execCommand('copy'); layer.msg('已复制', { icon: 1 }); } catch (e) { layer.msg('复制失败', { icon: 2 }); }
ta.remove();
});
function esc(s) {
return $('<div>').text(s == null ? '' : s).html();
}
});
</script>
</body>
</html>
@@ -0,0 +1,95 @@
<!--
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-07-21 13:40:36
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-07-26 15:36:01
* @Description:
* @FilePath: \ywxapp_dev\addon\appmall\view\frontend\developer\mine.html
* @CustomString: Copyright (c) 2026 YwxApp
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>查询我的令牌 - YwxApp 应用市场</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/assets/layui/css/layui.css" rel="stylesheet">
<link href="/assets/ywxapp/css/wxapp.css" rel="stylesheet">
<style>
body { background: #f2f3f5; margin: 0; }
.topbar { height: 52px; background: #fff; border-bottom: 1px solid #ebedf0; display: flex; align-items: center; padding: 0 22px; }
.topbar .brand { font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.topbar .brand .logo { width: 24px; height: 24px; border-radius: 6px; background: linear-gradient(135deg, #1677ff, #36cfc9); color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 14px; }
.topbar .brand small { font-weight: 400; color: #8a919f; font-size: 13px; }
.topbar .spacer { flex: 1; }
.dev-box { max-width: 460px; margin: 48px auto; background: #fff; padding: 30px 35px; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
.dev-box h2 { text-align: center; margin-bottom: 6px; }
.dev-tip { text-align: center; color: #999; margin-bottom: 22px; font-size: 13px; }
.dev-token { background: #f4f4f4; padding: 12px; border-radius: 6px; word-break: break-all; font-family: monospace; }
</style>
</head>
<body>
<div class="topbar">
<div class="brand"><span class="logo">Y</span>YwxApp 应用市场<small>开发者中心</small></div>
<div class="spacer"></div>
<a href="{:url('console')}" class="layui-btn layui-btn-sm layui-btn-normal">进入开发者中心</a>
</div>
<div class="dev-box">
<h2>查询我的令牌</h2>
<p class="dev-tip">输入注册邮箱,取回你的开发者令牌(若已激活将重新邮件发送)</p>
<form class="layui-form" id="lookupForm">
<div class="layui-form-item">
<label class="layui-form-label">邮箱</label>
<div class="layui-input-block">
<input type="email" name="email" required lay-verify="email" placeholder="注册时填写的邮箱" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn layui-btn-fluid" lay-submit lay-filter="lookup">查询令牌</button>
</div>
</div>
</form>
<div id="result" style="display:none; margin-top:20px;">
<div id="resultMsg" class="layui-bg-blue" style="padding:10px 12px;border-radius:6px;margin-bottom:12px;"></div>
<div id="tokenWrap" style="display:none;">
<label class="layui-form-label" style="text-align:left;padding-left:0;">你的开发者令牌</label>
<div class="dev-token" id="tokenVal"></div>
<button class="layui-btn layui-btn-sm layui-btn-normal" id="copyBtn" style="margin-top:10px;">复制令牌</button>
</div>
</div>
<p class="dev-tip" style="margin-top:18px;"><a href="{:url('index')}">返回开发者注册</a></p>
</div>
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="{$site.app}"></script>
<script>
layui.use(['form', 'jquery', 'layer', 'http'], function () {
var form = layui.form, $ = layui.jquery, layer = layui.layer, http = layui.http;
form.on('submit(lookup)', function (data) {
http.post('{:url("/appmall/developer/lookup")}', data.field).then(function (r) {
$('#result').show();
$('#resultMsg').text(r.message || '查询完成');
if (r.code === 0 && r.data && r.data.token) {
$('#tokenWrap').show();
$('#tokenVal').text(r.data.token);
} else {
$('#tokenWrap').hide();
}
if (r.code !== 0) { layer.msg(r.message || '查询失败', { icon: 2 }); }
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
return false;
});
$('#copyBtn').on('click', function () {
var t = document.getElementById('tokenVal').innerText;
var dummy = $('<textarea>').val(t).appendTo('body').select();
document.execCommand('copy');
dummy.remove();
layer.msg('已复制', { icon: 1 });
});
});
</script>
</body>
</html>
@@ -0,0 +1,109 @@
<!--
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-07-21 13:40:29
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-07-28 12:32:45
* @Description:
* @FilePath: \ywxapp_dev\addon\appmall\view\frontend\developer\register.html
* @CustomString: Copyright (c) 2026 YwxApp
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>开发者注册 - YwxApp 应用市场</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/assets/layui/css/layui.css" rel="stylesheet">
<link href="/assets/ywxapp/css/wxapp.css" rel="stylesheet">
<style>
body { background: #f2f3f5; margin: 0; }
.topbar { height: 52px; background: #fff; border-bottom: 1px solid #ebedf0; display: flex; align-items: center; padding: 0 22px; }
.topbar .brand { font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.topbar .brand .logo { width: 24px; height: 24px; border-radius: 6px; background: linear-gradient(135deg, #1677ff, #36cfc9); color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 14px; }
.topbar .brand small { font-weight: 400; color: #8a919f; font-size: 13px; }
.topbar .spacer { flex: 1; }
.dev-box { max-width: 460px; margin: 48px auto; background: #fff; padding: 30px 35px; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
.dev-box h2 { text-align: center; margin-bottom: 6px; }
.dev-tip { text-align: center; color: #999; margin-bottom: 22px; font-size: 13px; }
.dev-token { background: #f4f4f4; padding: 12px; border-radius: 6px; word-break: break-all; font-family: monospace; }
.closed { text-align:center; color:#c00; padding:30px 0; }
</style>
</head>
<body>
<div class="topbar">
<div class="brand"><span class="logo">Y</span>YwxApp 应用市场<small>开发者中心</small></div>
<div class="spacer"></div>
<a href="{:url('/appmall/Developer/console')}" class="layui-btn layui-btn-sm layui-btn-normal">进入开发者中心</a>
</div>
<div class="dev-box">
<h2>开发者入驻</h2>
<p class="dev-tip">注册后获取开发者令牌,用于在你自己的控制台「发布到官方市场」</p>
{if $register_open}
<form class="layui-form" id="regForm">
<div class="layui-form-item">
<label class="layui-form-label">开发者名称</label>
<div class="layui-input-block">
<input type="text" name="username" required lay-verify="required" placeholder="如:星河工作室" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">邮箱</label>
<div class="layui-input-block">
<input type="email" name="email" required lay-verify="email" placeholder="用于接收令牌/审核通知" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn layui-btn-fluid" lay-submit lay-filter="reg">提交申请</button>
</div>
</div>
<p class="dev-tip" style="margin:0;">
{if $need_approval}提交后需运营审核,令牌将通过邮件下发{else/}提交后令牌立即通过邮件下发{/if}
</p>
</form>
<div id="result" style="display:none; margin-top:20px;">
<div id="resultMsg" class="layui-bg-blue" style="padding:10px 12px;border-radius:6px;margin-bottom:12px;"></div>
<div id="tokenWrap" style="display:none;">
<label class="layui-form-label" style="text-align:left;padding-left:0;">你的开发者令牌</label>
<div class="dev-token" id="tokenVal"></div>
<button class="layui-btn layui-btn-sm layui-btn-normal" id="copyBtn" style="margin-top:10px;">复制令牌</button>
</div>
</div>
<p class="dev-tip" style="margin-top:18px;"><a href="{:url('mine')}">已有账号?查询我的令牌</a></p>
{else/}
<div class="closed">当前未开放自助注册,请联系运营获取开发者令牌。</div>
{/if}
</div>
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="{$site.app}"></script>
<script>
layui.use(['form', 'jquery', 'layer', 'http'], function () {
var form = layui.form, $ = layui.jquery, layer = layui.layer, http = layui.http;
form.on('submit(reg)', function (data) {
http.post('{:url("/appmall/developer/save")}', data.field).then(function (r) {
$('#result').show();
$('#resultMsg').text(r.message || '提交完成');
if (r.code === 0 && r.data && r.data.token) {
$('#tokenWrap').show();
$('#tokenVal').text(r.data.token);
} else {
$('#tokenWrap').hide();
}
if (r.code !== 0) { layer.msg(r.message || '提交失败', { icon: 2 }); }
}).catch(function () { layer.msg('请求失败', { icon: 2 }); });
return false;
});
$('#copyBtn').on('click', function () {
var t = document.getElementById('tokenVal').innerText;
var dummy = $('<textarea>').val(t).appendTo('body').select();
document.execCommand('copy');
dummy.remove();
layer.msg('已复制', { icon: 1 });
});
});
</script>
</body>
</html>
+1 -1
View File
@@ -15,7 +15,7 @@ use ywxapp\AddonBase;
/**
* 文章CMS插件启动类
*/
class Addon extends addon
class Addon extends AddonBase
{
// 插件基本信息
public $info = [
+1 -1
View File
@@ -6,7 +6,7 @@ return [
'description' => '完整文章类 CMS 插件:文章、分类、标签、评论,前台套用 layuiSimpleNews 模板',
'status' => 1,
'author' => 'ywxapp',
'version' => '1.0.0',
'version' => '1.0.1',
'type' => 1,
'state' => 0,
'install_time' => 1786348115,
+4 -8
View File
@@ -1,11 +1,8 @@
{
"backend": [
{
"name": "articles",
"title": "文章CMS",
"icon": "layui-icon layui-icon-list",
"sublist": [
{
"name": "article/index",
"title": "文章列表",
@@ -31,7 +28,6 @@
"title": "评论管理",
"route": "articles/backend/comment/index"
}
]
}
]
}
+1 -1
View File
@@ -51,7 +51,7 @@ class Category extends BackendBase
}
$owners = [];
if ($uids) {
$owners = \think\facade\Db::name('member')
$owners = \think\facade\Db::name('member_user')
->whereIn('uid', array_unique($uids))
->column('nickname', 'uid');
}
+1 -1
View File
@@ -6,7 +6,7 @@ return [
'intro' => '',
'author' => '',
'website' => '',
'version' => '1.0.2',
'version' => '1.0.3',
'state' => 1,
'url' => '/blog',
'license' => '',
+73
View File
@@ -0,0 +1,73 @@
<?php
declare (strict_types = 1);
namespace addon\demo;
use ywxapp\library\Menu;
use ywxapp\AddonBase;
use think\Request;
class Addon extends addon
{
/**
* 插件安装方法
* @return bool
*/
public function install()
{
$menu = [];
Menu::create($menu);
return true;
}
/**
* 插件卸载方法
* @return bool
*/
public function uninstall()
{
Menu::delete('demo');
return true;
}
/**
* 插件启用方法
*/
public function enable()
{
Menu::enable('demo');
}
/**
* 插件禁用方法
*/
public function disable()
{
Menu::disable('demo');
}
/**
* 插件升级方法(覆盖升级时的数据/配置迁移)
* @param string $currentVersion 已安装版本号
*/
public function upgrade($currentVersion = '')
{
return true;
}
/**
* 前端菜单方法
*/
public function frontMenu(){}
/**
* 会员菜单方法
*/
public function memberMenu(){}
/**
* 后台菜单方法
*/
public function backMenu(){}
}
+2
View File
@@ -0,0 +1,2 @@
<?php
// 这是系统自动生成的公共文件
+3
View File
@@ -0,0 +1,3 @@
<?php
// 插件配置项(后台「配置」表单数据源)
return [];
+107
View File
@@ -0,0 +1,107 @@
<?php
declare (strict_types = 1);
namespace addon\demo\controller;
use think\Request;
class Index extends \ywxapp\controller\FrontendBase
{
/**
* Summary of needLogin
* @var array
*/
protected $noNeedLogin = ['*'];
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{}
/**
* 显示资源列表
*
* @return \think\Response
*/
public function index()
{
$data = \ywxapp\model\BackendPower::find(1);
echo $data->name;
return "这是一个addon\demo 插件应用控制器addon\demo\controller";
}
/**
* 显示创建资源表单页.
*
* @return \think\Response
*/
public function create()
{
//
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function save(Request $request)
{
//
}
/**
* 显示指定的资源
*
* @param int $id
* @return \think\Response
*/
public function read($id)
{
//
}
/**
* 显示编辑资源表单页.
*
* @param int $id
* @return \think\Response
*/
public function edit($id = null)
{
//
}
/**
* 保存更新的资源
*
* @param \think\Request $request
* @param int $id
* @return \think\Response
*/
public function update(Request $request, $id)
{
//
}
/**
* 删除指定资源
*
* @param int $id
* @return \think\Response
*/
public function delete($id)
{
//
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare (strict_types = 1);
namespace addon\demo\controller\api;
use think\Request;
class Index extends \ywxapp\controller\ApiBase
{
/**
* Summary of needLogin
* @var array
*/
protected $noNeedLogin = ['*'];
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{}
/**
* 显示资源列表
*
* @return \think\Response
*/
public function index()
{
return "这是一个addon\demo 插件应用控制器addon\demo\controller\api";
}
/**
* 显示创建资源表单页.
*
* @return \think\Response
*/
public function create()
{
//
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function save(Request $request)
{
//
}
/**
* 显示指定的资源
*
* @param int $id
* @return \think\Response
*/
public function read($id)
{
//
}
/**
* 显示编辑资源表单页.
*
* @param int $id
* @return \think\Response
*/
public function edit($id = null)
{
//
}
/**
* 保存更新的资源
*
* @param \think\Request $request
* @param int $id
* @return \think\Response
*/
public function update(Request $request, $id)
{
//
}
/**
* 删除指定资源
*
* @param int $id
* @return \think\Response
*/
public function delete($id)
{
//
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare (strict_types = 1);
namespace addon\demo\controller\backend;
use think\Request;
class Index extends \ywxapp\controller\BackendBase
{
/**
* Summary of needLogin
* @var array
*/
protected $noNeedLogin = ['*'];
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{}
/**
* 显示资源列表
*
* @return \think\Response
*/
public function index()
{
return "这是一个addon\demo 插件应用控制器addon\demo\controller\backend";
}
/**
* 显示创建资源表单页.
*
* @return \think\Response
*/
public function create()
{
//
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function save(Request $request)
{
//
}
/**
* 显示指定的资源
*
* @param int $id
* @return \think\Response
*/
public function read($id)
{
//
}
/**
* 显示编辑资源表单页.
*
* @param int $id
* @return \think\Response
*/
public function edit($id = null)
{
//
}
/**
* 保存更新的资源
*
* @param \think\Request $request
* @param int $id
* @return \think\Response
*/
public function update(Request $request, $id)
{
//
}
/**
* 删除指定资源
*
* @param int $id
* @return \think\Response
*/
public function delete($id)
{
//
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare (strict_types = 1);
namespace addon\demo\controller\member;
use think\Request;
class Index extends \ywxapp\controller\MemberBase
{
/**
* Summary of needLogin
* @var array
*/
protected $noNeedLogin = ['*'];
/**
* Summary of needRight
* @var array
*/
protected $noNeedVerify = ['*'];
/**
* 控制器初始化 _initialize
* @return void
*/
protected function initialize()
{}
/**
* 显示资源列表
*
* @return \think\Response
*/
public function index()
{
return "这是一个addon\demo 插件应用控制器addon\demo\controller\member";
}
/**
* 显示创建资源表单页.
*
* @return \think\Response
*/
public function create()
{
//
}
/**
* 保存新建的资源
*
* @param \think\Request $request
* @return \think\Response
*/
public function save(Request $request)
{
//
}
/**
* 显示指定的资源
*
* @param int $id
* @return \think\Response
*/
public function read($id)
{
//
}
/**
* 显示编辑资源表单页.
*
* @param int $id
* @return \think\Response
*/
public function edit($id = null)
{
//
}
/**
* 保存更新的资源
*
* @param \think\Request $request
* @param int $id
* @return \think\Response
*/
public function update(Request $request, $id)
{
//
}
/**
* 删除指定资源
*
* @param int $id
* @return \think\Response
*/
public function delete($id)
{
//
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
//这是demo应用的配置文件
return [
'name' => 'demo',
'title' => '',
'intro' => '',
'author' => '',
'website' => '',
'version' => '1.0.0',
'state' => 1,
'url' => '/demo',
'license' => '',
'licenseto' => 0,
'config' => [],
'events' => [
// 事件绑定,
'bind' => [],
// 事件监听
'listen' => [],
// 事件订阅
'subscribe' => [],
],
'middleware' => [
// 别名 => 中间件类
'alias' => [],
// 中间件优先级,越靠前优先级越高
'priority' => [],
],
'services' => [],
];
+5
View File
@@ -0,0 +1,5 @@
{
"frontend": [],
"member": [],
"backend": []
}
+24
View File
@@ -0,0 +1,24 @@
<?php
use think\facade\Route;
// 公共前缀 /demo,同时设置默认命名空间为 addon\demo\controller
// 后台 → addon\demo\controller\backend\Index
Route::group('backend', function () {
Route::get('index/index', 'index/index')->name('demo_backend_index');
})->namespace('addon\demo\controller\backend');
// 用户中心 → addon\demo\controller\member\Index
Route::group('member', function () {
Route::get('index/index', 'index/index')->name('demo_member_index');
})->namespace('addon\demo\controller\member');
// API → apaddonp\demo\controller\api\Index
Route::group('api', function () {
Route::get('index/index', 'index/index')->name('demo_api_index');
})->namespace('addon\demo\controller\api');
// 前台 → addon\demo\controller\Index(不加子分组)
Route::get('index/index', 'index/index')->name('demo_index');
+10 -1
View File
@@ -1,4 +1,13 @@
<?php
/*
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-08-01 23:18:56
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-08-17 00:31:35
* @Description:
* @FilePath: \ywxapp_dev\addon\docs\Addon.php
* @CustomString: Copyright (c) 2026 YwxApp
*/
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
@@ -22,7 +31,7 @@ use ywxapp\AddonBase;
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Addon extends addon
class Addon extends AddonBase
{
/**
* 安装钩子
+1 -1
View File
@@ -6,7 +6,7 @@ return [
'intro' => '多项目 / 多版本在线文档系统,支持 UEditor 可视化编辑、Word 与 Markdown 导入、三栏阅读与全文搜索',
'author' => 'ywxapp',
'website' => 'https://www.ywxapp.cn',
'version' => '1.0.1',
'version' => '1.0.2',
'state' => 1,
'url' => '/docs',
'license' => '',
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace addon\download;
use think\facade\Db;
use ywxapp\AddonBase;
use ywxapp\model\BaseModel;
/**
* 下载站插件
*
* 安装由框架自动导入 install.sql 建表(wxapp_download_category / resource);
* 卸载按统一前缀清理全部表;升级时确保表存在(install.sql 幂等建表)。
*/
class Addon extends AddonBase
{
public function install(): bool
{
return true;
}
/**
* 卸载钩子:清理本插件全部表(与 install.sql 表名前缀严格对齐)。
*/
public function uninstall(): bool
{
$prefix = 'wxapp_download_';
$tables = [
'category',
'resource',
];
foreach ($tables as $t) {
try {
Db::execute("DROP TABLE IF EXISTS `{$prefix}{$t}`");
} catch (\Exception $e) {
// 忽略
}
}
return true;
}
/**
* 升级时确保全部表存在(读 install.sqlCREATE TABLE IF NOT EXISTS 幂等建表)。
* 单一事实源 = install.sql,避免复制 DDL 导致漂移。
*/
private function ensureTablesFromInstallSql(): void
{
$sqlFile = __DIR__ . DIRECTORY_SEPARATOR . 'install.sql';
if (!is_file($sqlFile)) {
return;
}
$content = (string) file_get_contents($sqlFile);
$content = preg_replace('/--.*|\/\*[\s\S]*?\*\//', '', $content);
$stmts = array_filter(
array_map('trim', explode(';', $content)),
function ($s) {
return strlen($s) > 5 && preg_match('/^CREATE\s+TABLE/i', $s);
}
);
foreach ($stmts as $sql) {
if (preg_match('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([\w]+)`?/i', $sql, $m)) {
BaseModel::ensureTable($m[1], $sql);
}
}
}
public function upgrade($currentVersion = ''): bool
{
$this->ensureTablesFromInstallSql();
return true;
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
// 下载站插件默认配置(与 install.sql 无关,仅作为 readConfig 第一层默认值)。
// 后台「插件配置」保存会写入 addon_config 表并覆盖此处。
return [
'title' => '下载',
'page_size' => 20,
'local_path' => '/static/download/files/',
'need_audit' => 1,
'show_hot' => 1,
'show_new' => 1,
'beian' => '',
'copyright' => '© 下载站',
];
+201
View File
@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace addon\download\controller;
use think\facade\Config;
use think\facade\Lang;
use think\facade\Cookie;
use think\Response;
use ywxapp\controller\FrontendBase;
use addon\download\model\Category;
use addon\download\model\Resource;
use addon\download\service\DownloadService;
class Index extends FrontendBase
{
protected $noNeedLogin = ['*'];
protected $noNeedVerify = ['*'];
public function initialize()
{
// 多语言:支持 ?lang=zh-cn|en-us 切换并写 cookie;插件级 lang 显式加载兜底
$lang = $this->request->param('lang', '');
if (in_array($lang, ['zh-cn', 'en-us'], true)) {
Lang::setLangSet($lang);
Cookie::set('download_lang', $lang);
} elseif ($cookie = Cookie::get('download_lang')) {
Lang::setLangSet($cookie);
}
// 固定页面文案注入模板
$this->view->assign([
'site_name' => lang('site_name'),
'all_category_text' => lang('all_category'),
'hot_text' => lang('hot'),
'latest_text' => lang('latest'),
'list_text' => lang('list'),
'detail_text' => lang('detail'),
'search_text' => lang('search'),
'search_placeholder' => lang('search_placeholder'),
'download_btn_text' => lang('download_btn'),
]);
// 插件前台接入「全站动态布局」:视图只写正文,由核心 frontend 的
// common/layout.html 包裹全站 header / footer(与开发文档约定一致)。
// 注意:layout_name 必须带 .html 扩展名,否则 think-template 的
// parseTemplateFile() 会把无扩展名的绝对路径里的盘符冒号(D:)当成
// 模板分隔符替换成当前 viewPath,导致「模板文件不存在」。
$this->view->config([
'layout_on' => true,
'layout_name' => $this->app->getRootPath() . 'app' . DIRECTORY_SEPARATOR
. 'frontend' . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR
. 'common' . DIRECTORY_SEPARATOR . 'layout.html',
]);
}
/**
* 读取本插件配置(运行时由 AppInit 注入 config('download')
*/
protected function pluginConfig(): array
{
return Config::get('download', []);
}
/**
* 首页:分类导航 + 热门/最新(按 config 开关)
*/
public function index()
{
$cfg = $this->pluginConfig();
$categories = Category::with(['resources' => function ($q) {
$q->order('downloads', 'desc')->limit(8);
}])
->where('pid', 0)
->where('status', 1)
->order('sort', 'desc')
->select();
$hot = [];
$news = [];
if (!empty($cfg['show_hot'])) {
$hot = Resource::scope('visible')->order('downloads', 'desc')->limit(10)->select();
}
if (!empty($cfg['show_new'])) {
$news = Resource::scope('visible')->order('id', 'desc')->limit(10)->select();
}
$this->view->assign('categories', $categories);
$this->view->assign('hot', $hot);
$this->view->assign('news', $news);
$this->view->assign('config', $cfg);
return $this->view->fetch();
}
/**
* 分类列表页
*/
public function list()
{
$cid = $this->request->param('cid/d', 0);
$page = $this->request->param('page/d', 1);
$cfg = $this->pluginConfig();
$size = (int)($cfg['page_size'] ?? 20);
$cat = $cid ? Category::find($cid) : null;
if ($cid && !$cat) {
$this->error('分类不存在');
}
$list = Resource::scope('visible');
if ($cid) {
$list = $list->where('cid', $cid);
}
$list = $list->order('downloads', 'desc')
->paginate(['page' => $page, 'list_rows' => $size]);
$this->view->assign('cat', $cat);
$this->view->assign('list', $list);
$this->view->assign('pager', $list->render());
$this->view->assign('config', $cfg);
return $this->view->fetch();
}
/**
* 详情页
*/
public function detail()
{
$id = $this->request->param('id/d', 0);
$resource = Resource::scope('visible')->find($id);
if (!$resource) {
$this->error('资源不存在或已下架');
}
// 查看计数(防刷交给 service 内的简单锁,此处仅点击+1)
DownloadService::incClicks($id);
$cat = $resource['cid'] ? Category::find($resource['cid']) : null;
$this->view->assign('resource', $resource);
$this->view->assign('cat', $cat);
$this->view->assign('config', $this->pluginConfig());
return $this->view->fetch();
}
/**
* 搜索
*/
public function search()
{
$q = $this->request->param('q', $this->request->param('kw', ''));
$page = $this->request->param('page/d', 1);
$cfg = $this->pluginConfig();
$size = (int)($cfg['page_size'] ?? 20);
$list = Resource::scope('visible');
if ($q) {
$list = $list->whereLike('title', "%{$q}%");
}
$list = $list->order('downloads', 'desc')
->paginate(['page' => $page, 'list_rows' => $size]);
$this->view->assign('keyword', $q);
$this->view->assign('list', $list);
$this->view->assign('pager', $list->render());
$this->view->assign('config', $cfg);
return $this->view->fetch();
}
/**
* 触发下载:外链 302 跳转 / 本地文件流式输出
*/
public function down()
{
$id = $this->request->param('id/d', 0);
$ip = $this->request->ip();
try {
$ret = DownloadService::dispatch($id, $ip);
} catch (\think\Exception $e) {
$this->error($e->getMessage());
return;
}
if ($ret['type'] === 'redirect') {
return redirect($ret['url']);
}
// 本地文件流式输出(避免大文件占用内存)
$path = $ret['path'];
$name = ($ret['name'] ?? 'download') . '.' . pathinfo($path, PATHINFO_EXTENSION);
return Response::create()->data(file_get_contents($path))->header([
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . rawurlencode($name) . '"',
'Content-Length' => filesize($path),
]);
}
}
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace addon\download\controller\backend;
use think\exception\ValidateException;
use think\facade\Db;
use addon\download\model\Category as CategoryModel;
use addon\download\model\Resource;
use ywxapp\controller\BackendBase;
class Category extends BackendBase
{
protected $noNeedVerify = ['*'];
protected function initialize()
{
$this->model = new CategoryModel();
}
public function index()
{
if ($this->request->isAjax()) {
$title = $this->request->param('title', '');
$all = $this->model->order('sort', 'desc')->order('id', 'asc')->select()->toArray();
//$tree = CategoryModel::toNestedTree($all);
// 关键字过滤:命中节点保留,并保留其祖先链
// if ($title) {
// $tree = $this->filterTree($tree, $title);
// }
$this->result->success($all);
}
return $this->view->fetch('category/index');
}
/**
* 按标题关键字过滤树,保留命中节点及其祖先链
*/
protected function filterTree(array $tree, string $keyword): array
{
$result = [];
foreach ($tree as $node) {
$children = !empty($node['children']) ? $this->filterTree($node['children'], $keyword) : [];
$hit = stripos($node['title'], $keyword) !== false;
if ($hit || !empty($children)) {
$node['children'] = $children;
$result[] = $node;
}
}
return $result;
}
public function create()
{
if ($this->request->isAjax()) {
$data = CategoryModel::cateTree($this->model->select()->toArray());
$this->result->success(['data' => $data]);
}
}
public function save()
{
if ($this->request->isPost()) {
$params = $this->request->post();
$this->validateSave($params);
Db::startTrans();
try {
$this->model->save($params);
Db::commit();
$this->result->success($this->model, '保存成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('保存失败: ' . $e->getMessage());
}
}
}
public function edit($id = null)
{
$id = $this->request->param('id');
$model = $this->model->find($id);
if (!$model) {
$this->result->error('数据不存在');
}
if ($this->request->isAjax()) {
$tree = CategoryModel::cateTree($this->model->select()->toArray());
$this->result->success(['power' => $tree, 'info' => $model]);
}
}
public function update()
{
$id = $this->request->param('id');
if ($this->request->isAjax() && $this->request->isPut()) {
$params = $this->request->param();
$this->validateSave($params);
Db::startTrans();
try {
$model = $this->model->find($id);
if (!$model) {
throw new ValidateException('数据不存在');
}
$model->save($params);
Db::commit();
$this->result->success($model, '更新成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('更新失败: ' . $e->getMessage());
}
}
}
public function delete()
{
if ($this->request->isAjax() && $this->request->isDelete()) {
$ids = $this->request->param('ids', '');
$force = $this->request->param('force/d', 0);
if (empty($ids)) {
$this->result->error('请选择要删除的数据');
}
$idArr = array_filter(array_map('intval', explode(',', $ids)));
try {
Db::transaction(function () use ($idArr, $force) {
foreach ($idArr as $id) {
$this->deleteCascade($id, (bool) $force);
}
});
$this->result->success();
} catch (\think\exception\HttpResponseException $e) {
throw $e;
} catch (\Throwable $th) {
$this->result->error('删除失败: ' . $th->getMessage());
}
}
}
/**
* 递归级联删除:force=true 时先删子分类下的资源与子分类,再删自身
*/
protected function deleteCascade($id, bool $force): void
{
$model = $this->model->find($id);
if (!$model) {
return;
}
if ($force) {
Resource::where('cid', $id)->delete();
$children = $this->model->where('pid', $id)->select();
foreach ($children as $child) {
$this->deleteCascade($child['id'], true);
}
}
// 非 force 时若仍有子分类/资源,onBeforeDelete 会抛出异常阻止删除
$model->delete();
}
protected function validateSave(array $params): void
{
if (empty($params['title'])) {
throw new ValidateException('分类名称不能为空');
}
}
}
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace addon\download\controller\backend;
use think\exception\ValidateException;
use think\facade\Db;
use addon\download\model\Resource as ResourceModel;
use addon\download\model\Category as CategoryModel;
use ywxapp\controller\BackendBase;
class Resource extends BackendBase
{
protected $noNeedVerify = ['*'];
protected function initialize()
{
$this->model = new ResourceModel();
}
public function index()
{
if ($this->request->isAjax()) {
$title = $this->request->param('title', '');
$cid = $this->request->param('cid/d', 0);
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$data = $this->model
->with(['category'])
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))
->when($cid, fn($q, $c) => $q->where('cid', $c))
->order('id', 'desc')
->paginate(['page' => $page, 'list_rows' => $limit]);
$this->result->setCount($data->total());
$this->result->success($data->items());
}
$categories = (new CategoryModel())
->where('status', 1)
->order('sort', 'desc')
->select();
$this->view->assign('categories', $categories);
return $this->view->fetch('resource/index');
}
public function create()
{
if ($this->request->isAjax()) {
$categories = (new CategoryModel())
->where('status', 1)
->order('sort', 'desc')
->select();
$this->result->success(['categories' => $categories]);
}
}
public function save()
{
if ($this->request->isPost()) {
$params = $this->request->post();
$this->validateSave($params);
Db::startTrans();
try {
$this->model->save($params);
Db::commit();
$this->result->success($this->model, '保存成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('保存失败: ' . $e->getMessage());
}
}
}
public function edit($id = null)
{
$id = $this->request->param('id');
$model = $this->model->find($id);
if (!$model) {
$this->result->error('数据不存在');
}
if ($this->request->isAjax()) {
$categories = (new CategoryModel())
->where('status', 1)
->order('sort', 'desc')
->select();
$this->result->success(['categories' => $categories, 'info' => $model]);
}
}
public function update()
{
$id = $this->request->param('id');
if ($this->request->isAjax() && $this->request->isPut()) {
$params = $this->request->param();
$this->validateSave($params);
Db::startTrans();
try {
$model = $this->model->find($id);
if (!$model) {
throw new ValidateException('数据不存在');
}
$model->save($params);
Db::commit();
$this->result->success($model, '更新成功');
} catch (\Exception $e) {
Db::rollback();
$this->result->error('更新失败: ' . $e->getMessage());
}
}
}
public function delete()
{
if ($this->request->isAjax() && $this->request->isDelete()) {
$ids = $this->request->param('ids', '');
if (empty($ids)) {
$this->result->error('请选择要删除的数据');
}
try {
Db::transaction(function () use ($ids) {
$this->model->destroy($ids);
});
$this->result->success();
} catch (\think\exception\HttpResponseException $e) {
throw $e;
} catch (\Throwable $th) {
$this->result->error('删除失败: ' . $th->getMessage());
}
}
}
protected function validateSave(array $params): void
{
if (empty($params['title'])) {
throw new ValidateException('资源标题不能为空');
}
if (empty($params['cid'])) {
throw new ValidateException('请选择分类');
}
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
return [
'name' => 'download',
'title' => '下载站',
'intro' => '资源下载站,支持分类管理、资源管理与下载统计。',
'author' => 'ywxapp',
'website' => 'https://github.com',
'version' => '1.0.0',
'state' => 1,
'url' => '/download',
'license' => '',
'licenseto' => 0,
'config' => [
['name' => 'page_size', 'title' => '列表每页数量', 'type' => 'number', 'value' => 20],
['name' => 'local_path', 'title' => '本地存储目录', 'type' => 'text', 'value' => '/static/download/files/'],
['name' => 'need_audit', 'title' => '用户投稿需审核', 'type' => 'switch', 'value' => 1],
['name' => 'show_hot', 'title' => '首页显示热门下载', 'type' => 'switch', 'value' => 1],
['name' => 'show_new', 'title' => '首页显示最新上传', 'type' => 'switch', 'value' => 1],
],
'events' => [
'bind' => [],
'listen' => [],
'subscribe' => [],
],
'middleware' => [
'alias' => [],
'priority' => [],
],
'services' => [],
'install_time' => 1787000000,
'update_time' => 1787000000,
];
+43
View File
@@ -0,0 +1,43 @@
-- 下载站插件安装脚本(前缀硬写 wxapp_download_,与 Addon.php 卸载清单一致)
-- 注意:种子文案禁 -- 注释整段;字符串内禁 \' ,用 '' 转义。
CREATE TABLE IF NOT EXISTS `wxapp_download_category` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`pid` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '父级ID0=一级',
`title` varchar(50) NOT NULL DEFAULT '' COMMENT '分类名',
`cover` varchar(255) NOT NULL DEFAULT '' COMMENT '图标/封面',
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序,越大越靠前',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=显示 0=隐藏',
`create_at` int(11) NOT NULL DEFAULT 0,
`update_at` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `pid` (`pid`),
KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='下载分类';
CREATE TABLE IF NOT EXISTS `wxapp_download_resource` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`cid` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '分类ID',
`title` varchar(150) NOT NULL DEFAULT '' COMMENT '资源标题',
`cover` varchar(255) NOT NULL DEFAULT '' COMMENT '封面图',
`intro` text COMMENT '简介',
`author` varchar(50) NOT NULL DEFAULT '' COMMENT '作者/出品方',
`version` varchar(30) NOT NULL DEFAULT '' COMMENT '版本号',
`file_size` bigint(20) NOT NULL DEFAULT 0 COMMENT '文件字节数',
`file_url` varchar(500) NOT NULL DEFAULT '' COMMENT '外链地址或本地相对路径',
`is_local` tinyint(1) NOT NULL DEFAULT 0 COMMENT '1=本地存储 0=外链',
`is_free` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=免费 0=收费',
`price` int(11) NOT NULL DEFAULT 0 COMMENT '所需积分/价格',
`clicks` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '查看次数',
`downloads` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '下载次数',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=正常 0=下架',
`is_audit` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=已审 0=待审',
`source` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=后台 1=用户投稿',
`create_at` int(11) NOT NULL DEFAULT 0,
`update_at` int(11) NOT NULL DEFAULT 0,
`delete_at` int(11) NOT NULL DEFAULT 0 COMMENT '软删除,0=未删',
PRIMARY KEY (`id`),
KEY `cid` (`cid`),
KEY `status` (`status`),
KEY `is_audit` (`is_audit`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='下载资源';
+32
View File
@@ -0,0 +1,32 @@
<?php
/*
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-08-18 16:46:29
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-08-18 17:25:53
* @Description:
* @FilePath: \ywxapp_dev\addon\download\lang\en-us.php
* @CustomString: Copyright (c) 2026 YwxApp
*/
// 下载站插件 - 英文语言包
// 键与 zh-cn.php 一一对应;未在中文包中列出的固定文案(如自由录入的资源名)
// 由 lang() 透传原文(中文),在此无需声明。
return [
// 站点 / 页面
'site_name' => 'Downloads',
'all_category' => 'All Categories',
'hot' => 'Popular',
'latest' => 'Latest',
'search' => 'Search',
'search_placeholder' => 'Search resources',
'detail' => 'Detail',
'list' => 'List',
'download_btn' => 'Download',
'no_cover' => 'No cover',
// 示例:固定分类名
'category.dev' => 'Dev Tools',
'category.design' => 'Design Assets',
];
+25
View File
@@ -0,0 +1,25 @@
<?php
// 下载站插件 - 简体中文语言包
// 约定:title 字段在数据库存默认语言(中文),lang() 对未定义键原样透传,
// 因此自由录入的资源/分类名无需在此列出;此处仅维护「固定页面文案」与
// 「需要英文翻译的固定分类名」的映射。
return [
// 站点 / 页面
'site_name' => '下载站',
'all_category' => '分类',
'hot' => '热门',
'latest' => '最新',
'search' => '搜索',
'search_placeholder' => '搜索资源名称',
'detail' => '详情',
'list' => '列表',
'download_btn' => '立即下载',
'no_cover' => '暂无封面',
// 示例:固定分类名(若想让英文站点显示英文分类名,在此给出映射;
// 自由录入的分类名 lang() 会原样透传中文)
'category.dev' => '开发工具',
'category.design' => '设计素材',
];
+18
View File
@@ -0,0 +1,18 @@
{
"backend": [
{
"name": "download",
"title": "下载站",
"icon": "fa fa-download",
"type": 1,
"sort": 60,
"status": 1,
"child": [
{ "name": "download/category", "title": "分类管理", "icon": "fa fa-list", "type": 2, "sort": 0, "route": "/download/backend/category/index" },
{ "name": "download/resource", "title": "资源管理", "icon": "fa fa-file-archive-o", "type": 2, "sort": 1, "route": "/download/backend/resource/index" }
]
}
],
"member": [],
"frontend": []
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare (strict_types = 1);
namespace addon\download\model;
use ywxapp\model\BaseModel;
class Category extends BaseModel
{
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'download_category',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
/**
* 子分类(pid 指向自身)
*/
public function children()
{
return $this->hasMany(self::class, 'pid', 'id')
->where('status', 1)
->order('sort', 'desc');
}
/**
* 分类下的资源
*/
public function resources()
{
return $this->hasMany(Resource::class, 'cid', 'id')
->where('status', 1)
->where('is_audit', 1);
}
/**
* 平铺分类列表转带前缀的下拉选项(供新增/编辑表单的上级分类 select 使用)
* 直接复用父类 BaseModel::cateTree($cate, $name='title', $lefthtml='|— ', $pid=0, $level=0)
* 返回的 title 自带层级前缀,无需在子类重复声明(会与父类签名冲突 fatal error)。
*/
/**
* 将平铺分类列表转换为嵌套树(供 layui.treeTable 使用)
* 返回形如 [['id'=>..,'children'=>[...]], ...]
*/
public static function toNestedTree(array $list): array
{
$map = [];
foreach ($list as &$item) {
$item['children'] = [];
$map[$item['id']] = &$item;
}
unset($item);
$tree = [];
foreach ($list as &$item) {
if (!empty($item['pid']) && isset($map[$item['pid']])) {
$map[$item['pid']]['children'][] = &$item;
} else {
$tree[] = &$item;
}
}
unset($item);
// 清理空的 children,保持返回结构干净
return self::cleanEmptyChildren($tree);
}
protected static function cleanEmptyChildren(array $tree): array
{
foreach ($tree as &$node) {
if (!empty($node['children'])) {
$node['children'] = self::cleanEmptyChildren($node['children']);
} else {
unset($node['children']);
}
}
return $tree;
}
/**
* 删除前保护:存在子分类或资源时禁止删除
*/
public static function onBeforeDelete($model)
{
$childCount = self::where('pid', $model->id)->count();
if ($childCount > 0) {
throw new \think\Exception('该分类下还存在 ' . $childCount . ' 个子分类,请先处理子分类');
}
$resCount = \addon\download\model\Resource::where('cid', $model->id)->count();
if ($resCount > 0) {
throw new \think\Exception('该分类下还存在 ' . $resCount . ' 个资源,请先移走或删除这些资源');
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare (strict_types = 1);
namespace addon\download\model;
use ywxapp\model\BaseModel;
class Resource extends BaseModel
{
protected $defaultSoftDelete = 0;
protected $deleteTime = 'delete_at';
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'download_resource',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
/**
* 所属分类
*/
public function category()
{
return $this->belongsTo(Category::class, 'cid', 'id');
}
/**
* 文件大小友好显示(字节 -> KB/MB/GB
*/
public function getSizeTextAttr($value, $data)
{
$size = (int)($data['file_size'] ?? 0);
if ($size <= 0) {
return '-';
}
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
while ($size >= 1024 && $i < count($units) - 1) {
$size /= 1024;
$i++;
}
return round($size, 2) . ' ' . $units[$i];
}
/**
* 列表查询作用域:仅正常且已审
*/
public function scopeVisible($query)
{
return $query->where('status', 1)->where('is_audit', 1);
}
/**
* 分类名称(依赖预载入的 category 关联)
*/
public function getCategoryTitleAttr($value, $data)
{
if (isset($this->category) && $this->category) {
return $this->category->title;
}
$cid = (int)($data['cid'] ?? 0);
if ($cid > 0) {
return (new Category())->where('id', $cid)->value('title') ?: '-';
}
return '-';
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | 本文件由 ywxapp/service/AppService::loadAddonRoutes() 在 boot 阶段 include
// | 并统一被外层 Route::group('download', ...) 包住,下方均写【相对规则】,
// | 最终自动加 /download 前缀:前台 -> /download/* ,后台 -> /download/backend/* 。
// +----------------------------------------------------------------------
use think\facade\Route;
// 前台页面 / 接口
Route::rule('index', 'Index/index');
Route::rule('list/:cid', 'Index/list');
Route::rule('detail/:id', 'Index/detail');
Route::rule('search', 'Index/search');
Route::rule('down/:id', 'Index/down'); // 触发下载(外链跳转 / 本地落盘)
// 后台管理路由(对应 controller/backend/ 下的控制器)
Route::group('backend', function () {
Route::rule('category', 'Category/index');
Route::rule('category/:action', 'Category/:action');
Route::rule('resource', 'Resource/index');
Route::rule('resource/:action', 'Resource/:action');
})->prefix('backend/');
+61
View File
@@ -0,0 +1,61 @@
-- 下载站测试数据(可直接前台展示:status=1 / is_audit=1
-- 前缀 wxapp_ 与 install.sql 保持一致。执行前如已存在测试数据先清空:
-- TRUNCATE `wxapp_download_category`; TRUNCATE `wxapp_download_resource`;
SET NAMES utf8mb4;
-- ===================== 一级分类 =====================
INSERT INTO `wxapp_download_category` (`id`, `pid`, `title`, `cover`, `sort`, `status`, `create_at`, `update_at`) VALUES
(1, 0, '办公软件', '📄', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(2, 0, '安全杀毒', '🛡️', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(3, 0, '图形图像', '🎨', 80, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(4, 0, '影音播放', '🎬', 70, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(5, 0, '开发工具', '💻', 60, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(6, 0, '游戏娱乐', '🎮', 50, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
-- ===================== 二级分类 =====================
INSERT INTO `wxapp_download_category` (`id`, `pid`, `title`, `cover`, `sort`, `status`, `create_at`, `update_at`) VALUES
(11, 1, '文档处理', '📝', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(12, 1, '表格计算', '📊', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(13, 2, '杀毒软件', '🦠', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(14, 2, '防火墙', '🔥', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(15, 3, '图像处理', '🖼️', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(16, 3, '矢量绘图', '✏️', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(17, 4, '视频播放', '📺', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(18, 4, '音乐播放', '🎵', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(19, 5, '编辑器', '⌨️', 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(20, 5, '数据库', '🗄️', 90, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
-- ===================== 资源(全部可见 status=1 / is_audit=1 =====================
INSERT INTO `wxapp_download_resource`
(`cid`, `title`, `cover`, `intro`, `author`, `version`, `file_size`, `file_url`, `is_local`, `is_free`, `price`, `clicks`, `downloads`, `status`, `is_audit`, `source`, `create_at`, `update_at`, `delete_at`) VALUES
-- 办公软件 / 文档处理
(11, '极速文档 2026 专业版', '📄', '轻量级文档处理工具,支持 Word/PDF 互转,启动快、占用低。', '极速软件', '2026.1.0', 88450390, 'https://pc.qq.com/', 0, 1, 0, 1320, 980, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(11, '云笔记 Markdown 编辑器', '📝', '支持双向链接与大纲视图的本地优先笔记软件。', '云栈科技', '3.4.2', 45208700, 'https://pc.qq.com/', 0, 1, 0, 880, 612, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(12, '表格大师 计算版', '📊', '海量数据秒级计算,自带数据透视与图表模板。', '数擎信息', '11.0', 120560000, 'https://pc.qq.com/', 0, 1, 0, 540, 330, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(12, '财务报表一键生成器', '💡', '内置 200+ 财务模板,快速生成合规报表。', '财通软件', '2.8.1', 30990000, 'https://pc.qq.com/', 0, 0, 50, 410, 188, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
-- 安全杀毒
(13, '护盾杀毒 免费版', '🦠', '云查杀引擎,体积仅 30MB,低内存占用。', '护盾实验室', '2026.0.3', 31457280, 'https://pc.qq.com/', 0, 1, 0, 2200, 1900, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(13, '木马专杀工具箱', '🧰', '针对顽固木马与流氓插件的专杀合集。', '净网团队', '5.2', 15800300, 'https://pc.qq.com/', 0, 1, 0, 960, 720, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(14, '个人防火墙 极简版', '🔥', '仅允许白名单程序联网,杜绝后台偷跑流量。', '安域科技', '1.9.7', 9870000, 'https://pc.qq.com/', 0, 1, 0, 320, 150, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
-- 图形图像
(15, '美图秀秀 电脑版', '🖼️', '一键美颜、抠图、拼图,海量素材免费下载。', '美图公司', '2026.2', 156000000, 'https://pc.qq.com/', 0, 1, 0, 5300, 4700, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(15, '光影魔术手', '🌅', '照片后期调色利器,批量处理更高效。', '光影工作室', '4.5.1', 68000000, 'https://pc.qq.com/', 0, 1, 0, 1200, 880, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(16, '矢量绘图画板', '✏️', '类似 Illustrator 的开源矢量绘图工具。', '开源社区', '1.2.0', 42000000, 'https://pc.qq.com/', 0, 1, 0, 700, 430, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
-- 影音播放
(17, '全能影音播放器', '📺', '支持 4K/HDR,几乎通吃所有视频格式。', '全能影音', '9.3.0', 88000000, 'https://pc.qq.com/', 0, 1, 0, 6100, 5400, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(17, '本地视频剪辑', '🎞️', '轻量剪辑,导出无水印,适合短视频创作。', '剪映轻量版', '3.0.1', 210000000, 'https://pc.qq.com/', 0, 1, 0, 3400, 2600, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(18, '高保真音乐播放器', '🎵', '支持无损 FLAC/APE,歌词自动匹配。', '声海科技', '7.1.4', 39000000, 'https://pc.qq.com/', 0, 1, 0, 1900, 1500, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
-- 开发工具
(19, '代码编辑器 Pro', '⌨️', '智能补全、远程开发、多光标编辑,插件丰富。', '码云开源', '4.12.0', 95000000, 'https://pc.qq.com/', 0, 1, 0, 2800, 2100, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(19, '终端利器 Tabby', '🖥️', '跨平台现代终端,支持 SSH/SFTP 与主题美化。', 'Tabby 社区', '1.0.205', 125000000, 'https://pc.qq.com/', 0, 1, 0, 1100, 760, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(20, '数据库管理工具', '🗄️', '同时连接 MySQL/PostgreSQL/SQLite,可视化建模。', '数据方舟', '6.4.2', 73000000, 'https://pc.qq.com/', 0, 0, 30, 640, 290, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
-- 游戏娱乐
(6, '休闲益智合集', '🎮', '100 款单机小游戏打包,离线即玩。', '乐玩工作室', '2026.春节版', 540000000, 'https://pc.qq.com/', 0, 1, 0, 4200, 3800, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(6, '模拟器大厅', '🕹️', '集成多平台复古游戏模拟器,手柄即插即用。', '怀旧游戏社', '2.1.0', 88000000, 'https://pc.qq.com/', 0, 1, 0, 1500, 970, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0);
-- ===================== 少量隐藏/待审数据(用于验证筛选) =====================
INSERT INTO `wxapp_download_resource`
(`cid`, `title`, `cover`, `intro`, `author`, `version`, `file_size`, `file_url`, `is_local`, `is_free`, `price`, `clicks`, `downloads`, `status`, `is_audit`, `source`, `create_at`, `update_at`, `delete_at`) VALUES
(11, '(下架)旧版文档工具', '📄', '仅用于测试下架不展示。', '极速软件', '2020.0', 40000000, 'https://pc.qq.com/', 0, 1, 0, 100, 50, 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0),
(13, '(待审)杀毒内测版', '🦠', '仅用于测试待审不展示。', '护盾实验室', '2027.beta', 33000000, 'https://pc.qq.com/', 0, 1, 0, 20, 5, 1, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0);
@@ -0,0 +1,66 @@
<?php
declare (strict_types = 1);
namespace addon\download\service;
use think\facade\Cache;
use think\facade\Db;
use addon\download\model\Resource;
/**
* 下载核心服务:计数 + 防盗链 + 本地/外链分发。
*/
class DownloadService
{
/**
* 触发下载:返回 ['type'=>'redirect','url'=>...] 或 ['type'=>'file','path'=>...]
* @param int $id 资源ID
* @param string $clientIp 客户端IP(用于防刷)
* @return array
* @throws \think\Exception
*/
public static function dispatch(int $id, string $clientIp): array
{
$resource = Resource::find($id);
if (!$resource || $resource->status != 1 || $resource->is_audit != 1) {
throw new \think\Exception('资源不存在或已下架');
}
// 防刷:同 IP + 同资源 5 秒内不重复计数
$lockKey = 'dl_lock_' . md5($clientIp . '_' . $id);
if (!Cache::get($lockKey)) {
Db::name('download_resource')
->where('id', $id)
->inc('downloads', 1)
->update();
Cache::set($lockKey, 1, 5);
}
if ((int)$resource->is_local === 1) {
// 本地文件:返回服务器绝对路径,由控制器做流式输出
$root = app()->getRootPath() . 'public';
$path = $root . $resource->file_url;
if (!is_file($path)) {
throw new \think\Exception('文件不存在');
}
return ['type' => 'file', 'path' => $path, 'name' => $resource->title];
}
if (empty($resource->file_url)) {
throw new \think\Exception('下载地址为空');
}
return ['type' => 'redirect', 'url' => $resource->file_url];
}
/**
* 查看计数(详情页调用)
*/
public static function incClicks(int $id): void
{
Db::name('download_resource')
->where('id', $id)
->inc('clicks', 1)
->update();
}
}
@@ -0,0 +1,368 @@
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header"> </div>
<div class="layui-card-body">
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
</div>
</div>
</div>
<script type="text/html" id="tableBarTpl">
<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm layui-btn-primary" title="新建分类" lay-event="dataCreate" data-perm="download:add"> <i class="layui-icon layui-icon-add-1"></i> </a>
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除分类" lay-event="dataDelete" data-perm="download:delete"><i class="layui-icon layui-icon-delete"></i> </a>
<a class="layui-btn layui-btn-sm layui-btn-primary" title="分类回收站" lay-event="dataRecybin" data-perm="download:recyclebin"><i class="layui-icon layui-icon-home"></i> </a>
</div>
</script>
<script type="text/html" id="dataBarTpl">
<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm layui-btn-primary" title="编辑分类" lay-event="update" data-perm="download:edit"><i class="layui-icon layui-icon-edit"></i> </a>
<a class="layui-btn layui-btn-sm layui-btn-primary" title="添加子分类" lay-event="create" data-perm="download:add"><i class="layui-icon layui-icon-add-1"></i> </a>
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除分类" lay-event="delete" data-perm="download:delete"><i class="layui-icon layui-icon-delete"></i> </a>
</div>
</script>
<!-- 添加/编辑分类表单模板 -->
<script type="text/html" id="dataFormTpl">
<form class="layui-form layui-form-pane" lay-filter="wxapp-form" id="wxapp-form">
<input type="hidden" name="id" value="{{= d.id || '' }}">
<input type="hidden" name="pid" value="{{= d.pid || 0 }}">
<div class="layui-form-item">
<label class="layui-form-label">分类名称</label>
<div class="layui-input-block">
<input type="text" name="title" required lay-verify="required" placeholder="请输入分类名称" value="{{ d.title || '' }}" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">图标</label>
<div class="layui-input-block">
<input type="text" name="cover" id="iconPicker" placeholder="支持 emoji 或图片URL" value="{{ d.cover || '' }}" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">排序</label>
<div class="layui-input-block">
<input type="number" name="sort" value="{{ d.sort || 50 }}" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<input type="checkbox" name="status" lay-skin="switch" lay-text="启用|禁用" {{ d.status != 0 ? 'checked' : (!d.id ? 'checked' : '') }}>
</div>
</div>
<div class="layui-form-item layui-hide">
<button class="layui-btn" lay-submit="" lay-filter="wxapp-form-submit" id="wxapp-form-submit">提交</button>
</div>
</form>
</script>
<!-- 状态开关 -->
<script type="text/html" id="statusTpl">
<input type="checkbox" name="status" value="{{d.id}}" lay-skin="switch" lay-text="启用|禁用" lay-filter="statusSwitch" {{ d.status == 1 ? 'checked' : '' }}>
</script>
<script type="text/html" id="dataRecybinTpl">
<div class="layui-card">
<div class="layui-card-header"> </div>
<div class="layui-card-body">
<table class="layui-hide" id="dataRecybinTable" lay-filter="dataRecybinTable"></table>
</div>
</div>
</script>
<script type="text/html" id="dataRecybinBarTpl">
<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm layui-btn-primary" title="恢复数据" lay-event="restore"><i class="layui-icon layui-icon-edit"></i> </a>
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除分类" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
</div>
</script>
<script>
layui.use(['layer', 'http', 'auth'], function () {
var $ = layui.$;
var treeTable = layui.treeTable;
var form = layui.form;
var layer = layui.layer;
var laytpl = layui.laytpl;
var http = layui.http;
var auth = layui.auth;
var _savedPerms = (layui.data('backend').permission) || [];
auth.init(_savedPerms);
auth.setController('download');
treeTable.render({
elem: '#dataTable',
url: 'index',
parseData: function (res) {
return {
code: res.code === 0 ? 0 : 1,
data: res.data || [],
msg: res.message || ''
};
},
tree: {
customName: {
children: "children",
isParent: "is_parent",
name: "title",
id: "id",
pid: "pid",
icon: "cover"
},
data: { isSimpleData: true, rootPid: 0 },
view: {},
async: {},
callback: {}
},
height: 'full-100',
toolbar: '#tableBarTpl',
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
{ field: 'title', title: '分类名称', width: 220, fixed: 'left' },
{ field: 'cover', title: '图标', width: 100, templet: function (d) {
return d.cover ? '<span style="font-size:18px;">' + d.cover + '</span>' : '<span class="layui-badge-rim">无</span>';
} },
{ field: 'sort', title: '排序', width: 80, sort: true },
{ field: 'status', title: '状态', width: 96, align: 'center', templet: '#statusTpl' },
{ fixed: "right", title: "操作", width: 181, align: "center", toolbar: "#dataBarTpl" }
]],
page: true,
done: function (res, curr, count, origin) {
var view = $('#dataTable').next('.layui-table-view');
view.find('[data-perm]').each(function () {
var perm = $(this).attr('data-perm');
if (perm && !auth.has(perm)) { $(this).remove(); }
});
$('.layui-btn[data-perm]').each(function () {
var perm = $(this).attr('data-perm');
if (perm && !auth.has(perm)) { $(this).remove(); }
});
}
});
treeTable.on('toolbar(dataTable)', function (obj) {
var options = obj.config;
switch (obj.event) {
case 'dataCreate':
active.dataCreate({ pid: 0 });
break;
case 'dataDelete':
var checkStatus = treeTable.checkStatus('dataTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
active.dataDelete(ids);
break;
case 'dataRecybin':
active.dataRecybin();
break;
};
});
treeTable.on('tool(dataTable)', function (elem) {
var data = elem.data;
switch (elem.event) {
case 'update':
active.dataEdit(data);
break;
case 'create':
active.dataCreate({ pid: data.id });
break;
case 'delete':
active.dataDelete([data.id]);
break;
default:
break;
}
});
// 状态开关
form.on('switch(statusSwitch)', function (obj) {
var id = this.value;
var status = obj.elem.checked ? 1 : 0;
layer.confirm('确定要' + (status ? '启用' : '禁用') + '该分类吗?', function (index) {
layui.request.post('update', { id: id, status: status }).then(function (res) {
if (res.code === 0) {
layer.msg(res.msg, { icon: 1 });
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg, { icon: 2 });
obj.elem.checked = !obj.elem.checked;
form.render('checkbox');
}
});
layer.close(index);
}, function () {
obj.elem.checked = !obj.elem.checked;
form.render('checkbox');
});
});
var dataFromFun = function (data, callback, done) {
var formHtml = laytpl($('#dataFormTpl').html()).render(data || {});
layer.open({
title: data.id ? '编辑分类' : (data.pid ? '添加子分类' : '添加根分类'),
content: formHtml,
anim: "slideLeft",
offset: "r",
btnAlign: "l",
area: ['520px', '98%'],
shade: 0.1,
shadeClose: true,
btn: ['确定', '取消'],
success: function (layero, index) {
callback(layero, index);
form.render();
},
yes: function (index, layero) {
window.layui.form.on('submit(wxapp-form-submit)', function (elem) {
done(layero, index, elem);
layui.off('submit(wxapp-form)', 'from');
return false;
});
layero.contents().find("#wxapp-form-submit").trigger('click');
}
});
};
//事件
var active = {
dataCreate: function (data = {}) {
dataFromFun(data,
function (layero, index) {
},
function (layero, index, elem) {
var field = elem.field;
field.status = field.status ? 1 : 0;
http.post('save', field)
.then((res) => {
if (res.code === 0) {
layer.close(index);
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
}
);
},
dataEdit: function (data = {}) {
dataFromFun(data,
function (layero, index) {
},
function (layero, index, elem) {
var field = elem.field;
field.status = field.status ? 1 : 0;
http.put('update', field)
.then((res) => {
if (res.code === 0) {
layer.close(index);
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
}
);
},
dataDelete: function (ids, force = 0) {
if (ids.length === 0) {
return layer.msg('请选择数据');
}
layer.prompt({
formType: 1
, title: '敏感操作,请验证口令'
}, function (value, index) {
layer.close(index);
layer.confirm('确定删除吗?其下级分类与关联资源将一并删除,且不可恢复。', function (index) {
http.delete('delete', { ids: ids.join(','), force: force })
.then((res) => {
if (res.code === 0) {
layer.close(index);
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
layer.msg('已删除');
treeTable.reload('dataTable', {}, true);
});
});
},
dataRecybin: function (data = {}) {
var formHtml = laytpl($('#dataRecybinTpl').html()).render(data || {});
layer.open({
title: '分类回收站',
content: formHtml,
anim: "slideLeft",
offset: "r",
area: ['60%', '99%'],
shade: 0.1,
shadeClose: true,
success: function (layero, index) {
layui.table.render({
elem: '#dataRecybinTable',
url: 'recyclebin',
height: 'full-100',
toolbar: '#dataRecybinBarTpl',
defaultToolbar: [{
title: '批量删除数据',
layEvent: 'dataDelete',
icon: 'layui-icon-delete',
onClick: function (obj) {
var checkStatus = treeTable.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
console.log(ids);
}
}, 'filter', 'exports', 'print'],
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
{ field: 'title', title: '分类名称', width: 200, fixed: 'left' },
{
fixed: "right", title: "操作", width: 120, align: "center", templet: function (d) {
return `<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm" title="恢复数据" lay-event="restore" > <i class="layui-icon layui-icon-edit"></i> </a >
<a class="layui-btn layui-btn-sm" title="删除数据" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
</div >`;
}
}
]],
page: true,
done: function (res, curr, count, origin) {
layui.table.on('tool(dataRecybinTable)', function (elem) {
var data = elem.data;
switch (elem.event) {
case 'restore':
active.dataRestore([data.id]);
break;
case 'delete':
active.dataDelete([data.id], 1);
break;
}
});
layui.table.on('toolbar(dataRecybinTable)', function (elem) {
switch (elem.event) {
case 'dataDelete':
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let idss = checkData.map((item, index, array) => { return item.id; });
active.dataDelete(idss, 1);
break;
case 'dataRestore':
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
active.dataRestore(ids);
break;
};
});
}
});
}
});
},
dataRestore: function (ids) {
if (ids.length === 0) {
return layer.msg('请选择数据');
}
http.put('restore', { ids: ids.join(',') })
.then((res) => {
if (res.code === 0) {
layer.msg('恢复成功');
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '恢复失败');
}
});
}
};
});
</script>
@@ -0,0 +1,397 @@
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header"></div>
<div class="layui-card-body">
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
</div>
</div>
</div>
<!-- 表格顶部工具条 -->
<script type="text/html" id="tableBarTpl">
<div class="layui-btn-group">
<a
class="layui-btn layui-btn-sm layui-btn-primary"
title="新建资源"
lay-event="dataCreate"
data-perm="download:add">
<i class="layui-icon layui-icon-add-1"></i>
</a>
<a
class="layui-btn layui-btn-sm layui-btn-primary"
title="删除资源"
lay-event="dataDelete"
data-perm="download:delete"
><i class="layui-icon layui-icon-delete"></i>
</a>
<a
class="layui-btn layui-btn-sm layui-btn-primary"
title="资源回收站"
lay-event="dataRecybin"
data-perm="download:recyclebin"
><i class="layui-icon layui-icon-home"></i>
</a>
</div>
</script>
<!-- 表格数据工具条 -->
<script type="text/html" id="dataBarTpl">
<div class="layui-btn-group">
<a
class="layui-btn layui-btn-sm layui-btn-primary"
title="编辑资源"
lay-event="update"
data-perm="download:edit"
><i class="layui-icon layui-icon-edit"></i>
</a>
<a
class="layui-btn layui-btn-sm layui-btn-primary"
title="删除资源"
lay-event="delete"
data-perm="download:delete"
><i class="layui-icon layui-icon-delete"></i>
</a>
</div>
</script>
<!-- 表格回收站 -->
<script type="text/html" id="dataRecybinTpl">
<div class="layui-card">
<div class="layui-card-header"></div>
<div class="layui-card-body">
<table
class="layui-hide"
id="dataRecybinTable"
lay-filter="dataRecybinTable"></table>
</div>
</div>
</script>
<!-- 资源状态 -->
<script type="text/html" id="statusTpl">
{{# if(d.status == 1) { }}
<span class="layui-badge layui-bg-green">正常</span>
{{# } else { }}
<span class="layui-badge layui-bg-orange">下架</span>
{{# } }}
</script>
<!-- 免费/收费 -->
<script type="text/html" id="freeTpl">
{{# if(d.is_free == 1) { }}
<span class="layui-badge layui-bg-green">免费</span>
{{# } else { }}
<span class="layui-badge layui-bg-orange">收费</span>
{{# } }}
</script>
<!-- 添加/编辑资源表单 -->
<script type="text/html" id="dataFormTpl">
<form class="layui-form layui-form-pane" lay-filter="wxapp-form" id="wxapp-form">
<input type="hidden" name="id" value="{{d.id||''}}">
<div class="layui-form-item">
<label class="layui-form-label">资源标题</label>
<div class="layui-input-block">
<input type="text" name="title" required lay-verify="required" placeholder="请输入资源标题" autocomplete="off" class="layui-input" value="{{d.title||''}}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">所属分类</label>
<div class="layui-input-block">
<select name="cid" lay-verify="required">
{volist name="categories" id="c"}
<option value="{$c.id}" {{ d.cid==$c.id ? 'selected' : '' }}>{$c.title}</option>
{/volist}
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">作者</label>
<div class="layui-input-block">
<input type="text" name="author" placeholder="请输入作者" autocomplete="off" class="layui-input" value="{{d.author||''}}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">版本</label>
<div class="layui-input-block">
<input type="text" name="version" placeholder="如 1.0.0" autocomplete="off" class="layui-input" value="{{d.version||''}}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">下载地址</label>
<div class="layui-input-block">
<input type="text" name="file_url" placeholder="外链下载地址" autocomplete="off" class="layui-input" value="{{d.file_url||''}}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">本地存储</label>
<div class="layui-input-inline">
<input type="checkbox" name="is_local" lay-skin="switch" lay-text="本地|外链" {{ d.is_local==1 ? 'checked' : '' }} value="1">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">是否免费</label>
<div class="layui-input-inline">
<input type="checkbox" name="is_free" lay-skin="switch" lay-text="免费|收费" {{ d.is_free!=0 ? 'checked' : '' }} value="1">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline">
<input type="checkbox" name="status" lay-skin="switch" lay-text="正常|下架" {{ d.status!=0 ? 'checked' : '' }} value="1">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">简介</label>
<div class="layui-input-block">
<textarea name="intro" placeholder="资源简介" class="layui-textarea">{{d.intro||''}}</textarea>
</div>
</div>
<div class="layui-form-item layui-hide">
<button class="layui-btn" lay-submit="" lay-filter="wxapp-form-submit" id="wxapp-form-submit">提交</button>
</div>
</form>
</script>
<script>
layui.use(['http', 'auth'], function () {
var $ = layui.$, table = layui.table, form = layui.form, layer = layui.layer
, laytpl = layui.laytpl, http = layui.http
, auth = layui.auth;
var _savedPerms = (layui.data('backend').permission) || [];
auth.init(_savedPerms);
auth.setController('download');
var dataTable = table.render({
elem: '#dataTable'
, url: 'index' // 后端数据接口
, page: true
, limit: 15
, toolbar: '#tableBarTpl'
, cols: [[
{ type: 'checkbox', fixed: 'left' }
, { field: 'id', title: 'ID', width: 60, align: 'center', sort: true, fixed: 'left' }
, { field: 'title', title: '标题', minWidth: 200, align: 'left', fixed: 'left' }
, { field: 'category_title', title: '分类', width: 120, align: 'center' }
, { field: 'author', title: '作者', width: 120, align: 'center' }
, { field: 'version', title: '版本', width: 90, align: 'center' }
, { field: 'downloads', title: '下载数', width: 90, align: 'center', sort: true }
, { field: 'is_free', title: '类型', width: 80, align: 'center', templet: '#freeTpl' }
, { field: 'status', title: '状态', width: 90, align: 'center', templet: '#statusTpl' }
, { title: '操作', width: 120, align: 'left', toolbar: '#dataBarTpl', fixed: 'right', unresize: true }
]]
, done: function (res, curr, count) {
var view = $('#dataTable').next('.layui-table-view');
view.find('[data-perm]').each(function () {
var perm = $(this).attr('data-perm');
if (perm && !auth.has(perm)) { $(this).remove(); }
});
$('.layui-btn[data-perm]').each(function () {
var perm = $(this).attr('data-perm');
if (perm && !auth.has(perm)) { $(this).remove(); }
});
}
});
table.on('toolbar(dataTable)', function (elem) {
switch (elem.event) {
case 'dataCreate':
active.dataCreate({ });
break;
case 'dataDelete':
var checkStatus = table.checkStatus('dataTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
active.dataDelete(ids);
break;
case 'dataRecybin':
active.dataRecybin();
break;
};
});
table.on('tool(dataTable)', function (elem) {
var data = elem.data;
switch (elem.event) {
case 'update':
active.dataEdit(data);
break;
case 'delete':
active.dataDelete([data.id]);
break;
}
});
var dataFromFun = function (data, callback, done) {
var formHtml = laytpl($('#dataFormTpl').html()).render(data || {});
layer.open({
title: data.id ? '编辑资源' : '添加资源',
content: formHtml,
anim: "slideLeft",
offset: "r",
btnAlign: "l",
area: ['50% ', '99%'],
shade: 0.1,
shadeClose: true,
btn: ['确定', '取消'],
success: function (layero, index) {
callback(layero, index);
form.render();
},
yes: function (index, layero) {
window.layui.form.on('submit(wxapp-form-submit)', function (elem) {
done(layero, index, elem);
layui.off('submit(wxapp-form)', 'from');
return false;
});
layero.contents().find("#wxapp-form-submit").trigger('click');
}
});
};
//事件
var active = {
dataDelete: function (ids, force = 0) {
if (ids.length === 0) {
return layer.msg('请选择数据');
}
layer.prompt({
formType: 1
, title: '敏感操作,请验证口令'
}, function (value, index) {
layer.close(index);
layer.confirm('确定删除吗?', function (index) {
http.delete("delete", { ids: ids.join(','), force: force })
.then((res) => {
if (res.code === 0) {
layer.close(index);
table.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
});
});
},
dataCreate: function (data = {}) {
dataFromFun(data,
function (layero, index) { },
function (layero, index, elem) {
var field = elem.field;
http.post('save', field)
.then((res) => {
if (res.code === 0) {
layer.close(index);
table.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
}
);
},
dataEdit: function (data = {}) {
dataFromFun(data,
function (layero, index) { },
function (layero, index, elem) {
var field = elem.field;
http.put('update', field)
.then((res) => {
if (res.code === 0) {
layer.close(index);
table.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
}
);
},
dataRecybin: function (data = {}) {
var formHtml = laytpl($('#dataRecybinTpl').html()).render(data || {});
layer.open({
title: '资源回收站',
content: formHtml,
anim: "slideLeft",
offset: "r",
area: ['60%', '99%'],
shade: 0.1,
shadeClose: true,
success: function (layero, index) {
table.render({
elem: '#dataRecybinTable',
url: "recyclebin",
toolbar: '#tableBarTpl',
defaultToolbar: [{
title: '批量删除数据',
layEvent: 'dataDelete',
icon: 'layui-icon-delete',
onClick: function (obj) {
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
console.log(ids);
}
}, 'filter', 'exports', 'print'],
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
{ field: 'title', title: '标题', width: 200, fixed: 'left' },
{ field: 'category_title', title: '分类', width: 120, align: 'center' },
{ field: 'author', title: '作者', width: 120, align: 'center' },
{
fixed: "right", title: "操作", width: 120, align: "center", templet: function (d) {
return `<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm" title="恢复数据" lay-event="restore" > <i class="layui-icon layui-icon-edit"></i> </a >
<a class="layui-btn layui-btn-sm" title="删除数据" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
</div >`;
}
}
]],
page: true,
done: function () {
table.on('tool(dataRecybinTable)', function (elem) {
var data = elem.data;
switch (elem.event) {
case 'restore':
active.dataRestore([data.id]);
break;
case 'delete':
active.dataDelete([data.id], 1);
break;
}
});
table.on('toolbar(dataRecybinTable)', function (elem) {
switch (elem.event) {
case 'dataDelete':
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let idss = checkData.map((item, index, array) => { return item.id; });
active.dataDelete(idss, 1);
break;
case 'dataRestore':
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
active.dataRestore(ids);
break;
};
});
}
});
}
});
},
dataRestore: function (ids) {
if (ids.length === 0) {
return layer.msg('请选择数据');
}
http.put("restore", { ids: ids.join(',') })
.then((res) => {
if (res.code === 0) {
layer.msg('恢复成功');
table.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '恢复失败');
}
});
}
};
});
</script>
@@ -0,0 +1,35 @@
{block name="style"}<link rel="stylesheet" href="/static/download/css/download.css">{__inline__}{/block}
<div class="dl-wrap">
<div class="dl-crumb">
<a href="{:url('index/index')}">{$site_name}</a><span>/</span>
{present name="cat.title"}
<a href="{:url('index/list', ['cid'=>$cat.id])}">{$cat.title|lang}</a><span>/</span>
{/present}
<strong>{$resource.title|lang}</strong>
</div>
<div class="dl-detail">
<img class="dl-detail-cover" src="{$resource.cover|default='/static/download/img/default.svg'}" alt="{$resource.title|lang}">
<div class="dl-detail-info">
<h1 class="dl-detail-title">{$resource.title|lang}</h1>
<div class="dl-detail-row">大小:{$resource.size_text|default='-'}</div>
<div class="dl-detail-row">分类:{present name="cat.title"}<a href="{:url('index/list', ['cid'=>$cat.id])}">{$cat.title|lang}</a>{else/}—{/present}</div>
<div class="dl-detail-row">更新:{$resource.update_at|default='-'}</div>
<div class="dl-detail-row">下载:{$resource.downloads|default=0}</div>
<div class="dl-detail-row">
{if $resource.is_free == 1}<span class="dl-tag-free">免费</span>{else/}<span class="dl-tag-pay">付费</span>{/if}
</div>
<a class="dl-btn-down" href="{:url('index/down', ['id'=>$resource.id])}">{$download_btn_text}</a>
</div>
</div>
<div class="dl-detail-intro">
<h3>资源简介</h3>
<p>{$resource.intro|default='暂无简介'}</p>
</div>
<a class="dl-back" href="{:url('index/list', ['cid'=>$cat.id|default=0])}">← {$list_text}</a>
</div>
@@ -0,0 +1,77 @@
{block name="style"}<link rel="stylesheet" href="/static/download/css/download.css">{__inline__}{/block}
<div class="dl-wrap">
<section class="dl-hero">
<div>
<h2>{$site_name} · 海量资源 高速免费下载</h2>
<p>精选软件、源码与工具,安全无捆绑,下载更省心。</p>
<div class="slogan">一键下载 · 更新无插件 · 卸载无残留</div>
</div>
<a class="dl-hero-btn" href="{:url('index/list', ['cid'=>0])}">{$all_category_text}</a>
</section>
{notempty name="hot"}
<section class="dl-block">
<div class="dl-block-head">
<h2 class="dl-block-title">{$hot_text}</h2>
<span class="dl-refresh" onclick="location.href='{:url(\'index/index\')}'">换一换</span>
</div>
<ul class="dl-grid">
{volist name="hot" id="item"}
<li class="dl-card">
<a href="{:url('index/detail', ['id'=>$item.id])}">
<img class="dl-cover" src="{$item.cover|default='/static/download/img/default.svg'}" alt="{$item.title|lang}">
<span class="dl-name">{$item.title|lang}</span>
</a>
<p class="dl-desc">{$item.intro|default=''}</p>
<span class="dl-meta">{$item.size_text|default='-'}</span>
<a class="dl-card-btn" href="{:url('index/down', ['id'=>$item.id])}">{$download_btn_text}</a>
</li>
{/volist}
</ul>
</section>
{/notempty}
{notempty name="news"}
<section class="dl-block">
<div class="dl-block-head">
<h2 class="dl-block-title">{$latest_text}</h2>
<span class="dl-refresh" onclick="location.href='{:url(\'index/index\')}'">换一换</span>
</div>
<ul class="dl-grid">
{volist name="news" id="item"}
<li class="dl-card">
<a href="{:url('index/detail', ['id'=>$item.id])}">
<img class="dl-cover" src="{$item.cover|default='/static/download/img/default.svg'}" alt="{$item.title|lang}">
<span class="dl-name">{$item.title|lang}</span>
</a>
<p class="dl-desc">{$item.intro|default=''}</p>
<span class="dl-meta">{$item.size_text|default='-'}</span>
<a class="dl-card-btn" href="{:url('index/down', ['id'=>$item.id])}">{$download_btn_text}</a>
</li>
{/volist}
</ul>
</section>
{/notempty}
<section class="dl-block">
<div class="dl-block-head">
<h2 class="dl-block-title">{$all_category_text}</h2>
</div>
<div class="dl-cat-grid">
{volist name="categories" id="cat"}
<div class="dl-cat">
<h3><a href="{:url('index/list', ['cid'=>$cat.id])}">{$cat.title|lang}</a></h3>
<div class="dl-cat-list">
{volist name="cat.resources" id="r"}
<a href="{:url('index/detail', ['id'=>$r.id])}">{$r.title|lang}</a>
{/volist}
</div>
<a class="dl-cat-more" href="{:url('index/list', ['cid'=>$cat.id])}">{$all_category_text} </a>
</div>
{/volist}
</div>
</section>
</div>
@@ -0,0 +1,41 @@
{block name="style"}<link rel="stylesheet" href="/static/download/css/download.css">{__inline__}{/block}
<div class="dl-wrap">
<div class="dl-crumb">
<a href="{:url('index/index')}">{$site_name}</a><span>/</span>
{present name="cat.title"}
<a href="{:url('index/list', ['cid'=>$cat.id])}">{$cat.title|lang}</a>
{else/}
<strong>{$all_category_text}</strong>
{/present}
</div>
<form class="dl-searchbar" action="{:url('index/search')}" method="get">
<input type="text" name="kw" placeholder="{$search_placeholder}" value="{$keyword|default=''}">
<button type="submit">{$search_text}</button>
</form>
{notempty name="list"}
<ul class="dl-grid">
{volist name="list" id="item"}
<li class="dl-card">
<a href="{:url('index/detail', ['id'=>$item.id])}">
<img class="dl-cover" src="{$item.cover|default='/static/download/img/default.svg'}" alt="{$item.title|lang}">
<span class="dl-name">{$item.title|lang}</span>
</a>
<p class="dl-desc">{$item.intro|default=''}</p>
<span class="dl-meta">{$item.size_text|default='-'}</span>
<a class="dl-card-btn" href="{:url('index/down', ['id'=>$item.id])}">{$download_btn_text}</a>
</li>
{/volist}
</ul>
{notempty name="pager"}
<div class="dl-pager">{$pager|raw}</div>
{/notempty}
{else/}
<div class="dl-empty">{$empty_text|default='暂无资源'}</div>
{/notempty}
</div>
@@ -0,0 +1,42 @@
{block name="style"}<link rel="stylesheet" href="/static/download/css/download.css">{__inline__}{/block}
<div class="dl-wrap">
<div class="dl-crumb">
<a href="{:url('index/index')}">{$site_name}</a><span>/</span>
<strong>{$search_text}</strong>
</div>
<form class="dl-searchbar" action="{:url('index/search')}" method="get">
<input type="text" name="kw" placeholder="{$search_placeholder}" value="{$keyword|default=''}">
<button type="submit">{$search_text}</button>
</form>
{notempty name="list"}
<div class="dl-block" style="border:0;padding:0;background:transparent;">
<div class="dl-block-head">
<h2 class="dl-block-title">{$search_text}{$keyword|default=''}</h2>
</div>
</div>
<ul class="dl-grid">
{volist name="list" id="item"}
<li class="dl-card">
<a href="{:url('index/detail', ['id'=>$item.id])}">
<img class="dl-cover" src="{$item.cover|default='/static/download/img/default.svg'}" alt="{$item.title|lang}">
<span class="dl-name">{$item.title|lang}</span>
</a>
<p class="dl-desc">{$item.intro|default=''}</p>
<span class="dl-meta">{$item.size_text|default='-'}</span>
<a class="dl-card-btn" href="{:url('index/down', ['id'=>$item.id])}">{$download_btn_text}</a>
</li>
{/volist}
</ul>
{notempty name="pager"}
<div class="dl-pager">{$pager|raw}</div>
{/notempty}
{else/}
<div class="dl-empty">{$empty_text|default='未找到相关资源'}</div>
{/notempty}
</div>
+4 -4
View File
@@ -87,7 +87,7 @@ class Index extends ForumFrontend
$board = ForumBoard::find((int) $topic->board_id);
// 作者信息(复用会员表),带默认值避免模板内联查库
$author = Db::name('member')->where('id', $topic->user_id)
$author = Db::name('member_user')->where('id', $topic->user_id)
->field('id,nickname,avatar,intro')->find();
if (!$author) {
$author = ['id' => $topic->user_id, 'nickname' => '匿名', 'avatar' => '', 'intro' => ''];
@@ -97,7 +97,7 @@ class Index extends ForumFrontend
$uids = array_column($replies->toArray(), 'user_id');
$users = [];
if (!empty($uids)) {
$users = Db::name('member')->where('id', 'in', array_unique($uids))
$users = Db::name('member_user')->where('id', 'in', array_unique($uids))
->column('nickname,avatar', 'id');
}
$replyList = [];
@@ -313,7 +313,7 @@ class Index extends ForumFrontend
$uids = array_filter(array_unique($uids));
$users = [];
if (!empty($uids)) {
$users = Db::name('member')->where('id', 'in', $uids)
$users = Db::name('member_user')->where('id', 'in', $uids)
->column('nickname,avatar', 'id');
}
foreach ($collection as $t) {
@@ -331,7 +331,7 @@ class Index extends ForumFrontend
*/
public function user($id = 0)
{
$user = Db::name('member')->where('id', (int) $id)->field('id,nickname,avatar,intro')->find();
$user = Db::name('member_user')->where('id', (int) $id)->field('id,nickname,avatar,intro')->find();
if (!$user) {
$this->assign('msg', '用户不存在');
return $this->fetch('index/notfound');
+1 -1
View File
@@ -5,7 +5,7 @@ return [
'title' => '轻社区论坛',
'intro' => '基于 Fly-3.0 风格打造的轻量社区论坛:版块、发帖、回复、消息。',
'author' => 'ywxapp',
'version' => '1.0.2',
'version' => '1.0.3',
'state' => 1,
'type' => 1,
'install_time' => 1785604242,
+1 -1
View File
@@ -23,7 +23,7 @@ use ywxapp\model\BaseModel;
* favorites / favorite_shares / ads / apply / click_stats);
* 卸载时按统一前缀清理全部表;升级时经 callAddonHook('upgrade') 收敛表结构。
*/
class Addon extends addon
class Addon extends AddonBase
{
/**
* 安装钩子(框架自动导入 install.sql 后调用)。
+39 -20
View File
@@ -94,17 +94,16 @@ class Index extends FrontendBase
public function index()
{
$data = CategoryModel::with([
'links' => function ($query) {
$query->where('status', 1)->order('click_count', 'desc');
},
'children' => function ($query) {
$query->where('status', 1)->order('sort', 'desc')->with([
'links' => function ($q) {
$q->where('status', 1)->order('click_count', 'desc');
},
]);
$query->with(['subLinks' => function ($q) {
$q->limit(12)->order('click_count', 'desc');
}]);
},
])->where('status', 1)->order('sort', 'desc')->select();
'links' => function ($query) {
// 首页不按二级分组,直接展示该一级分类下的全部链接(含已归属二级的)
$query->where('status', 1)->order('click_count', 'desc')->limit(30);
},
])->where('pid', 0)->where('status', 1)->order('sort', 'desc')->select();
$hotspot = LinksModel::where('is_hot', 1)->where('status', 1)->limit(18)->order('click_count', 'desc')->select();
$links = LinksModel::where('is_recommend', 1)->where('status', 1)->limit(10)->order('sort', 'desc')->select();
@@ -449,19 +448,39 @@ class Index extends FrontendBase
*/
public function category($id = 0)
{
$data = CategoryModel::with([
'links' => function ($query) {
$query->where('status', 1)->order('click_count', 'desc');
},
$info = CategoryModel::with([
'children' => function ($query) {
$query->where('status', 1)->order('sort', 'desc')->with([
'links' => function ($q) {
$q->where('status', 1)->order('click_count', 'desc');
},
]);
$query->with(['subLinks' => function ($q) {
$q->order('click_count', 'desc');
}]);
},
])->find($id);
$this->view->assign('info', $data);
])->where('status', 1)->find($id);
if (!$info) {
return redirect('/haonav/index.html');
}
// 顶级分类且有子分类:按二级子分类分组展示
if ($info->pid == 0 && $info->children && $info->children->count() > 0) {
// 未细分二级的链接(sub_cid=0)归入「常用」分组,排在最后
$others = LinksModel::where('cid', $id)
->where('sub_cid', 0)
->where('status', 1)
->order('click_count', 'desc')
->select();
$this->view->assign('others', $others);
$this->view->assign('mode', 'group');
} else {
// 二级分类 / 叶子分类:直接列出该分类下的全部链接
$links = ($info->pid == 0)
? LinksModel::where('cid', $id)->where('status', 1)
: LinksModel::where('sub_cid', $id)->where('status', 1);
$links = $links->order('click_count', 'desc')->select();
$this->view->assign('links', $links);
$this->view->assign('mode', 'flat');
}
$this->view->assign('info', $info);
$this->assignSeo();
return $this->view->fetch();
}
+5 -4
View File
@@ -57,10 +57,11 @@ class Category extends HaonavBackend
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$data = $this->model->withAttr('cate')
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))
->paginate(['page' => $page, 'list_rows' => $limit]);
$this->result->setCount($data->total());
$this->result->success($data->items());
->when($title, fn($q, $t) => $q->whereLike('title', "%{$t}%"))->select();
// ->paginate(['page' => $page, 'list_rows' => $limit]);
// $this->result->setCount($data->total());
// $this->result->success($data->items());
$this->result->success($data);
}
return $this->view->fetch('category/index');
}
+2 -2
View File
@@ -6,7 +6,7 @@ return [
'intro' => '简洁实用的网址导航,支持分类管理、链接收录、热门/推荐与点击统计。',
'author' => 'ywxapp',
'website' => 'https://github.com',
'version' => '1.0.9',
'version' => '1.0.10',
'state' => 1,
'url' => '/haonav',
'license' => '',
@@ -34,6 +34,6 @@ return [
],
'services' => [
],
'install_time' => 1785818546,
'install_time' => 1786890830,
'update_time' => 1786365870,
];
+3218 -245
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -42,11 +42,21 @@ class Category extends BaseModel
}
/**
* 分类(二级及更深层次
* 二级分类(pid 指向自身
*/
public function children()
{
return $this->hasMany(self::class, 'pid', 'id');
return $this->hasMany(self::class, 'pid', 'id')->where('status', 1)->order('sort', 'desc');
}
/**
* 按二级分类分组的链接(首页/分类页用)
* 返回二级分类及其下属链接,sub_cid=0 的链接归入「常用」
*/
public function subLinks()
{
return $this->hasMany(Links::class, 'sub_cid', 'id')
->where('status', 1)->order('click_count', 'desc');
}
/**
+3 -2
View File
@@ -3,7 +3,7 @@
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-07-31 13:28:15
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-08-16 15:16:48
* @LastEditTime: 2026-08-20 12:27:18
* @Description:
* @FilePath: \ywxapp_dev\addon\haonav\route\app.php
* @CustomString: Copyright (c) 2026 YwxApp
@@ -84,11 +84,12 @@ Route::group('backend', function () {
Route::rule('links/:action', 'Links/:action');
Route::rule('category', 'Category/index') ;
Route::rule('category/index', 'Category/index') ;
Route::rule('category/update', 'Category/update') ;
Route::rule('configs', 'Configs/index');
Route::rule('configs/:action', 'Configs/:action');
Route::rule('ad', 'Ad/index');
Route::rule('ad/:action', 'Ad/:action');
Route::rule('apply', 'Apply/index');
Route::rule('apply/:action', 'Apply/:action');
})->layer('backend');;
})->prefix('backend/');;
+91 -254
View File
@@ -1,261 +1,98 @@
-- ============================================================
-- addon/haonav/upgrade.sql —— 分类体系重构迁移(一级 + 二级 + 常用网址)
-- 适用场景:已安装 haonav 插件的站点,数据库里还是旧的扁平分类(id 1~12)
-- 作用:清空旧的 haonav_category / haonav_links 种子数据,插入新的
-- 14 个一级 + 30 个二级分类 + 155 条常用网址(国内外混合)。
-- ⚠️ 注意:本脚本会 DELETE 掉 haonav_category 和 haonav_links 的全部数据
-- (含你此前手动添加的分类/网址)。执行前请先备份,或确认可接受重置种子。
-- haonav_config / haonav_ads / haonav_apply 等表不受影响。
-- 执行方式:在 MySQL 客户端用你的库前缀替换 __PREFIX__ 后执行,例如
-- sed 's/__PREFIX__/ywx_/' upgrade.sql | mysql -u用户 -p 数据库
-- 或通过后台「数据库」工具导入(注意前缀替换)。
-- addons/haonav/upgrade.sql
-- 用于已安装插件、但缺少新字段/配置的老库升级
-- 在数据库客户端执行本文件即可(重复执行安全:字段/数据已存在会自动跳过)。
-- ============================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- 1) 清空旧种子(按外键先清子表再清父表)
DELETE FROM `__PREFIX__haonav_links`;
DELETE FROM `__PREFIX__haonav_category`;
-- 1. 链接表新增死链检测字段
ALTER TABLE `__PREFIX__haonav_links`
ADD COLUMN `last_check_at` int DEFAULT NULL COMMENT '最后死链检测时间' AFTER `last_click_at`,
ADD COLUMN `status_code` int DEFAULT NULL COMMENT '最近一次HTTP状态码,NULL表示未检测' AFTER `last_check_at`;
-- 2) 插入新分类体系(一级 + 二级
INSERT INTO `__PREFIX__haonav_category` (`id`, `pid`, `title`, `icon`, `summary`, `keywords`, `description`, `sort`, `status`, `website_count`, `click_count`, `create_at`, `update_at`) VALUES
-- 一级分类
(1, 0, '常用搜索', '🔍', '', '', '搜索引擎与门户入口', 140, 1, 0, 0, NULL, NULL),
(2, 0, '视频娱乐', '📺', '', '', '影视、短视频与音乐平台', 130, 1, 0, 0, NULL, NULL),
(3, 0, '社交沟通', '👥', '', '', '综合与海外社交平台', 120, 1, 0, 0, NULL, NULL),
(4, 0, '新闻资讯', '📰', '', '', '门户新闻与科技资讯', 110, 1, 0, 0, NULL, NULL),
(5, 0, '开发编程', '💻', '', '', '代码托管、技术社区与云服务', 100, 1, 0, 0, NULL, NULL),
(6, 0, '学习教育', '🎓', '', '', '在线课程与资料文库', 90, 1, 0, 0, NULL, NULL),
(7, 0, '购物电商', '🛒', '', '', '综合电商与海淘二手', 80, 1, 0, 0, NULL, NULL),
(8, 0, '办公效率', '📁', '', '', '文档协作、邮箱、云盘与会议', 70, 1, 0, 0, NULL, NULL),
(9, 0, '设计创意', '🎨', '', '', '设计素材与在线设计工具', 60, 1, 0, 0, NULL, NULL),
(10, 0, '实用工具', '🧰', '', '', '生活服务与在线小工具', 50, 1, 0, 0, NULL, NULL),
(11, 0, '财经金融', '💰', '', '', '行情理财与支付银行', 40, 1, 0, 0, NULL, NULL),
(12, 0, '旅游出行', '✈️', '', '', '交通出行与住宿预订', 30, 1, 0, 0, NULL, NULL),
(13, 0, '医疗健康', '🩺', '', '', '在线问诊与健康科普', 20, 1, 0, 0, NULL, NULL),
(14, 0, '政务民生', '🏛️', '', '', '政务服务与生活缴费', 10, 1, 0, 0, NULL, NULL),
-- 二级分类(pid 指向一级)
(15, 1, '综合搜索', '🌐', '', '', '百度、Google 等综合搜索引擎', 140, 1, 0, 0, NULL, NULL),
(16, 1, '学术资源', '📖', '', '', '学术、图书与知识检索', 139, 1, 0, 0, NULL, NULL),
(21, 2, '长视频', '🎬', '', '', '爱奇艺、腾讯视频等长视频', 130, 1, 0, 0, NULL, NULL),
(22, 2, '短视频直播', '📱', '', '', '抖音、快手等短视频平台', 129, 1, 0, 0, NULL, NULL),
(23, 2, '音乐', '🎵', '', '', '在线音乐播放平台', 128, 1, 0, 0, NULL, NULL),
(31, 3, '综合社交', '💬', '', '', '微信、微博等国内社交', 120, 1, 0, 0, NULL, NULL),
(32, 3, '国际社交', '🌍', '', '', 'X、Telegram 等海外社交', 119, 1, 0, 0, NULL, NULL),
(41, 4, '综合门户', '🗞️', '', '', '新浪、网易等综合门户', 110, 1, 0, 0, NULL, NULL),
(42, 4, '科技资讯', '', '', '', '36氪、少数派等科技媒体', 109, 1, 0, 0, NULL, NULL),
(51, 5, '代码托管', '🔧', '', '', 'GitHub、Gitee 等代码平台', 100, 1, 0, 0, NULL, NULL),
(52, 5, '技术社区', '📝', '', '', 'CSDN、掘金等技术社区', 99, 1, 0, 0, NULL, NULL),
(53, 5, '云服务', '☁️', '', '', '阿里云、腾讯云等云服务', 98, 1, 0, 0, NULL, NULL),
(61, 6, '慕课学习', '📚', '', '', '慕课网、Coursera 等课程', 90, 1, 0, 0, NULL, NULL),
(62, 6, '资料文库', '📄', '', '', '百度文库、道客巴巴等', 89, 1, 0, 0, NULL, NULL),
(71, 7, '综合电商', '🛍️', '', '', '淘宝、京东等综合电商', 80, 1, 0, 0, NULL, NULL),
(72, 7, '海淘二手', '♻️', '', '', '亚马逊、闲鱼等海淘二手', 79, 1, 0, 0, NULL, NULL),
(81, 8, '文档协作', '📝', '', '', '腾讯文档、Notion 等协作', 70, 1, 0, 0, NULL, NULL),
(82, 8, '邮箱', '📧', '', '', 'QQ邮箱、Gmail 等邮箱', 69, 1, 0, 0, NULL, NULL),
(83, 8, '云盘', '💾', '', '', '百度网盘、OneDrive 等', 68, 1, 0, 0, NULL, NULL),
(84, 8, '会议办公', '🎥', '', '', '腾讯会议、钉钉等', 67, 1, 0, 0, NULL, NULL),
(91, 9, '设计素材', '🖼️', '', '', '千图网、Unsplash 等素材', 60, 1, 0, 0, NULL, NULL),
(92, 9, '设计工具', '🛠️', '', '', 'Figma、Canva 等设计工具', 59, 1, 0, 0, NULL, NULL),
(101, 10, '生活服务', '🚌', '', '', '快递、地图、出行等', 50, 1, 0, 0, NULL, NULL),
(102, 10, '在线工具', '⚙️', '', '', 'JSON、二维码等在线工具', 49, 1, 0, 0, NULL, NULL),
(111, 11, '行情理财', '📈', '', '', '东方财富、雪球等', 40, 1, 0, 0, NULL, NULL),
(112, 11, '支付银行', '🏦', '', '', '支付宝、网银等', 39, 1, 0, 0, NULL, NULL),
(121, 12, '交通出行', '🚄', '', '', '携程、12306 等出行', 30, 1, 0, 0, NULL, NULL),
(122, 12, '住宿预订', '🏨', '', '', '美团、Booking 等住宿', 29, 1, 0, 0, NULL, NULL),
(131, 13, '在线问诊', '💊', '', '', '丁香医生、好大夫等', 20, 1, 0, 0, NULL, NULL),
(132, 13, '健康科普', '🌿', '', '', '丁香园等健康科普', 19, 1, 0, 0, NULL, NULL),
(141, 14, '政务服务', '📋', '', '', '国家政务平台、粤省事等', 10, 1, 0, 0, NULL, NULL),
(142, 14, '生活缴费', '💡', '', '', '社保、水电网查询', 9, 1, 0, 0, NULL, NULL);
-- 2. 写入导航配置项(已存在则忽略
INSERT IGNORE INTO `__PREFIX__haonav_config` (`id`, `name`, `group`, `title`, `tip`, `type`, `value`, `content`, `rule`, `extend`, `setting`) VALUES
(1, 'default_engine', 'site', '默认搜索引擎', '站内搜索框默认使用的搜索引擎', 'string', 'baidu', 'baidu,bing,google,sogou,site', 'required', '', ''),
(2, 'enable_submit', 'site', '开启网址投稿', '关闭后前台投稿将直接上架,无需审核', 'bool', '1', '', '', '', ''),
(3, 'enable_hot_api', 'site', '启用外部热榜', '开启后将尝试聚合外部热搜(可能较慢,失败自动降级)', 'bool', '0', '', '', '', ''),
(4, 'check_token', 'task', '死链检测令牌', 'cron 调用 /haonav/task/checklinks?token= 时校验,留空则拒绝执行', 'string', '', '', '', '', '');
-- 3) 插入常用网址(国内外混合,cid 指向二级分类 id)
INSERT INTO `__PREFIX__haonav_links` (`id`, `cid`, `title`, `url`, `icon`, `description`, `keywords`, `click_count`, `sort`, `is_hot`, `is_recommend`, `status`, `screenshot`, `favicon`, `create_at`, `update_at`, `last_click_at`) VALUES
-- 综合搜索
(1, 11, '百度', 'https://www.baidu.com', '🔍', '全球最大的中文搜索引擎', '百度,搜索,引擎', 45000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(2, 11, 'Google', 'https://www.google.com', '🌐', '全球搜索引擎巨头', 'Google,搜索,英文', 30000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(3, 11, '必应', 'https://www.bing.com', '🔎', '微软旗下搜索引擎', '必应,bing,搜索', 18000, 90, 0, 1, 1, NULL, NULL, NULL, NULL, NULL),
(4, 11, '搜狗', 'https://www.sogou.com', '🐶', '腾讯旗下搜索引擎', '搜狗,搜索,输入法', 12000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(5, 11, '360搜索', 'https://www.so.com', '🛡️', '360安全搜索引擎', '360,搜索,安全', 9000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(6, 11, 'Yahoo', 'https://www.yahoo.com', '🟣', '老牌门户与搜索引擎', '雅虎,yahoo,门户', 7000, 75, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 学术资源
(7, 12, '中国知网', 'https://www.cnki.net', '📚', '中文学术文献数据库', '知网,论文,学术', 8000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(8, 12, '豆瓣', 'https://www.douban.com', '🎞️', '书影音与知识社区', '豆瓣,书影音,社区', 16000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(9, 12, '维基百科', 'https://www.wikipedia.org', '📖', '自由的百科全书', '维基,百科,知识', 14000, 90, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(10, 12, '微信读书', 'https://weread.qq.com', '📕', '腾讯在线阅读平台', '微信读书,电子书,阅读', 9000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(11, 12, '鸠摩搜书', 'https://www.jiumodiary.com', '🔏', '电子书聚合搜索', '鸠摩,电子书,搜书', 5000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 长视频
(12, 21, '爱奇艺', 'https://www.iqiyi.com', '🔴', '悦享品质视频平台', '爱奇艺,视频,会员', 26000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(13, 21, '腾讯视频', 'https://v.qq.com', '🟢', '海量正版高清视频', '腾讯视频,视频,电视剧', 24000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(14, 21, '优酷', 'https://www.youku.com', '🟠', '阿里巴巴视频平台', '优酷,视频,土豆', 20000, 90, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(15, 21, '芒果TV', 'https://www.mgtv.com', '🟡', '湖南卫视官方视频', '芒果TV,视频,综艺', 18000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(16, 21, '哔哩哔哩', 'https://www.bilibili.com', '🔵', '国内知名弹幕视频网', 'B站,视频,弹幕', 32000, 88, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(17, 21, '搜狐视频', 'https://tv.sohu.com', '', '搜狐高清视频平台', '搜狐视频,视频,美剧', 9000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 短视频直播
(18, 22, '抖音', 'https://www.douyin.com', '🎵', '记录美好生活的短视频', '抖音,短视频,直播', 35000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(19, 22, '快手', 'https://www.kuaishou.com', '🎬', '普惠的短视频社区', '快手,短视频,直播', 22000, 95, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(20, 22, '微信视频号', 'https://channels.weixin.qq.com', '💬', '微信生态短视频', '视频号,微信,短视频', 14000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 音乐
(21, 23, '网易云音乐', 'https://music.163.com', '🔴', '有态度的音乐平台', '网易云,音乐,歌单', 28000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(22, 23, 'QQ音乐', 'https://y.qq.com', '🟢', '腾讯在线音乐平台', 'QQ音乐,音乐,听歌', 26000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(23, 23, '酷狗音乐', 'https://www.kugou.com', '🟣', '庞大曲库音乐平台', '酷狗,音乐,听歌', 15000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(24, 23, '酷我音乐', 'https://www.kuwo.cn', '🔵', '无损音乐正版试听', '酷我,音乐,无损', 9000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(25, 23, '千千音乐', 'https://music.taihe.com', '🟡', '太合音乐旗下平台', '千千音乐,音乐,在线听', 5000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(26, 23, 'Spotify', 'https://www.spotify.com', '🟢', '全球流媒体音乐服务', 'Spotify,音乐,海外', 8000, 78, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 综合社交
(27, 31, '微信', 'https://weixin.qq.com', '💬', '腾讯即时通讯与社交', '微信,社交,聊天', 40000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(28, 31, 'QQ', 'https://im.qq.com', '🐧', '腾讯即时通讯工具', 'QQ,社交,聊天', 30000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(29, 31, '微博', 'https://www.weibo.com', '🔶', '随时随地发现新鲜事', '微博,社交,微博客', 28000, 90, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(30, 31, '小红书', 'https://www.xiaohongshu.com', '🔴', '标记生活的种草社区', '小红书,社交,种草', 24000, 88, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(31, 31, '知乎', 'https://www.zhihu.com', '🔵', '有问题就会有答案', '知乎,问答,知识', 20000, 85, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
-- 国际社交
(32, 32, 'X (Twitter)', 'https://x.com', '', '马斯卡旗下的社交平台', 'X,Twitter,海外社交', 16000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(33, 32, 'Telegram', 'https://telegram.org', '🔵', '加密即时通讯与频道', 'Telegram,电报,聊天', 12000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(34, 32, 'Facebook', 'https://www.facebook.com', '🔵', '全球最大社交网络', 'Facebook,脸书,海外社交', 14000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(35, 32, 'Instagram', 'https://www.instagram.com', '🔴', '图片与短视频社交', 'Instagram,ins,海外社交', 13000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(36, 32, 'Reddit', 'https://www.reddit.com', '🟠', '全球兴趣社区论坛', 'Reddit,论坛,海外', 9000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(37, 32, 'YouTube', 'https://www.youtube.com', '🔴', '全球最大视频平台', 'YouTube,视频,海外', 30000, 80, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
-- 综合门户
(38, 41, '新浪', 'https://www.sina.com.cn', '🔴', '综合门户与新闻', '新浪,门户,新闻', 22000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(39, 41, '搜狐', 'https://www.sohu.com', '🔵', '综合门户新闻平台', '搜狐,门户,新闻', 16000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(40, 41, '网易', 'https://www.163.com', '🔴', '综合门户与邮箱', '网易,门户,新闻', 18000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(41, 41, '腾讯新闻', 'https://news.qq.com', '🟢', '腾讯新闻资讯平台', '腾讯新闻,新闻,资讯', 14000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(42, 41, '今日头条', 'https://www.toutiao.com', '🔴', '个性化推荐资讯', '今日头条,新闻,资讯', 20000, 85, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(43, 41, '凤凰网', 'https://www.ifeng.com', '🔶', '全球华人资讯门户', '凤凰网,门户,新闻', 9000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 科技资讯
(44, 42, '36氪', 'https://36kr.com', '🔵', '新经济商业媒体', '36氪,科技,创业', 8000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(45, 42, '虎嗅', 'https://www.huxiu.com', '🟠', '有洞察的商业科技媒体', '虎嗅,科技,商业', 7000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(46, 42, '少数派', 'https://sspai.com', '🔵', '效率工具与数字生活', '少数派,科技,效率', 6000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(47, 42, '钛媒体', 'https://www.tmtpost.com', '🔴', '财经科技资讯平台', '钛媒体,科技,财经', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 代码托管
(48, 51, 'GitHub', 'https://github.com', '🐙', '全球最大代码托管平台', 'GitHub,代码,开源', 35000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(49, 51, 'GitLab', 'https://gitlab.com', '🟠', 'DevOps 代码协作平台', 'GitLab,代码,协作', 9000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(50, 51, 'Gitee', 'https://gitee.com', '🔴', '国内代码托管平台', 'Gitee,码云,代码', 12000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(51, 51, 'Coding', 'https://dev.tencent.com', '🔵', '腾讯云代码托管', 'Coding,代码,腾讯云', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 技术社区
(52, 52, 'CSDN', 'https://www.csdn.net', '🔴', '中文IT技术社区', 'CSDN,技术,博客', 20000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(53, 52, '掘金', 'https://juejin.cn', '🔵', '面向开发者的技术社区', '掘金,技术,前端', 14000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(54, 52, '博客园', 'https://www.cnblogs.com', '🔵', '开发者博客社区', '博客园,技术,博客', 8000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(55, 52, 'Stack Overflow', 'https://stackoverflow.com', '🟠', '全球编程问答社区', 'StackOverflow,技术,问答', 16000, 88, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(56, 52, 'V2EX', 'https://www.v2ex.com', '🔵', '创意工作者的社区', 'V2EX,技术,社区', 5000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(57, 52, '开源中国', 'https://www.oschina.net', '🔴', '中文开源技术社区', '开源中国,技术,开源', 6000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 云服务
(58, 53, '阿里云', 'https://www.aliyun.com', '🟠', '阿里云计算平台', '阿里云,云,服务器', 18000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(59, 53, '腾讯云', 'https://cloud.tencent.com', '🔵', '腾讯云计算平台', '腾讯云,云,服务器', 14000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(60, 53, '华为云', 'https://www.huaweicloud.com', '🔴', '华为云计算平台', '华为云,云,服务器', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(61, 53, '百度智能云', 'https://cloud.baidu.com', '🔵', '百度云计算平台', '百度云,云,AI', 5000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(62, 53, 'Cloudflare', 'https://www.cloudflare.com', '🟠', '全球CDN与安全服务', 'Cloudflare,CDN,海外', 8000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(63, 53, '七牛云', 'https://www.qiniu.com', '🟢', '对象存储与CDN', '七牛云,存储,CDN', 4000, 80, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 慕课学习
(64, 61, '中国大学MOOC', 'https://www.icourse163.org', '🔴', '国家精品在线课程', '慕课,大学,课程', 9000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(65, 61, '网易云课堂', 'https://study.163.com', '🔴', '实用技能学习平台', '云课堂,课程,学习', 8000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(66, 61, '慕课网', 'https://www.imooc.com', '🟢', 'IT技能学习平台', '慕课网,IT,编程', 10000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(67, 61, 'Coursera', 'https://www.coursera.org', '🔵', '全球在线课程平台', 'Coursera,课程,海外', 7000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(68, 61, '学堂在线', 'https://www.xuetangx.com', '🔵', '清华出品慕课平台', '学堂在线,课程,大学', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(69, 61, 'B站课堂', 'https://www.bilibili.com/v/cheese', '🔵', 'B站知识区课程', 'B站课堂,课程,学习', 5000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 资料文库
(70, 62, '百度文库', 'https://wenku.baidu.com', '🔵', '文档资料分享平台', '百度文库,文档,资料', 9000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(71, 62, '道客巴巴', 'https://www.doc88.com', '🔴', '在线文档分享平台', '道客巴巴,文档,资料', 4000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(72, 62, '原创力文档', 'https://max.book118.com', '🟠', '专业文档下载站', '原创力,文档,下载', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 综合电商
(73, 71, '淘宝', 'https://www.taobao.com', '🔶', '淘!我喜欢', '淘宝,购物,电商', 40000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(74, 71, '京东', 'https://www.jd.com', '🔴', '正品低价品质保障', '京东,购物,自营', 38000, 95, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(75, 71, '拼多多', 'https://www.pinduoduo.com', '🔴', '拼着买更便宜', '拼多多,团购,便宜', 32000, 90, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(76, 71, '天猫', 'https://www.tmall.com', '🔴', '品质好物聚集地', '天猫,购物,品牌', 28000, 88, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(77, 71, '苏宁易购', 'https://www.suning.com', '🔵', '家电3C综合电商', '苏宁,购物,家电', 9000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 海淘二手
(78, 72, '亚马逊', 'https://www.amazon.cn', '🔴', '全球综合电商平台', '亚马逊,海淘,电商', 12000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(79, 72, '闲鱼', 'https://www.goofish.com', '🔶', '阿里二手交易社区', '闲鱼,二手,转卖', 16000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(80, 72, '转转', 'https://www.zhuanzhuan.com', '🔵', '二手交易平台', '转转,二手,交易', 7000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(81, 72, 'eBay', 'https://www.ebay.com', '🔴', '全球在线拍卖与购物', 'eBay,海淘,海外', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 文档协作
(82, 81, '腾讯文档', 'https://docs.qq.com', '🔵', '多人实时在线文档', '腾讯文档,在线文档,协作', 14000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(83, 81, '石墨文档', 'https://shimo.im', '🟢', '轻盈的在线协作文档', '石墨文档,协作,文档', 6000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(84, 81, '飞书', 'https://www.feishu.cn', '🔵', '一站式办公协作平台', '飞书,办公,协作', 10000, 92, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(85, 81, '钉钉文档', 'https://www.dingtalk.com', '🔵', '阿里办公协作套件', '钉钉,办公,协作', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(86, 81, 'Notion', 'https://www.notion.so', '', '全能笔记与协作工具', 'Notion,笔记,海外', 8000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(87, 81, 'Google Docs', 'https://docs.google.com', '🔵', '谷歌在线文档套件', 'GoogleDocs,文档,海外', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 邮箱
(88, 82, 'QQ邮箱', 'https://mail.qq.com', '🔵', '腾讯免费邮箱', 'QQ邮箱,邮箱,邮件', 20000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(89, 82, '网易邮箱', 'https://mail.163.com', '🔴', '网易免费邮箱', '网易邮箱,邮箱,邮件', 12000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(90, 82, 'Outlook', 'https://outlook.live.com', '🔵', '微软邮箱服务', 'Outlook,邮箱,微软', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(91, 82, 'Gmail', 'https://mail.google.com', '🔴', '谷歌邮箱服务', 'Gmail,邮箱,谷歌', 11000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(92, 82, 'Foxmail', 'https://www.foxmail.com', '🔵', '腾讯邮箱客户端', 'Foxmail,邮箱,客户端', 3000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 云盘
(93, 83, '百度网盘', 'https://pan.baidu.com', '🔵', '国内最大云存储', '百度网盘,云盘,存储', 26000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(94, 83, '阿里云盘', 'https://www.aliyundrive.com', '🔴', '不限速个人云盘', '阿里云盘,云盘,存储', 14000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(95, 83, 'OneDrive', 'https://onedrive.live.com', '🔵', '微软云存储服务', 'OneDrive,云盘,微软', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(96, 83, '蓝奏云', 'https://www.lanzou.com', '🟢', '不限速文件分享', '蓝奏云,云盘,分享', 5000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(97, 83, '腾讯微云', 'https://www.weiyun.com', '🔵', '腾讯云存储服务', '微云,云盘,腾讯', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 会议办公
(98, 84, '腾讯会议', 'https://meeting.tencent.com', '🔵', '高清流畅视频会议', '腾讯会议,会议,视频', 14000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(99, 84, '钉钉', 'https://www.dingtalk.com', '🔵', '阿里企业办公平台', '钉钉,办公,企业', 12000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(100, 84, '飞书会议', 'https://www.feishu.cn', '🔵', '字节一站式办公', '飞书,会议,办公', 7000, 92, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(101, 84, 'Zoom', 'https://www.zoom.us', '🔵', '国际视频会议工具', 'Zoom,会议,海外', 8000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(102, 84, '企业微信', 'https://work.weixin.qq.com', '🔢', '腾讯企业通讯工具', '企业微信,办公,企业', 9000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 设计素材
(103, 91, '千图网', 'https://www.58pic.com', '🔴', '原创设计素材库', '千图网,素材,设计', 6000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(104, 91, '包图网', 'https://www.ibaotu.com', '🔶', '商业设计素材平台', '包图网,素材,设计', 4000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(105, 91, '我图网', 'https://www.ooopic.com', '🔵', '正版设计素材下载', '我图网,素材,设计', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(106, 91, 'Unsplash', 'https://unsplash.com', '', '免费高清摄影图库', 'Unsplash,图片,海外', 9000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(107, 91, 'Pexels', 'https://www.pexels.com', '🔵', '免费摄影视频素材', 'Pexels,图片,海外', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(108, 91, 'Pixabay', 'https://pixabay.com', '🔵', '免费正版图库与视频', 'Pixabay,图片,海外', 4000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 设计工具
(109, 92, 'Figma', 'https://www.figma.com', '🔴', '协作式UI设计工具', 'Figma,设计,UI', 10000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(110, 92, 'Canva', 'https://www.canva.cn', '🔵', '在线平面设计工具', 'Canva,设计,海报', 8000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(111, 92, '稿定设计', 'https://www.gaoding.com', '🔴', '电商新媒体设计工具', '稿定设计,设计,模板', 4000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(112, 92, '即时设计', 'https://js.design', '🔵', '国产协作式UI工具', '即时设计,UI,设计', 4000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(113, 92, '墨刀', 'https://modao.cc', '🔵', '在线原型设计工具', '墨刀,原型,设计', 3000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 生活服务
(114, 101, '快递100', 'https://www.kuaidi100.com', '🔵', '快递查询与寄件', '快递100,快递,查询', 9000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(115, 101, '12306', 'https://www.12306.cn', '🔴', '中国铁路购票官网', '12306,火车票,铁路', 18000, 95, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(116, 101, '高德地图', 'https://www.amap.com', '🔵', '高德导航与地图', '高德,地图,导航', 16000, 92, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(117, 101, '百度地图', 'https://map.baidu.com', '🔵', '百度地图与导航', '百度地图,地图,导航', 12000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(118, 101, '中国天气', 'https://www.weather.com.cn', '🌤️', '中央气象台天气', '天气,气象,预报', 8000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(119, 101, '美团', 'https://www.meituan.com', '🔶', '本地生活服务平台', '美团,外卖,生活', 20000, 88, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 在线工具
(120, 102, 'JSON在线解析', 'https://www.json.cn', '🔧', 'JSON格式化与校验', 'JSON,工具,格式化', 8000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(121, 102, '草料二维码', 'https://cli.im', '🔲', '二维码生成与解码', '草料,二维码,工具', 5000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(122, 102, 'ProcessOn', 'https://www.processon.com', '🔵', '在线流程图与思维导图', 'ProcessOn,流程图,思维导图', 5000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(123, 102, '站长工具', 'https://tool.chinaz.com', '🔵', 'SEO与建站查询', '站长工具,SEO,查询', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(124, 102, 'Regex101', 'https://regex101.com', '🔴', '正则表达式在线测试', 'Regex,正则,海外', 4000, 82, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 行情理财
(125, 111, '东方财富', 'https://www.eastmoney.com', '🔴', '财经金融资讯门户', '东方财富,财经,股票', 16000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(126, 111, '同花顺', 'https://www.10jqka.com.cn', '🔴', '股票行情交易软件', '同花顺,股票,行情', 9000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(127, 111, '雪球', 'https://xueqiu.com', '🔴', '投资者社区与行情', '雪球,股票,投资', 9000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(128, 111, '天天基金', 'https://fund.eastmoney.com', '🔵', '基金净值与申购', '天天基金,基金,理财', 6000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 支付银行
(129, 112, '支付宝', 'https://www.alipay.com', '🔵', '支付就用支付宝', '支付宝,支付,金融', 40000, 100, 1, 1, 1, NULL, NULL, NULL, NULL, NULL),
(130, 112, '微信支付', 'https://pay.weixin.qq.com', '💚', '腾讯移动支付', '微信支付,支付,金融', 30000, 95, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(131, 112, '中国银联', 'https://www.unionpay.com', '🔴', '银行卡联合组织', '银联,银行,支付', 5000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(132, 112, '网银在线', 'https://www.chinabank.com.cn', '🔵', '京东旗下支付', '网银在线,支付,京东', 3000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 交通出行
(133, 121, '携程', 'https://www.ctrip.com', '🔵', '机票酒店预订平台', '携程,旅游,机票', 18000, 100, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(134, 121, '去哪儿', 'https://www.qunar.com', '🔶', '旅行预订搜索', '去哪儿,旅游,机票', 9000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(135, 121, '飞猪', 'https://www.fliggy.com', '🔴', '阿里旅行平台', '飞猪,旅游,机票', 8000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(136, 121, '12306', 'https://www.12306.cn', '🔴', '铁路购票官网', '12306,火车票,出行', 18000, 92, 1, 0, 1, NULL, NULL, NULL, NULL, NULL),
(137, 121, '高德打车', 'https://www.amap.com', '🔵', '一键呼叫网约车', '高德打车,出行,网约车', 6000, 88, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 住宿预订
(138, 122, '美团酒店', 'https://hotel.meituan.com', '🔶', '本地酒店预订', '美团,酒店,住宿', 12000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(139, 122, 'Airbnb', 'https://www.airbnb.cn', '🔴', '全球民宿短租', 'Airbnb,民宿,海外', 6000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(140, 122, 'Booking', 'https://www.booking.com', '🔵', '全球酒店预订', 'Booking,酒店,海外', 7000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(141, 122, '途家', 'https://www.tujia.com', '🔵', '国内民宿预订', '途家,民宿,住宿', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 在线问诊
(142, 131, '丁香医生', 'https://dxy.com', '🔴', '专业健康科普与问诊', '丁香医生,健康,问诊', 8000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(143, 131, '好大夫在线', 'https://www.haodf.com', '🔵', '医患对接问诊平台', '好大夫,问诊,医生', 6000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(144, 131, '微医', 'https://www.guahao.com', '🔵', '互联网医疗服务', '微医,挂号,问诊', 4000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(145, 131, '平安健康', 'https://health.pingan.com', '🔴', '平安互联网医疗', '平安健康,问诊,医疗', 4000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 健康科普
(146, 132, '丁香园', 'https://www.dxy.cn', '🔴', '医药健康专业社区', '丁香园,医学,科普', 5000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(147, 132, '腾讯健康', 'https://health.qq.com', '🔵', '腾讯健康科普服务', '腾讯健康,健康,科普', 4000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(148, 132, '39健康网', 'https://www.39.net', '🔵', '大众健康资讯门户', '39健康网,健康,科普', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 政务服务
(149, 141, '国家政务服务平台', 'https://gjzwfw.www.gov.cn', '🔴', '全国一体化政务门户', '国家政务,政务,服务', 6000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(150, 141, '粤省事', 'https://www.gdyxzc.gov.cn', '🔵', '广东政务服务小程序', '粤省事,政务,广东', 4000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(151, 141, '浙里办', 'https://www.zjzwfw.gov.cn', '🔵', '浙江政务服务门户', '浙里办,政务,浙江', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(152, 141, '国务院客户端', 'https://www.gov.cn', '🔴', '中央人民政府门户', '国务院,政务,政策', 3000, 85, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
-- 生活缴费
(153, 142, '社保查询', 'http://si.12333.gov.cn', '🔵', '全国社保公共服务', '社保,查询,缴费', 4000, 100, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(154, 142, '水电网缴费', 'https://www.95598.cn', '', '国家电网与公共事业', '水电,缴费,电网', 3000, 95, 0, 0, 1, NULL, NULL, NULL, NULL, NULL),
(155, 142, '个人所得税', 'https://etax.chinatax.gov.cn', '🔴', '自然人税务平台', '个税,税务,申报', 3000, 90, 0, 0, 1, NULL, NULL, NULL, NULL, NULL);
-- 3. 链接表新增二级分类字段
ALTER TABLE `__PREFIX__haonav_links`
ADD COLUMN `sub_cid` int unsigned NOT NULL DEFAULT '0' COMMENT '所属二级分类ID0表示未细分' AFTER `cid`;
ALTER TABLE `__PREFIX__haonav_links`
ADD INDEX `idx_sub_cid` (`sub_cid`);
SET FOREIGN_KEY_CHECKS = 1;
-- 4. 写入二级分类(pid 指向一级,已存在则忽略)
INSERT IGNORE INTO `__PREFIX__haonav_category` (`id`, `pid`, `title`, `icon`, `summary`, `keywords`, `description`, `sort`, `status`, `website_count`, `click_count`, `create_at`, `update_at`) VALUES
(101, 1, '综合社交', '💬', '', '', '微博、朋友圈等综合社交平台', 100, 1, 0, 0, NULL, NULL),
(102, 1, '即时通讯', '📱', '', '', '微信、QQ、Telegram 等通讯工具', 90, 1, 0, 0, NULL, NULL),
(103, 1, '兴趣社区', '🏘️', '', '', '豆瓣、贴吧、知乎等兴趣社区', 80, 1, 0, 0, NULL, NULL),
(104, 1, '职场交友', '🤝', '', '', '脉脉、探探、Soul 等职场与交友', 70, 1, 0, 0, NULL, NULL),
(105, 2, '长视频', '🎬', '', '', '腾讯视频、爱奇艺、优酷等长视频', 100, 1, 0, 0, NULL, NULL),
(106, 2, '短视频', '📱', '', '', '抖音、快手、B站等短视频', 90, 1, 0, 0, NULL, NULL),
(107, 2, '动漫漫画', '🎨', '', '', '哔哩漫画、腾讯动漫、快看', 80, 1, 0, 0, NULL, NULL),
(108, 2, '直播娱乐', '🎥', '', '', '直播平台与综合娱乐', 70, 1, 0, 0, NULL, NULL),
(109, 3, '综合电商', '🛍️', '', '', '淘宝、京东、拼多多等综合电商', 100, 1, 0, 0, NULL, NULL),
(110, 3, '特卖二手', '♻️', '', '', '唯品会、闲鱼、转转等特卖与二手', 90, 1, 0, 0, NULL, NULL),
(111, 3, '本地生活', '🍜', '', '', '美团、大众点评、旅游出行', 80, 1, 0, 0, NULL, NULL),
(112, 3, '汽车房产', '🚗', '', '', '汽车之家、链家、贝壳等', 70, 1, 0, 0, NULL, NULL),
(113, 5, '在线课程', '📝', '', '', 'MOOC、网易云课堂、极客时间', 100, 1, 0, 0, NULL, NULL),
(114, 5, '学术检索', '🔬', '', '', '知网、万方、Google Scholar', 90, 1, 0, 0, NULL, NULL),
(115, 5, '基础教育', '📐', '', '', '可汗学院、学堂在线等', 80, 1, 0, 0, NULL, NULL),
(116, 6, '综合新闻', '📰', '', '', '人民日报、新华社、央视网', 100, 1, 0, 0, NULL, NULL),
(117, 6, '财经科技', '💡', '', '', '财新、36氪、界面新闻', 90, 1, 0, 0, NULL, NULL),
(118, 6, '健康医疗', '🩺', '', '', '丁香医生、好大夫在线', 80, 1, 0, 0, NULL, NULL),
(119, 7, '游戏平台', '🕹️', '', '', 'Steam、TapTap、腾讯游戏', 100, 1, 0, 0, NULL, NULL),
(120, 7, '小游戏', '🎲', '', '', '4399、7k7k 等小游戏', 90, 1, 0, 0, NULL, NULL),
(121, 8, '搜索引擎', '🔍', '', '', '百度、Google、Bing 等', 100, 1, 0, 0, NULL, NULL),
(122, 8, '门户邮箱', '📧', '', '', '新浪、搜狐、QQ邮箱、Gmail', 90, 1, 0, 0, NULL, NULL),
(123, 8, '翻译地图', '🗺️', '', '', '翻译、地图等工具门户', 80, 1, 0, 0, NULL, NULL),
(124, 9, '代码托管', '📦', '', '', 'GitHub、GitLab、Gitee', 100, 1, 0, 0, NULL, NULL),
(125, 9, '技术社区', '👨‍💻', '', '', 'CSDN、掘金、Stack Overflow', 90, 1, 0, 0, NULL, NULL),
(126, 9, '云服务', '☁️', '', '', '阿里云、腾讯云、Cloudflare', 80, 1, 0, 0, NULL, NULL),
(127, 9, '设计素材', '🎨', '', '', '站酷、花瓣、摄图网', 70, 1, 0, 0, NULL, NULL),
(128, 10, '网络文学', '📖', '', '', '起点、晋江、纵横等', 100, 1, 0, 0, NULL, NULL),
(129, 10, '电子阅读', '📚', '', '', '微信读书、掌阅、番茄小说', 90, 1, 0, 0, NULL, NULL),
(130, 11, '办公套件', '📊', '', '', 'WPS、Office 等办公套件', 100, 1, 0, 0, NULL, NULL),
(131, 11, '企业协同', '👥', '', '', '钉钉、飞书、企业微信', 90, 1, 0, 0, NULL, NULL),
(132, 11, '云存储', '💾', '', '', '百度网盘、阿里云盘', 80, 1, 0, 0, NULL, NULL),
(133, 11, '音乐音频', '🎵', '', '', '网易云、QQ音乐、Spotify', 70, 1, 0, 0, NULL, NULL),
(134, 12, '证券理财', '📈', '', '', '东方财富、同花顺、雪球', 100, 1, 0, 0, NULL, NULL),
(135, 12, '支付银行', '🏦', '', '', '支付宝、招商银行等', 90, 1, 0, 0, NULL, NULL);
-- 5. 已装库旧数据按 URL 特征归入二级(仅更新 sub_cid=0 的记录,安全可重复执行)
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 102 WHERE `sub_cid` = 0 AND (`url` LIKE '%weixin.qq%' OR `url` LIKE '%im.qq%' OR `url` LIKE '%telegram%' OR `url` LIKE '%whatsapp%' OR `url` LIKE '%skype%' OR `url` LIKE '%discord%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 103 WHERE `sub_cid` = 0 AND (`url` LIKE '%douban%' OR `url` LIKE '%tieba%' OR `url` LIKE '%zhihu%' OR `url` LIKE '%weibo%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 104 WHERE `sub_cid` = 0 AND (`url` LIKE '%maimai%' OR `url` LIKE '%tantan%' OR `url` LIKE '%soulapp%' OR `url` LIKE '%immomo%' OR `url` LIKE '%jiayuan%' OR `url` LIKE '%yidui%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 105 WHERE `sub_cid` = 0 AND (`url` LIKE '%v.qq%' OR `url` LIKE '%iqiyi%' OR `url` LIKE '%youku%' OR `url` LIKE '%mgtv%' OR `url` LIKE '%sohu.com%tv%' OR `url` LIKE '%miguvideo%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 106 WHERE `sub_cid` = 0 AND (`url` LIKE '%douyin%' OR `url` LIKE '%kuaishou%' OR `url` LIKE '%bilibili%' OR `url` LIKE '%ixigua%' OR `url` LIKE '%haokan%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 107 WHERE `sub_cid` = 0 AND (`url` LIKE '%manga%' OR `url` LIKE '%manhua%' OR `url` LIKE '%ac.qq%' OR `url` LIKE '%u17%' OR `url` LIKE '%dmzj%' OR `url` LIKE '%manhuadb%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 108 WHERE `sub_cid` = 0 AND (`url` LIKE '%douyu%' OR `url` LIKE '%huya%' OR `url` LIKE '%live%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 109 WHERE `sub_cid` = 0 AND (`url` LIKE '%taobao%' OR `url` LIKE '%tmall%' OR `url` LIKE '%jd.com%' OR `url` LIKE '%pinduoduo%' OR `url` LIKE '%suning%' OR `url` LIKE '%amazon%' OR `url` LIKE '%mi.com%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 110 WHERE `sub_cid` = 0 AND (`url` LIKE '%vip.com%' OR `url` LIKE '%dangdang%' OR `url` LIKE '%goofish%' OR `url` LIKE '%zhuanzhuan%' OR `url` LIKE '%xianyu%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 111 WHERE `sub_cid` = 0 AND (`url` LIKE '%meituan%' OR `url` LIKE '%ele.me%' OR `url` LIKE '%dianping%' OR `url` LIKE '%ctrip%' OR `url` LIKE '%fliggy%' OR `url` LIKE '%qunar%' OR `url` LIKE '%mafengwo%' OR `url` LIKE '%12306%' OR `url` LIKE '%ly.com%' OR `url` LIKE '%kfc%' OR `url` LIKE '%mcdonalds%' OR `url` LIKE '%xiachufang%' OR `url` LIKE '%meishij%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 112 WHERE `sub_cid` = 0 AND (`url` LIKE '%autohome%' OR `url` LIKE '%dongchedi%' OR `url` LIKE '%yiche%' OR `url` LIKE '%lianjia%' OR `url` LIKE '%ke.com%' OR `url` LIKE '%anjuke%' OR `url` LIKE '%5i5j%' OR `url` LIKE '%guazi%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 113 WHERE `sub_cid` = 0 AND (`url` LIKE '%icourse%' OR `url` LIKE '%study.163%' OR `url` LIKE '%ke.qq%' OR `url` LIKE '%xuetangx%' OR `url` LIKE '%imooc%' OR `url` LIKE '%geekbang%' OR `url` LIKE '%jikexueyuan%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 114 WHERE `sub_cid` = 0 AND (`url` LIKE '%cnki%' OR `url` LIKE '%wanfang%' OR `url` LIKE '%cqvip%' OR `url` LIKE '%scholar.google%' OR `url` LIKE '%xueshu.baidu%' OR `url` LIKE '%smartedu%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 115 WHERE `sub_cid` = 0 AND (`url` LIKE '%khanacademy%' OR `url` LIKE '%coursera%' OR `url` LIKE '%edx%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 116 WHERE `sub_cid` = 0 AND (`url` LIKE '%people.com%' OR `url` LIKE '%news.cn%' OR `url` LIKE '%cctv%' OR `url` LIKE '%chinanews%' OR `url` LIKE '%huanqiu%' OR `url` LIKE '%cankaoxiaoxi%' OR `url` LIKE '%thepaper%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 117 WHERE `sub_cid` = 0 AND (`url` LIKE '%caixin%' OR `url` LIKE '%jiemian%' OR `url` LIKE '%36kr%' OR `url` LIKE '%tmtpost%' OR `url` LIKE '%leiphone%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 118 WHERE `sub_cid` = 0 AND (`url` LIKE '%dxy.com%' OR `url` LIKE '%haodf%' OR `url` LIKE '%jk.cn%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 119 WHERE `sub_cid` = 0 AND (`url` LIKE '%steampowered%' OR `url` LIKE '%taptap%' OR `url` LIKE '%game.qq%' OR `url` LIKE '%game.163%' OR `url` LIKE '%epicgames%' OR `url` LIKE '%wegame%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 120 WHERE `sub_cid` = 0 AND (`url` LIKE '%4399%' OR `url` LIKE '%7k7k%' OR `url` LIKE '%2144%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 121 WHERE `sub_cid` = 0 AND (`url` LIKE '%baidu.com%' OR `url` LIKE '%google.com%' OR `url` LIKE '%bing%' OR `url` LIKE '%sogou%' OR `url` LIKE '%yahoo%' OR `url` LIKE '%duckduckgo%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 122 WHERE `sub_cid` = 0 AND (`url` LIKE '%sina%' OR `url` LIKE '%sohu%' OR `url` LIKE '%mail.qq%' OR `url` LIKE '%mail.163%' OR `url` LIKE '%outlook%' OR `url` LIKE '%gmail%' OR `url` LIKE '%foxmail%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 123 WHERE `sub_cid` = 0 AND (`url` LIKE '%fanyi%' OR `url` LIKE '%map.baidu%' OR `url` LIKE '%amap%' OR `url` LIKE '%translate%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 124 WHERE `sub_cid` = 0 AND (`url` LIKE '%github%' OR `url` LIKE '%gitlab%' OR `url` LIKE '%gitee%' OR `url` LIKE '%bitbucket%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 125 WHERE `sub_cid` = 0 AND (`url` LIKE '%csdn%' OR `url` LIKE '%juejin%' OR `url` LIKE '%stackoverflow%' OR `url` LIKE '%oschina%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 126 WHERE `sub_cid` = 0 AND (`url` LIKE '%aliyun%' OR `url` LIKE '%cloud.tencent%' OR `url` LIKE '%huaweicloud%' OR `url` LIKE '%qiniu%' OR `url` LIKE '%cloudflare%' OR `url` LIKE '%cloud.baidu%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 127 WHERE `sub_cid` = 0 AND (`url` LIKE '%zcool%' OR `url` LIKE '%huaban%' OR `url` LIKE '%ui.cn%' OR `url` LIKE '%699pic%' OR `url` LIKE '%58pic%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 128 WHERE `sub_cid` = 0 AND (`url` LIKE '%qidian%' OR `url` LIKE '%jjwxc%' OR `url` LIKE '%zongheng%' OR `url` LIKE '%hongxiu%' OR `url` LIKE '%xxsy%' OR `url` LIKE '%heiyan%' OR `url` LIKE '%fanqie%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 129 WHERE `sub_cid` = 0 AND (`url` LIKE '%weread%' OR `url` LIKE '%zhangyue%' OR `url` LIKE '%read.douban%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 130 WHERE `sub_cid` = 0 AND (`url` LIKE '%wps%' OR `url` LIKE '%office%' OR `url` LIKE '%yozosoft%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 131 WHERE `sub_cid` = 0 AND (`url` LIKE '%dingtalk%' OR `url` LIKE '%feishu%' OR `url` LIKE '%work.weixin%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 132 WHERE `sub_cid` = 0 AND (`url` LIKE '%pan.baidu%' OR `url` LIKE '%aliyundrive%' OR `url` LIKE '%lanzou%' OR `url` LIKE '%cloud.189%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 133 WHERE `sub_cid` = 0 AND (`url` LIKE '%music.163%' OR `url` LIKE '%y.qq%' OR `url` LIKE '%kugou%' OR `url` LIKE '%kuwo%' OR `url` LIKE '%spotify%' OR `url` LIKE '%music.apple%' OR `url` LIKE '%ximalaya%' OR `url` LIKE '%qtfm%' OR `url` LIKE '%lizhi%' OR `url` LIKE '%kg.qq%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 134 WHERE `sub_cid` = 0 AND (`url` LIKE '%eastmoney%' OR `url` LIKE '%10jqka%' OR `url` LIKE '%xueqiu%' OR `url` LIKE '%finance.sina%' OR `url` LIKE '%hexun%' OR `url` LIKE '%dzh%');
UPDATE `__PREFIX__haonav_links` SET `sub_cid` = 135 WHERE `sub_cid` = 0 AND (`url` LIKE '%alipay%' OR `url` LIKE '%cmbchina%' OR `url` LIKE '%icbc%' OR `url` LIKE '%ccb%' OR `url` LIKE '%boc.cn%');
+282 -329
View File
@@ -1,59 +1,21 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{$title | default='YwxApp'}</title>
<meta name="renderer" content="webkit">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="/assets/layui/css/layui.css" rel="stylesheet">
<link href="/assets/ywxapp/css/ywxapp.css" rel="stylesheet">
<!-- HTML5 shim, for IE6-8 support of HTML5 elements. All other JS at the end of file. -->
<!--[if lt IE 9]>
<script src="__CDN__/assets/js/html5shiv.js"></script>
<script src="__CDN__/assets/js/respond.min.js"></script>
<![endif]-->
<script type="text/javascript">
var config = {
root: "/",
};
var require = {
};
</script>
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-form layui-card-header layuiadmin-card-header-auto">
<div class="layui-form-item">
<div class="layui-inline">
<input type="text" name="title" placeholder="请输入标题" autocomplete="off" class="layui-input">
</div>
<div class="layui-inline">
<button class="layui-btn layuiadmin-btn-useradmin" lay-submit lay-filter="tableSearchButton">
<i class="layui-icon layui-icon-search layuiadmin-button-btn"></i>
</button>
</div>
</div>
</div>
<div class="layui-card-header"> </div>
<div class="layui-card-body">
<table class="layui-hide" id="dataTable" lay-filter="dataTable"></table>
</div>
</div>
</div>
<script type="text/html" id="dataBarTpl">
<script type="text/html" id="dataBarTpl">
<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm layui-btn-primary" title="编辑节点" lay-event="update"><i class="layui-icon layui-icon-edit"></i> </a>
<a class="layui-btn layui-btn-sm layui-btn-primary" title="添加子节点" lay-event="create"><i class="layui-icon layui-icon-add-1"></i> </a>
<a class="layui-btn layui-btn-sm layui-btn-primary" title="删除节点" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
</div>
</script>
<!-- 添加/编辑菜单表单模板 -->
<script type="text/html" id="dataFormTpl">
<!-- 添加/编辑菜单表单模板 -->
<script type="text/html" id="dataFormTpl">
<form class="layui-form layui-form-pane" lay-filter="wxapp-form" id="wxapp-form">
<input type="hidden" name="id" value="{{= d.id || '' }}">
<input type="hidden" name="pid" value="{{= d.pid || 0 }}">
@@ -105,11 +67,11 @@
</div>
</form>
</script>
<!-- 状态开关 -->
<script type="text/html" id="statusTpl">
<!-- 状态开关 -->
<script type="text/html" id="statusTpl">
<input type="checkbox" name="status" value="{{d.id}}" lay-skin="switch" lay-text="启用|禁用" lay-filter="statusSwitch" {{ d.status == 1 ? 'checked' : '' }}>
</script>
<script type="text/html" id="dataRecybinTpl">
<script type="text/html" id="dataRecybinTpl">
<div class="layui-card">
<div class="layui-card-header"> </div>
<div class="layui-card-body">
@@ -118,303 +80,294 @@
</div>
</script>
</script>
<script src="/assets/layui/layui.js"></script>
<script src="/assets/ywxapp/ywxapp.js" module="backend"></script>
<script>
layui.use(['layer', 'http'], function () {
var $ = layui.$;
var treeTable = layui.treeTable;
var form = layui.form;
var layer = layui.layer;
var laytpl = layui.laytpl;
var http = layui.http;
console.log('layui.treeTable:', layui.setter.layerArea()); // 调试输出
treeTable.render({
defaultToolbar: [{
title: '创建资源', layEvent: 'dataCreate', icon: 'layui-icon-add-1'
}, {
title: '删除资源', layEvent: 'dataDelete', icon: 'layui-icon-delete'
}, {
title: '资源回收站', layEvent: 'dataRecybin', icon: 'layui-icon-home'
}, 'filter', 'exports', 'print'
],
elem: '#dataTable',
url: 'category/index',
parseData: function (res) { // 预处理返回数据
console.log('原始数据:', res); // 调试输入
return {
code: res.code === 0 ? 0 : 1, // 0 标识成功
data: res.data || [], // 数据数组
msg: res.message || '' // 错误信息
};
},
tree: {
customName: {
children: "children",
isParent: "is_parent",
name: "title",
id: "id",
pid: "pid",
icon: "icon"
},
data: { isSimpleData: true, rootPid: 0 },
view: {},
async: {},
callback: {}
},
height: 'full-100',
toolbar: '#tableBarTpl',
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
{ field: 'title', title: '名称', width: 180, fixed: 'left' },
{ field: 'icon', title: '图标', width: 80 },
{ field: 'sort', title: '排序', width: 80, sort: true },
{ field: 'status', title: '状态', width: 96, align: 'center', templet: '#statusTpl' },
{ fixed: "right", title: "操作", align: "center", toolbar: "#dataBarTpl", fixed: 'right' }
]],
page: true,
done: function (res, curr, count, origin) { }
});
treeTable.on('toolbar(dataTable)', function (obj) {
var options = obj.config;
switch (obj.event) {
case 'dataCreate':
active.dataCreate({ pid: 0, type: 1 });
break;
case 'dataDelete':
var checkStatus = treeTable.checkStatus('dataTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
active.dataDelete(ids);
break;
case 'dataRecybin':
active.dataRecybin();
break;
<script>
layui.use(['layer', 'http'], function () {
var $ = layui.$;
var treeTable = layui.treeTable;
var form = layui.form;
var layer = layui.layer;
var laytpl = layui.laytpl;
var http = layui.http;
var cols = [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
{ field: 'title', title: '名称', width: 180, fixed: 'left' },
{ field: 'icon', title: '图标', width: 80 },
{ field: 'sort', title: '排序', width: 80, sort: true },
{ field: 'status', title: '状态', width: 96, align: 'center', templet: '#statusTpl' },
{ fixed: "right", title: "操作", align: "center", toolbar: "#dataBarTpl", fixed: 'right' }
]];
treeTable.render({
elem: '#dataTable',
url: 'index',
parseData: function (res) { // 预处理返回数据
console.log('原始数据:', res); // 调试输入
return {
code: res.code === 0 ? 0 : 1, // 0 标识成功
data: res.data || [], // 数据数组
msg: res.message || '' // 错误信息
};
},
tree: {
customName: {
children: "children",
isParent: "is_parent",
name: "title",
id: "id",
pid: "pid",
icon: "icon"
},
data: { isSimpleData: true, rootPid: 0 },
view: {},
async: {},
callback: {}
},
height: 'full-100',
toolbar: '#tableBarTpl',
cols: cols,
page: true,
done: function (res, curr, count, origin) {
var view = $('#dataTable').next('.layui-table-view');
view.find('[data-perm]').each(function () {
var perm = $(this).attr('data-perm');
if (perm && !auth.has(perm)) { $(this).remove(); }
});
$('.layui-btn[data-perm]').each(function () {
var perm = $(this).attr('data-perm');
if (perm && !auth.has(perm)) { $(this).remove(); }
});
}
});
treeTable.on('toolbar(dataTable)', function (obj) {
var options = obj.config;
switch (obj.event) {
case 'dataCreate':
active.dataCreate({ pid: 0, type: 1 });
break;
case 'dataDelete':
var checkStatus = treeTable.checkStatus('dataTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
active.dataDelete(ids);
break;
case 'dataRecybin':
active.dataRecybin();
break;
};
});
treeTable.on('tool(dataTable)', function (elem) {
var data = elem.data;
switch (elem.event) {
case 'update':
active.dataEdit(data);
break;
case 'create':
active.dataCreate({ pid: data.id });
break;
case 'delete':
active.dataDelete([data.id]);
break;
default:
break;
}
});
// 状态开关
form.on('switch(statusSwitch)', function (obj) {
var data = this.value;
var status = obj.elem.checked ? 1 : 0;
console.log(data);
layer.confirm('确定要' + (status ? '启用' : '禁用') + '该数据吗?', function (index) {
layui.http.put('update', { id: data.id,title:data.title, status: status }).then(function (res) {
if (res.code === 0) {
layer.msg(res.msg, { icon: 1 });
table.reload('dataTable', {}, true);
} else {
layer.msg(res.msg, { icon: 2 });
obj.elem.checked = !obj.elem.checked;
form.render('checkbox');
}
});
layer.close(index);
}, function () {
obj.elem.checked = !obj.elem.checked;
form.render('checkbox');
});
treeTable.on('tool(dataTable)', function (elem) {
var data = elem.data;
switch (elem.event) {
case 'update':
active.dataEdit(data);
break;
case 'create':
active.dataCreate({ pid: data.id });
break;
case 'delete':
active.dataDelete([data.id]);
break;
default:
break;
});
var dataFromFun = function (data, callback, done) {
var formHtml = laytpl($('#dataFormTpl').html()).render(data || {});
layer.open({
title: data.id ? '编辑数据' : (data.pid ? '添加子数据' : '添加根数据'),
content: formHtml,
anim: "slideLeft",
offset: "r",
btnAlign: "l",
area: ['520px', '98%'],
shade: 0.1,
shadeClose: true,
btn: ['确定', '取消'],
success: function (layero, index) {
callback(layero, index);
form.render();
},
yes: function (index, layero) {
window.layui.form.on('submit(wxapp-form-submit)', function (elem) {
done(layero, index, elem);
layui.off('submit(wxapp-form)', 'from');
return false;
});
layero.contents().find("#wxapp-form-submit").trigger('click');
}
});
form.on('submit(tableSearchButton)', function (data) {
var field = data.field;
treeTable.reload('dataTable', {
where: field
});
});
// 状态开关
form.on('switch(statusSwitch)', function (obj) {
var id = this.value;
var status = obj.elem.checked ? 1 : 0;
layer.confirm('确定要' + (status ? '启用' : '禁用') + '该数据吗?', function (index) {
layui.http.put('update', { id: id, status: status }).then(function (res) {
if (res.code == 0) {
layer.msg(res.msg, { icon: 1 });
table.reload('dataTable', {}, true);
} else {
layer.msg(res.msg, { icon: 2 });
obj.elem.checked = !obj.elem.checked;
form.render('checkbox');
}
});
};
//事件
var active = {
dataCreate: function (data = {}) {
dataFromFun(data,
function (layero, index) { },
function (layero, index, elem) {
var field = elem.field;
field.status = field.status ? 1 : 0;
http.post('save', field)
.then((res) => {
if (res.code === 0) {
layer.close(index);
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
}
);
},
dataEdit: function (data = {}) {
dataFromFun(data,
function (layero, index) { },
function (layero, index, elem) {
var field = elem.field;
field.status = field.status ? 1 : 0;
http.put('update', field)
.then((res) => {
if (res.code === 0) {
layer.close(index);
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
}
);
},
dataDelete: function (ids, force = 0) {
if (ids.length === 0) {
return layer.msg('请选择数据');
}
layer.prompt({
formType: 1
, title: '敏感操作,请验证口令'
}, function (value, index) {
layer.close(index);
}, function () {
obj.elem.checked = !obj.elem.checked;
form.render('checkbox');
layer.confirm('确定删除吗?', function (index) {
http.delete('delete', { ids: ids.join(','), force: force })
.then((res) => {
if (res.code === 0) {
layer.close(index);
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
layer.msg('已删除');
treeTable.reload('dataTable', {}, true);
});
});
});
var dataFromFun = function (data, callback, done) {
var formHtml = laytpl($('#dataFormTpl').html()).render(data || {});
},
dataRecybin: function (data = {}) {
var formHtml = laytpl($('#dataRecybinTpl').html()).render(data || {});
layer.open({
title: data.id ? '编辑数据' : (data.pid ? '添加子数据' : '添加根数据'),
title: '资源回收站',
content: formHtml,
anim: "slideLeft",
offset: "r",
btnAlign: "l",
area: layui.setter.layerArea(),
area: ['60%', '99%'],
shade: 0.1,
shadeClose: true,
btn: ['确定', '取消'],
success: function (layero, index) {
callback(layero, index);
form.render();
},
yes: function (index, layero) {
window.layui.form.on('submit(wxapp-form-submit)', function (elem) {
done(layero, index, elem);
layui.off('submit(wxapp-form)', 'from');
return false;
});
layero.contents().find("#wxapp-form-submit").trigger('click');
}
});
};
//事件
var active = {
dataCreate: function (data = {}) {
dataFromFun(data,
function (layero, index) { },
function (layero, index, elem) {
var field = elem.field;
field.icon = 'layui-icon ' + field.icon;
field.status = field.status ? 1 : 0;
http.post('save', field)
.then((res) => {
if (res.code === 0) {
layer.close(index);
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
}
);
},
dataEdit: function (data = {}) {
dataFromFun(data,
function (layero, index) { },
function (layero, index, elem) {
var field = elem.field;
field.icon = 'layui-icon ' + field.icon;
field.status = field.status ? 1 : 0;
http.put('update', field)
.then((res) => {
if (res.code === 0) {
layer.close(index);
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
}
);
},
dataDelete: function (ids, force = 0) {
if (ids.length === 0) {
return layer.msg('请选择数据');
}
layer.prompt({
formType: 1
, title: '敏感操作,请验证口令'
}, function (value, index) {
layer.close(index);
layer.confirm('确定删除吗?', function (index) {
http.delete('delete', { ids: ids.join(','), force: force })
.then((res) => {
if (res.code === 0) {
layer.close(index);
treeTable.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '操作失败');
}
});
layer.msg('已删除');
treeTable.reload('dataTable', {}, true);
});
});
},
dataRecybin: function (data = {}) {
var formHtml = laytpl($('#dataRecybinTpl').html()).render(data || {});
layer.open({
title: '资源回收站',
content: formHtml,
anim: "slideLeft",
offset: "r",
area: ['60%', '99%'],
shade: 0.1,
shadeClose: true,
success: function (layero, index) {
layui.table.render({
elem: '#dataRecybinTable',
url: 'recyclebin',
height: 'full-100',
defaultToolbar: [{
title: '批量删除数据',
layEvent: 'dataDelete',
icon: 'layui-icon-delete',
onClick: function (obj) {
var checkStatus = treeTable.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
console.log(ids);
}
}, 'filter', 'exports', 'print'],
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
{ field: 'title', title: '标题', width: 180, fixed: 'left' },
{
fixed: "right", title: "操作", width: 120, align: "center", templet: function (d) {
return `<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm" title="恢复数据" lay-event="restore" > <i class="layui-icon layui-icon-edit"></i> </a >
<a class="layui-btn layui-btn-sm" title="删除数据" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
</div >`;
}
}
]],
page: true,
done: function (res, curr, count, origin) {
layui.table.on('tool(dataRecybinTable)', function (elem) {
var data = elem.data;
switch (elem.event) {
case 'restore':
active.dataRestore([data.id]);
break;
case 'delete':
active.dataDelete([data.id], 1);
break;
}
});
layui.table.on('toolbar(dataRecybinTable)', function (elem) {
switch (elem.event) {
case 'dataDelete':
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let idss = checkData.map((item, index, array) => { return item.id; });
active.dataDelete(idss, 1);
break;
case 'dataRestore':
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
active.dataRestore(ids);
break;
};
});
layui.table.render({
elem: '#dataRecybinTable',
url: 'recyclebin',
height: 'full-100',
defaultToolbar: [{
title: '批量删除数据',
layEvent: 'dataDelete',
icon: 'layui-icon-delete',
onClick: function (obj) {
var checkStatus = treeTable.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
console.log(ids);
}
});
}
});
},
dataRestore: function (ids) {
if (ids.length === 0) {
return layer.msg('请选择数据');
}
http.put('restore', { ids: ids.join(',') })
.then((res) => {
if (res.code === 0) {
layer.msg('恢复成功');
table.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '恢复失败');
}, 'filter', 'exports', 'print'],
cols: [[
{ type: 'checkbox', fixed: 'left' },
{ field: 'id', title: 'ID', width: 80, sort: true, fixed: 'left' },
{ field: 'title', title: 'title', width: 180, fixed: 'left' },
{
fixed: "right", title: "操作", width: 120, align: "center", templet: function (d) {
return `<div class="layui-btn-group">
<a class="layui-btn layui-btn-sm" title="恢复数据" lay-event="restore" > <i class="layui-icon layui-icon-edit"></i> </a >
<a class="layui-btn layui-btn-sm" title="删除数据" lay-event="delete"><i class="layui-icon layui-icon-delete"></i> </a>
</div >`;
}
}
]],
page: true,
done: function (res, curr, count, origin) {
layui.table.on('tool(dataRecybinTable)', function (elem) {
var data = elem.data;
switch (elem.event) {
case 'restore':
active.dataRestore([data.id]);
break;
case 'delete':
active.dataDelete([data.id], 1);
break;
}
});
layui.table.on('toolbar(dataRecybinTable)', function (elem) {
switch (elem.event) {
case 'dataDelete':
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let idss = checkData.map((item, index, array) => { return item.id; });
active.dataDelete(idss, 1);
break;
case 'dataRestore':
var checkStatus = table.checkStatus('dataRecybinTable'), checkData = checkStatus.data;
let ids = checkData.map((item, index, array) => { return item.id; });
active.dataRestore(ids);
break;
};
});
}
});
}
});
},
dataRestore: function (ids) {
if (ids.length === 0) {
return layer.msg('请选择数据');
}
};
});
</script>
</body>
http.put('restore', { ids: ids.join(',') })
.then((res) => {
if (res.code === 0) {
layer.msg('恢复成功');
table.reload('dataTable', {}, true);
} else {
layer.msg(res.msg || '恢复失败');
}
});
}
};
});
</html>
</script>
+43 -16
View File
@@ -62,24 +62,14 @@
<div class="section-title">
<h2>📋 {$info.title}</h2>
</div>
<div class="website-list">
{volist name="info.links" id="link"}
<a href="/haonav/site/{$link.id}.html" class="website-item">
<span class="website-item-icon"><img src="{$link.show_icon}" loading="lazy" alt="{$link.title}"></span>
<span class="website-item-info">
<h3>{$link.title}</h3>
<p>{$link.description|default='暂无描述'}</p>
</span>
<span class="qr-btn js-qr" data-url="{$link.url}" title="二维码"></span>
</a>
{/volist}
</div>
<!-- 二级子分类 -->
{if $mode == 'group'}
<!-- 顶级分类:按二级子分类分组展示 -->
{volist name="info.children" id="sub"}
<div class="sub-category" style="margin-top:24px;">
<div class="sub-title"><span>{$sub.icon} {$sub.title}</span></div>
<div class="sub-category">
<div class="sub-title"><span>{$sub.title}</span></div>
<div class="website-list">
{volist name="sub.links" id="link"}
{volist name="sub.sub_links" id="link"}
<a href="/haonav/site/{$link.id}.html" class="website-item">
<span class="website-item-icon"><img src="{$link.show_icon}" loading="lazy" alt="{$link.title}"></span>
<span class="website-item-info">
@@ -92,6 +82,43 @@
</div>
</div>
{/volist}
<!-- 未细分二级的常用链接(sub_cid=0) -->
{notempty name="others"}
<div class="sub-category">
<div class="sub-title"><span>常用</span></div>
<div class="website-list">
{volist name="others" id="link"}
<a href="/haonav/site/{$link.id}.html" class="website-item">
<span class="website-item-icon"><img src="{$link.show_icon}" loading="lazy" alt="{$link.title}"></span>
<span class="website-item-info">
<h3>{$link.title}</h3>
<p>{$link.description|default='暂无描述'}</p>
</span>
<span class="qr-btn js-qr" data-url="{$link.url}" title="二维码"></span>
</a>
{/volist}
</div>
</div>
{/notempty}
{else /}
<!-- 二级/叶子分类:直接列出该分类下的全部链接 -->
<div class="sub-category">
<div class="sub-title"><span>{$info.title}</span></div>
<div class="website-list">
{volist name="links" id="link"}
<a href="/haonav/site/{$link.id}.html" class="website-item">
<span class="website-item-icon"><img src="{$link.show_icon}" loading="lazy" alt="{$link.title}"></span>
<span class="website-item-info">
<h3>{$link.title}</h3>
<p>{$link.description|default='暂无描述'}</p>
</span>
<span class="qr-btn js-qr" data-url="{$link.url}" title="二维码"></span>
</a>
{/volist}
</div>
</div>
{/if}
</div>
<div class="footer">
+7 -19
View File
@@ -143,27 +143,15 @@
<!-- 分类区块 -->
<div class="category-block" id="cat-{$k}">
<div class="section-title">
<h2>📋 <a href="/haonav/category/{$vo.id}.html" target="_blank"> {$vo.title} </a></h2>
<h2>📋 <a href="/haonav/category/{$vo.id}.html" target="_blank"> {$vo.title} </a></h2> <div class="sub-title"><span>全部</span> <a class="sub-more" href="/haonav/category/{$vo.id}.html">更多 </a></div>
</div>
<div class="website-grid">
{volist name="vo.links" id="site"}
<div class="website-card" data-sid="{$site.id}">
<span class="fav-btn js-fav" data-id="{$site.id}" data-title="{$site.title}" data-url="{$site.url}" data-icon="{$site.show_icon}" title="收藏"></span>
<span class="qr-btn js-qr" data-url="{$site.url}" title="二维码"></span>
<a href="/haonav/site/{$site.id}.html" target="_blank" title="{$site.title}" style="text-decoration:none;color:inherit;display:block;">
<div class="website-icon"><img src="{$site.show_icon}" loading="lazy" alt="{$site.title}"></div>
<div class="website-name">{$site.title}</div>
{if $site.rating_count > 0}<div class="website-rating">⭐ {$site.rating}<span class="rc">({$site.rating_count})</span></div>{/if}
</a>
</div>
{/volist}
</div>
<!-- 二级子分类 -->
{volist name="vo.children" id="sub"}
<!-- 首页只展示一级分类下的全部链接,二级分类在分类页展示 -->
{notempty name="vo.links"}
<div class="sub-category">
<div class="sub-title"><span>{$sub.icon} {$sub.title}</span></div>
<div class="website-grid">
{volist name="sub.links" id="site"}
{volist name="vo.links" id="site"}
<div class="website-card" data-sid="{$site.id}">
<span class="fav-btn js-fav" data-id="{$site.id}" data-title="{$site.title}" data-url="{$site.url}" data-icon="{$site.show_icon}" title="收藏"></span>
<span class="qr-btn js-qr" data-url="{$site.url}" title="二维码"></span>
@@ -176,7 +164,7 @@
{/volist}
</div>
</div>
{/volist}
{/notempty}
</div>
{/volist}
+14 -14
View File
@@ -18,7 +18,7 @@ use ywxapp\AddonBase;
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Addon extends addon
class Addon extends AddonBase
{
/**
* 安装钩子:表由 install.sql 统一创建,这里无需额外处理
@@ -33,19 +33,19 @@ class Addon extends addon
*/
public function uninstall()
{
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_connection`");
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_message`");
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_topic`");
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_auth`");
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_acl`");
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_retain`");
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_offline`");
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_stats`");
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_rule`");
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_outbox`");
// 清理框架菜单(后端权限 + 前端/会员规则)
Db::execute("DELETE FROM wxapp_user_rule WHERE name LIKE 'mqttbroker:%'");
Db::execute("DELETE FROM wxapp_admin_power WHERE addon='mqttbroker'");
$prefix = Db::getConfig('prefix');
$tables = [
'mqttbroker_connection', 'mqttbroker_message', 'mqttbroker_topic',
'mqttbroker_auth', 'mqttbroker_acl', 'mqttbroker_retain',
'mqttbroker_offline', 'mqttbroker_stats', 'mqttbroker_rule',
'mqttbroker_outbox',
];
foreach ($tables as $t) {
Db::execute("DROP TABLE IF EXISTS `{$prefix}{$t}`");
}
// 清理框架菜单(后端权限 + 前端/会员规则),前缀同样动态获取
Db::execute("DELETE FROM `{$prefix}user_rule` WHERE name LIKE 'mqttbroker:%'");
Db::execute("DELETE FROM `{$prefix}admin_power` WHERE addon='mqttbroker'");
return true;
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ class TestAcl extends Command
// 插入临时规则(测试后清理)
$rows = [
['target_type' => 'member', 'target' => 'alice', 'topic' => 'alice/secret', 'access' => 2, 'allow' => 0, 'sort' => 5, 'remark' => 'tmp'],
['target_type' => 'user', 'target' => 'alice', 'topic' => 'alice/secret', 'access' => 2, 'allow' => 0, 'sort' => 5, 'remark' => 'tmp'],
['target_type' => 'all', 'target' => '', 'topic' => 'secret/#', 'access' => 2, 'allow' => 0, 'sort' => 10, 'remark' => 'tmp'],
['target_type' => 'all', 'target' => '', 'topic' => 'public/#', 'access' => 3, 'allow' => 1, 'sort' => 20, 'remark' => 'tmp'],
];
+16
View File
@@ -75,6 +75,22 @@ return [
'value' => 'allow',
'tip' => '所有规则都未命中时的兜底策略',
],
[
'name' => 'allow_member_login',
'title' => '允许框架会员登录',
'type' => 'select',
'options' => ['0' => '关闭', '1' => '开启'],
'value' => '0',
'tip' => '开启后,框架 member_user 表的会员可用其账号密码直接连接 Broker(密码算法一致),无需在"认证账号"中重复建号',
],
[
'name' => 'member_is_superuser',
'title' => '会员默认超管',
'type' => 'select',
'options' => ['0' => '否', '1' => '是'],
'value' => '0',
'tip' => '会员直通连接是否视为超级用户(跳过 ACL);建议保持"否",用 ACL 规则按 %u 隔离',
],
[
'name' => 'max_keepalive',
'title' => '最大保活间隔(秒)',
+110
View File
@@ -0,0 +1,110 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace addon\mqttbroker\controller\api;
use think\facade\Db;
use ywxapp\controller\ApiBase;
use addon\mqttbroker\service\Store;
/**
* App MQTT API(需框架会员 JWT 登录)
* 路由前缀:/mqttbroker/api/...
*/
class App extends ApiBase
{
/**
* 当前会员账号(Broker 连接用户名通常为 member.account
*/
protected function memberAccount(): string
{
return (string) ($this->auth->model->account ?? '');
}
/**
* 我的设备 / 连接列表
* GET /mqttbroker/api/devices
*/
public function devices()
{
$account = $this->memberAccount();
if ($account === '') {
return $this->result->error('无法获取当前会员账号');
}
$list = Db::name('mqttbroker_connection')
->where('username', $account)
->order('update_at', 'desc')
->limit(200)
->field('client_id,username,ip,status,create_at,update_at')
->select()
->toArray();
return $this->result->success(['list' => $list, 'total' => count($list)]);
}
/**
* 我的订阅主题列表
* GET /mqttbroker/api/subscriptions
*/
public function subscriptions()
{
$account = $this->memberAccount();
if ($account === '') {
return $this->result->error('无法获取当前会员账号');
}
$clientIds = Db::name('mqttbroker_connection')
->where('username', $account)
->column('client_id');
$list = [];
if ($clientIds) {
$list = Db::name('mqttbroker_topic')
->whereIn('client_id', $clientIds)
->order('id', 'desc')
->field('client_id,topic,qos,create_at')
->select()
->toArray();
}
return $this->result->success(['list' => $list, 'total' => count($list)]);
}
/**
* 发布消息(服务端代发,强制命名空间隔离)
* POST /mqttbroker/api/publish
* param: topic, payload, qos(0/1/2), retain(0/1)
*/
public function publish()
{
$account = $this->memberAccount();
if ($account === '') {
return $this->result->error('无法获取当前会员账号');
}
$topic = trim((string) $this->request->post('topic', ''));
$payload = (string) $this->request->post('payload', '');
$qos = (int) $this->request->post('qos', 0);
$retain = (int) $this->request->post('retain', 0);
if ($topic === '' || $topic[0] === '$') {
return $this->result->error('主题不能为空,且禁止发布到 $SYS 等系统主题');
}
// 命名空间隔离:topic 必须以 {username}/ 开头,避免越权发到他人主题
$prefix = $account . '/';
if (strncmp($topic, $prefix, strlen($prefix)) !== 0) {
return $this->result->error("主题必须以 {$prefix} 开头(命名空间隔离)");
}
if (!in_array($qos, [0, 1, 2], true)) {
$qos = 0;
}
$ok = Store::enqueueManual($topic, $payload, $qos, $retain, 'app');
if (!$ok) {
return $this->result->error('发布失败,请稍后重试');
}
return $this->result->success(['topic' => $topic]);
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ declare (strict_types = 1);
namespace addon\mqttbroker\controller\api;
use think\facade\Db;
use ywxapp\controller\ApiController;
use ywxapp\controller\ApiBase;
use addon\mqttbroker\model\Message;
/**
@@ -19,7 +19,7 @@ use addon\mqttbroker\model\Message;
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Mqtt extends ApiController
class Mqtt extends ApiBase
{
protected $noNeedLogin = ['*'];
protected $noNeedVerify = ['*'];
@@ -26,9 +26,8 @@ use addon\mqttbroker\service\Store;
*/
class MqttBroker extends BackendBase
{
// 与 blog 后台一致:开发期放宽登录校验(生产请改回需登录)
// 插件后台由 BackendBase 强制登录校验,未登录访问一律拦截
protected $noNeedVerify = ['*'];
/**
* 概览
@@ -232,6 +231,14 @@ class MqttBroker extends BackendBase
* ========================================================== */
public function stats()
{
return View::fetch('admin/stats');
}
/**
* 运行监控数据接口(供 stats 页面轮询,避免与页面路由冲突)
*/
public function statsData()
{
$row = Db::name('mqttbroker_stats')->where('id', 1)->find();
if (!$row) {
+11 -2
View File
@@ -1,4 +1,13 @@
<?php
/*
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-07-10 17:47:52
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-08-17 17:41:29
* @Description:
* @FilePath: \ywxapp_dev\addon\mqttbroker\info.php
* @CustomString: Copyright (c) 2026 YwxApp
*/
return [
'name' => 'mqttbroker',
@@ -6,8 +15,8 @@ return [
'intro' => '基于 Workerman 自研的 MQTT 3.1.1/5.0 Broker(类 EMQX 轻量版):认证/ACL、WebSocket/TLS、保留与离线消息持久化、共享订阅、$SYS 指标、Redis 多进程桥接、实时仪表盘、转发规则(桥接EMQX)、手动发布零依赖出站队列,含后台管理',
'author' => '',
'website' => '',
'version' => '2.1.1',
'state' => 0,
'version' => '2.1.2',
'state' => 1,
'url' => '/mqttbroker/backend',
'license' => '',
'licenseto' => 0,
+1
View File
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS `__PREFIX__mqttbroker_auth` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(191) NOT NULL DEFAULT '' COMMENT '用户名',
`password` varchar(191) NOT NULL DEFAULT '' COMMENT '密码(bcrypt哈希,兼容明文)',
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属框架会员ID(0=非会员账号;会员直通时回填对应 member_user.id',
`is_superuser` tinyint(1) NOT NULL DEFAULT '0' COMMENT '超级用户跳过ACL',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用',
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
+8
View File
@@ -28,6 +28,7 @@ Route::group('backend', function () {
Route::post('deleteAcl', 'backend/MqttBroker/deleteAcl');
// 实时监控
Route::get('stats', 'backend/MqttBroker/stats');
Route::get('statsData', 'backend/MqttBroker/statsData');
// 转发规则(规则引擎 / 桥接到 EMQX)
Route::get('rule', 'backend/MqttBroker/rule');
Route::post('saveRule', 'backend/MqttBroker/saveRule');
@@ -38,4 +39,11 @@ Route::group('backend', function () {
Route::group('api', function () {
Route::get('status', 'api/Mqtt/status');
Route::post('publish', 'api/Mqtt/publish');
// App 端接口(需会员 JWT 登录):/mqttbroker/api/app/...
Route::group('app', function () {
Route::get('devices', 'api/App/devices');
Route::get('subscriptions', 'api/App/subscriptions');
Route::post('publish', 'api/App/publish');
});
});
+28 -2
View File
@@ -89,6 +89,12 @@ class Auth
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_sort` (`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// 自愈:老表补 member_id 列(框架会员直通用)
$prefix = Db::getConfig('prefix');
$has = Db::query("SHOW COLUMNS FROM `{$prefix}mqttbroker_auth` LIKE 'member_id'");
if (empty($has)) {
Db::execute("ALTER TABLE `{$prefix}mqttbroker_auth` ADD `member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属框架会员ID(会员直通回填)'");
}
} catch (\Throwable $e) {
}
}
@@ -120,7 +126,27 @@ class Auth
}
if (!$acc) {
// 无此账号:匿名开时视为普通匿名用户(用户名仅作标识),否则拒绝
// 无此账号:尝试框架会员直通(仅当显式开启且禁止匿名,避免被匿名语义绕过)
if (!$anonymous && !empty($this->config['allow_member_login'])) {
$member = null;
try {
$member = Db::name('member_user')
->where('account', $username)
->where('status', 1)
->field('id,password')
->find();
} catch (\Throwable $e) {
$member = null;
}
// 会员密码使用 bcrypt 原生盐(详见 ywxapp\model\MemberUser),
// 表无独立 salt 列,直接 verify 即可。
if ($member && !empty($member['password'])
&& password_verify((string) $password, $member['password'])) {
$isSuper = !empty($this->config['member_is_superuser']);
return ['ok' => true, 'superuser' => $isSuper, 'reason' => 'member', 'member_id' => $member['id']];
}
}
// 匿名开时视为普通匿名用户(用户名仅作标识),否则拒绝
return $anonymous
? ['ok' => true, 'superuser' => false, 'reason' => 'anonymous-named']
: ['ok' => false, 'superuser' => false, 'reason' => 'account not found'];
@@ -170,7 +196,7 @@ class Auth
continue;
}
$type = $r['target_type'];
if ($type === 'member' && $r['target'] !== $username) {
if ($type === 'user' && $r['target'] !== $username) {
continue;
}
if ($type === 'client' && $r['target'] !== $clientId) {
+8
View File
@@ -35,3 +35,11 @@ ALTER TABLE `__PREFIX__mqttbroker_rule`
ALTER TABLE `__PREFIX__mqttbroker_outbox`
CHANGE `create_time` `create_at` INT DEFAULT NULL;
-- 会员直通用:补 member_id 列(兼容已装旧库;重复执行不会报错)
SET @db = DATABASE();
SET @has = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = '__PREFIX__mqttbroker_auth' AND COLUMN_NAME = 'member_id');
SET @sql = IF(@has = 0, 'ALTER TABLE `__PREFIX__mqttbroker_auth` ADD `member_id` int unsigned NOT NULL DEFAULT 0 COMMENT ''归属框架会员ID(会员直通回填)''', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+1 -3
View File
@@ -1,6 +1,4 @@
{extend name="layout"}
<div class="layui-container">
<div class="layui-container">
<div class="admin-title">ACL 权限规则</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
需在"服务设置"中开启 ACL 才生效。按 sort 升序匹配,命中第一条即决定放行/拒绝;
@@ -1,6 +1,4 @@
{extend name="layout"}
<div class="layui-container">
<div class="layui-container">
<div class="admin-title">认证账号</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
当"允许匿名连接"关闭时,客户端必须使用此处账号连接;超级用户跳过 ACL 校验。
@@ -8,7 +6,7 @@
<button class="layui-btn layui-btn-sm" onclick="editAuth()">+ 新增账号</button>
<table class="layui-table" style="margin-top:10px;">
<thead>
<tr><th>ID</th><th>用户名</th><th>超级用户</th><th>状态</th><th>备注</th><th>操作</th></tr>
<tr><th>ID</th><th>用户名</th><th>超级用户</th><th>会员</th><th>状态</th><th>备注</th><th>操作</th></tr>
</thead>
<tbody>
{foreach $list as $c}
@@ -16,6 +14,7 @@
<td>{$c.id}</td>
<td>{$c.username}</td>
<td>{if $c.is_superuser}<span class="layui-badge layui-bg-orange"></span>{else}否{/if}</td>
<td>{if $c.member_id}<span class="layui-badge layui-bg-blue">M{$c.member_id}</span>{else}<span style="color:#aaa;"></span>{/if}</td>
<td>{if $c.status}<span class="layui-badge layui-bg-green">启用</span>{else}<span class="layui-badge">禁用</span>{/if}</td>
<td>{$c.remark}</td>
<td>
@@ -1,6 +1,4 @@
{extend name="layout"}
<div class="layui-container">
<div class="layui-container">
<div class="admin-title">转发规则(规则引擎 / 桥接到 EMQX)</div>
<div class="layui-text" style="margin-bottom:10px;color:#999;">
匹配"源主题过滤器"的消息,将被转发到外部 MQTT Broker(如 EMQX)。目标主题支持
@@ -59,7 +59,7 @@ layui.use(['jquery'], function(){
return p(h)+':'+p(m)+':'+p(ss);
}
function load(){
$.getJSON('/mqttbroker/backend/stats', function(res){
$.getJSON('/mqttbroker/backend/statsData', function(res){
if (res.code !== 0 || !res.data) return;
var d = res.data;
$('#m_online').text(d.db_online != null ? d.db_online : d.clients_online);
+6 -10
View File
@@ -10,24 +10,21 @@ declare (strict_types = 1);
namespace addon\smsbao;
use ywxapp\AddonBase;
use ywxapp\library\Menu;
use ywxapp\AddonBase;
/**
* Addon
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Addon extends addon
class Addon extends AddonBase
{
/**
* 插件安装方法
* @return bool
*/
public function install()
{
$menu = [];
Menu::create($menu);
{
return true;
}
@@ -36,8 +33,7 @@ class Addon extends addon
* @return bool
*/
public function uninstall()
{
Menu::delete('smsbao');
{
return true;
}
@@ -46,7 +42,7 @@ class Addon extends addon
*/
public function enable()
{
Menu::enable('smsbao');
return true;
}
/**
@@ -54,7 +50,7 @@ class Addon extends addon
*/
public function disable()
{
Menu::disable('smsbao');
return true;
}
/**
+2 -2
View File
@@ -10,14 +10,14 @@
// 默认值仅为迁移占位,请在生产环境替换为自有账号与签名。
return [
[
'name' => 'u',
'name' => 'user',
'title' => '短信宝账号',
'type' => 'string',
'value' => 'cqmqzp',
'tip' => '短信宝平台账号,请替换为自己的账号',
],
[
'name' => 'p',
'name' => 'pass',
'title' => '短信宝密码',
'type' => 'string',
'value' => 'a645b6f80c0a40b69156ca0fd9d47d7c',
+8 -12
View File
@@ -6,22 +6,18 @@ return [
'intro' => '',
'author' => '',
'website' => '',
'version' => '1.0.1',
'state' => 0,
'version' => '1.0.2',
'state' => 1,
'url' => '/smsbao',
'license' => '',
'licenseto' => 0,
'config' => [
],
'events' => [
'subscribe' =>
[
0 => 'addon\\smsbao\\subscribe\\Smsbao',
'config' => [ ],
'events' => [
'subscribe' => [
'addon\smsbao\subscribe\Smsbao',
],
],
'middleware' => [
],
'services' => [
],
'middleware' => [ ],
'services' => [ ],
'update_time' => 1786365220,
];
+1 -1
View File
@@ -17,7 +17,7 @@ use ywxapp\AddonBase;
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Addon extends addon
class Addon extends AddonBase
{
public function install()
+2 -2
View File
@@ -12,8 +12,8 @@ return [
'intro' => '生产环境真正下发短信:监听 SmsSend 事件,调用可配置网关 HTTP 接口',
'author' => 'Ywxapp',
'website' => '',
'version' => '1.0.1',
'state' => 1,
'version' => '1.0.2',
'state' => 0,
'url' => '/smssend',
'license' => '',
'licenseto' => 0,
+3 -3
View File
@@ -53,7 +53,7 @@ class Friend extends FrontendBase
->select();
$list = [];
foreach ($friends as $f) {
$u = Db::name('member')->where('uid', $f->fid)
$u = Db::name('member_user')->where('uid', $f->fid)
->field('uid,nickname,avatar,phone')->find();
if ($u) {
$u['friend_id'] = $f->id;
@@ -76,7 +76,7 @@ class Friend extends FrontendBase
->select();
$list = [];
foreach ($rows as $r) {
$u = Db::name('member')->where('uid', $r->uid)
$u = Db::name('member_user')->where('uid', $r->uid)
->field('uid,nickname,avatar')->find();
if ($u) {
$u['friend_id'] = $r->id;
@@ -151,7 +151,7 @@ class Friend extends FrontendBase
if (! $row) {
$this->error('好友不存在');
}
$u = Db::name('member')->where('uid', $row->fid)
$u = Db::name('member_user')->where('uid', $row->fid)
->field('uid,nickname,avatar,phone')->find();
if ($u) {
$u['friend_id'] = $row->id;
+1 -1
View File
@@ -67,7 +67,7 @@ class Gift extends FrontendBase
if (! $gift) {
$this->error('礼物不存在');
}
$receiver = Db::name('member')->where('uid', $toUid)->value('uid');
$receiver = Db::name('member_user')->where('uid', $toUid)->value('uid');
if (! $receiver) {
$this->error('接收用户不存在');
}
+11 -11
View File
@@ -65,9 +65,9 @@ class Login extends FrontendBase
if (! $ret) {
$this->error("验证码不正确!");
}
$user = Db::name('member')->where('mobile', $mobile)->where('status', '>=', 0)->find();
$user = Db::name('member_user')->where('mobile', $mobile)->where('status', '>=', 0)->find();
if (empty($user)) {
$uid = Db::name('member')->insertGetId([
$uid = Db::name('member_user')->insertGetId([
'account' => $mobile,
'mobile' => $mobile,
'nickname' => $this->generateRandomNickname(),
@@ -78,9 +78,9 @@ class Login extends FrontendBase
'update_at' => time(),
'update_ip' => Request::ip(),
]);
$user = Db::name('member')->find($uid);
$user = Db::name('member_user')->find($uid);
} else {
Db::name('member')->where('uid', $user['uid'])->update([
Db::name('member_user')->where('uid', $user['uid'])->update([
'update_at' => time(),
'update_ip' => Request::ip(),
]);
@@ -114,9 +114,9 @@ class Login extends FrontendBase
}
Cache::delete('wxchat_oneclick_' . $preToken);
$user = Db::name('member')->where('mobile', $phone)->where('status', '>=', 0)->find();
$user = Db::name('member_user')->where('mobile', $phone)->where('status', '>=', 0)->find();
if (empty($user)) {
$uid = Db::name('member')->insertGetId([
$uid = Db::name('member_user')->insertGetId([
'account' => $phone,
'mobile' => $phone,
'nickname' => $this->generateRandomNickname(),
@@ -127,9 +127,9 @@ class Login extends FrontendBase
'update_at' => time(),
'update_ip' => Request::ip(),
]);
$user = Db::name('member')->find($uid);
$user = Db::name('member_user')->find($uid);
} else {
Db::name('member')->where('uid', $user['uid'])->update([
Db::name('member_user')->where('uid', $user['uid'])->update([
'update_at' => time(),
'update_ip' => Request::ip(),
]);
@@ -167,7 +167,7 @@ class Login extends FrontendBase
$phone = $tempData['phone'];
// 检查手机号是否已被注册
$exists = Db::name('member')
$exists = Db::name('member_user')
->where('mobile', $phone)
->where('status', 1)
->find();
@@ -196,7 +196,7 @@ class Login extends FrontendBase
'update_ip' => Request::ip(),
];
$userId = Db::name('member')->insertGetId($userData);
$userId = Db::name('member_user')->insertGetId($userData);
if (! $userId) {
throw new \Exception('用户注册失败');
}
@@ -205,7 +205,7 @@ class Login extends FrontendBase
$this->issueToken($userId);
$this->logUserRegister($userId, $userData);
$user = Db::name('member')->find($userId);
$user = Db::name('member_user')->find($userId);
$this->success([
'user_info' => $this->formatUserInfo($user),
'is_registered' => true,
+1 -1
View File
@@ -178,7 +178,7 @@ class Task extends FrontendBase
->where('create_at', '>=', $today)
->count();
case 'profile':
$user = Db::name('member')->where('uid', $uid)->field('nickname,avatar')->find();
$user = Db::name('member_user')->where('uid', $uid)->field('nickname,avatar')->find();
$profile = Db::name('member_profile')->where('uid', $uid)
->field('bio,birthday,gender')->find();
$score = 0;
+3 -3
View File
@@ -58,7 +58,7 @@ class Member extends FrontendBase
if (! $profile) {
$this->error('用户不存在');
}
$user = Db::name('member')->where('uid', $targetUid)
$user = Db::name('member_user')->where('uid', $targetUid)
->field('uid,nickname,avatar,account,status')->find();
$pc = ! empty($profile->privacy_config) ? json_decode($profile->privacy_config, true) : [];
@@ -538,7 +538,7 @@ class Member extends FrontendBase
$limit = 50;
if ($type == 'rich') {
$list = Db::name('member_wallets')->alias('w')
$list = Db::name('member_wallet')->alias('w')
->join('user u', 'u.uid = w.uid')
->order('w.coins', 'desc')
->limit($limit)
@@ -578,7 +578,7 @@ class Member extends FrontendBase
public function wallet()
{
$uid = $this->uid;
$row = Db::name('member_wallets')->where('uid', $uid)->find();
$row = Db::name('member_wallet')->where('uid', $uid)->find();
if (empty($row)) {
$row = [
'balance' => '0.00',
+1 -1
View File
@@ -23,7 +23,7 @@ return [
'intro' => '',
'author' => '',
'website' => '',
'version' => '1.0.1',
'version' => '1.0.2',
'state' => 0,
'url' => '/wxchat',
'license' => '',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -11,7 +11,7 @@ declare(strict_types=1);
namespace app\Api\Controller\V1;
use ywxapp\controller\ApiController;
use ywxapp\controller\ApiBase;
use ywxapp\service\RemoteService;
use think\Request;
@@ -26,7 +26,7 @@ use think\Request;
*
* 中心站领域数据(appmarket_* 表)已全部收敛到 addon/appmall,核心不再直接读写。
*/
class Addon extends ApiController
class Addon extends ApiBase
{
// notify/payResult 由支付平台回调/跳转,无需登录;info 公开
protected $noNeedLogin = ['info', 'notify', 'payResult'];
+2 -2
View File
@@ -11,10 +11,10 @@ declare (strict_types = 1);
namespace app\Api\Controller\V1;
use think\facade\Validate;
use ywxapp\controller\ApiController;
use ywxapp\controller\ApiBase;
use addon\articles\model\Article as ArticleModel;
class Article extends ApiController
class Article extends ApiBase
{
protected $noNeedLogin = ['*'];
protected $needRight = ['*'];
+132
View File
@@ -0,0 +1,132 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\api\controller\v1;
use think\facade\Filesystem;
use ywxapp\controller\ApiBase;
use ywxapp\model\SystemConfig;
/**
* 通用基础接口(App 启动所需,聊天 / 商城等应用共用)
*
* 路由前缀:/api/v1/common/<action>
* 鉴权:config / init 公开;upload 需登录(防滥用)。
*
* @package app\api\controller\v1
*/
class Common extends ApiBase
{
/**
* 免登录(公开)接口白名单。
*
* @var array<int, string>
*/
protected $noNeedLogin = ['config', 'init'];
/**
* App 启动初始化数据(公开)。
*
* 返回站点基础信息、可用的注册方式开关、接口版本等,供客户端冷启动时读取。
*
* @return \think\Response JSON 响应,携带 site / register_methods / version
*
* @route GET /api/v1/common/init
*/
public function init()
{
$config = $this->siteConfig();
$data = [
'site' => $config,
'register_methods' => [
'password' => 1,
'sms' => 1,
'email' => 1,
],
'version' => '1.0.0',
];
return $this->apiSuccess($data);
}
/**
* 获取站点配置(公开)。
*
* 返回站点名称、Logo、备案号等基础配置。
*
* @return \think\Response JSON 响应,携带站点配置数组
*
* @route GET /api/v1/common/config
*/
public function config()
{
return $this->apiSuccess($this->siteConfig());
}
/**
* 文件上传(需登录)。
*
* 接收 multipart/form-data 中的 file 字段,保存到本地存储并返回可访问 URL。
*
* @param \think\file\UploadedFile $file 上传的文件(form-data: file
*
* @return \think\Response JSON 响应,成功携带 url / path
*
* @throws \Throwable 当文件存储失败时
*
* @route POST /api/v1/common/upload
*/
public function upload()
{
$file = $this->request->file('file');
if (!$file) {
return $this->apiError('请选择上传文件');
}
try {
$path = Filesystem::disk('local')->putFile('uploads', $file);
$url = Filesystem::disk('local')->url($path);
return $this->apiSuccess([
'url' => $url,
'path' => $path,
], '上传成功');
} catch (\Throwable $e) {
return $this->apiError('上传失败:' . $e->getMessage());
}
}
/**
* 读取站点配置。
*
* 优先从 SystemConfig 表读取,表不存在或字段缺失时回退到默认值。
*
* @return array<string, mixed> 站点配置数组(name / logo / icp
*/
protected function siteConfig(): array
{
$defaults = [
'name' => 'YwxApp',
'logo' => '',
'icp' => '',
];
try {
$rows = SystemConfig::column('value', 'name');
if ($rows) {
foreach ($defaults as $k => $v) {
if (isset($rows[$k])) {
$defaults[$k] = $rows[$k];
}
}
}
} catch (\Throwable $e) {
// 表不存在时用默认值
}
return $defaults;
}
}

Some files were not shown because too many files have changed in this diff Show More