better-staridc-MNBT
1<?php
2/** 应用商店 */
3$active = 'appstore';
4$cpu_max = $plan ? $plan['cpu_max'] : '1';
5$mem_max = $plan ? $plan['mem_max'] : '512';
6$disk_max = $plan ? $plan['disk_max'] : '0';
7$proxy_max = $plan ? $plan['proxy_max'] : '0';
8include __DIR__ . '/head.php';
9?>
10<?php if (!empty($me['container_id']) || !empty($me['service_name'])): ?>
11 <div class="dk-alert dk-alert-warn">您已创建容器,单容器模型下无法再次创建。如需更换应用,请联系管理员先删除现有容器。</div>
12<?php endif; ?>
13
14<div class="dk-card">
15 <div class="dk-card-head">
16 <h3>应用商店</h3>
17 <input class="dk-input" id="dkAppSearch" placeholder="搜索应用名称…" style="width:260px;padding:8px 14px">
18 </div>
19 <div class="dk-card-body">
20 <div class="dk-spinner-overlay" id="dkAppLoading"><span class="dk-spin"></span> 正在加载应用列表…</div>
21 <div class="dk-grid" id="dkAppGrid" style="display:none"></div>
22 </div>
23</div>
24
25<script>
26(function(){
27 var APPS = [];
28 var CPU_MAX = <?= json_encode((float)$cpu_max) ?>;
29 var MEM_MAX = <?= json_encode((float)$mem_max) ?>;
30 var DISK_MAX = <?= json_encode((float)$disk_max) ?>;
31 var PROXY_MAX = <?= json_encode((int)$proxy_max) ?>;
32 var HAS_CONTAINER = <?php echo (!empty($me['container_id']) || !empty($me['service_name'])) ? 'true' : 'false'; ?>;
33
34 function esc(s){ return $('<div>').text(s == null ? '' : String(s)).html(); }
35 function appIconChar(name){
36 return (name || '?').charAt(0).toUpperCase();
37 }
38 function typeLabel(t){
39 var map = { BuildWebsite:'建站', Database:'数据库', Storage:'存储', DevTool:'开发工具', Media:'媒体', Other:'其他' };
40 return map[t] || t || '应用';
41 }
42
43 function renderGrid(list){
44 var grid = $('#dkAppGrid');
45 if (!list.length) { grid.html('<div class="dk-empty" style="grid-column:1/-1"><div class="dk-empty-ico">空的</div><p>没有匹配的应用</p></div>'); return; }
46 var html = '';
47 list.forEach(function(app){
48 var versions = (app.appversion || []).map(function(v){ return v.m_version; }).join(' / ') || '-';
49 html += '<div class="dk-app-card">' +
50 '<div class="dk-app-head"><div class="dk-app-icon">'+ esc(appIconChar(app.appname)) +'</div>' +
51 '<div><h4>'+ esc(app.apptitle || app.appname) +'</h4><div class="dk-app-type">'+ esc(typeLabel(app.apptype)) +' · v'+ esc(versions) +'</div></div></div>' +
52 '<div class="dk-app-desc">'+ esc(app.desc || app.description || ('应用标识:'+ app.appname)) +'</div>' +
53 '<div class="dk-app-foot"><span class="dk-muted dk-mono" style="font-size:12px">'+ esc(app.appname) +'</span>' +
54 '<button class="dk-btn dk-btn-sm" onclick="dkOpenApp(\''+ esc(app.appname) +'\')">安装</button></div>' +
55 '</div>';
56 });
57 grid.html(html);
58 }
59
60 function loadApps(){
61 dkAjax('app_list', {}, {silent:true, timeout:90000}).then(function(r){
62 APPS = r.data && r.data.data ? r.data.data : (r.data || []);
63 $('#dkAppLoading').hide();
64 $('#dkAppGrid').show();
65 renderGrid(APPS);
66 }).fail(function(){
67 $('#dkAppLoading').html('<div class="dk-empty"><div class="dk-empty-ico">⚠</div><h4>加载失败</h4><p>无法获取应用列表,请确认节点已安装 Docker 并初始化应用商店。</p></div>');
68 });
69 }
70
71 $('#dkAppSearch').on('input', function(){
72 var q = $(this).val().toLowerCase();
73 renderGrid(APPS.filter(function(a){
74 var t = ((a.appname||'')+' '+(a.apptitle||'')+' '+(a.apptype||'')).toLowerCase();
75 return t.indexOf(q) !== -1;
76 }));
77 });
78
79 window.dkOpenApp = function(appname){
80 if (HAS_CONTAINER) { dkToast('您已创建容器,无法再次创建', 'error'); return; }
81 var app = null;
82 for (var i=0;i<APPS.length;i++){ if (APPS[i].appname === appname){ app = APPS[i]; break; } }
83 if (!app) { dkToast('应用不存在', 'error'); return; }
84 // 检查依赖
85 dkOpenInstallModal(app);
86 };
87
88 function dkRandomStr(len){
89 var chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
90 var s = '';
91 for (var i=0; i<(len||12); i++) s += chars.charAt(Math.floor(Math.random()*chars.length));
92 return s;
93 }
94 function dkRandomUser(len){
95 var chars = 'abcdefghijkmnpqrstuvwxyz23456789';
96 var s = '';
97 for (var i=0; i<(len||10); i++) s += chars.charAt(Math.floor(Math.random()*chars.length));
98 return s;
99 }
100 function dkRandomPort(){
101 return Math.floor(Math.random() * 50000) + 10000;
102 }
103 function fieldInput(key, def, desc, type){
104 var t = type || 'string';
105 // 标签:优先用 desc(宝塔 field.name 会作为 desc 传入),其次用 key 去下划线
106 var label = desc ? esc(desc) : esc(key).replace(/_/g, ' ');
107 var isPwdField = (t === 'password' || t === 'secret' || /password|secret|passwd|pwd/i.test(key));
108 var isUserField = (/user|username|admin/i.test(key) && !/host|ip|email|domain/i.test(key));
109 var isPortField = (t === 'port' || /port/i.test(key));
110 if (def === 'random') def = dkRandomStr(12);
111 else if (isPwdField && (def === '' || def == null)) def = dkRandomStr(12);
112 else if (isUserField && (def === '' || def == null)) def = dkRandomUser(10);
113 else if (isPortField && (def === '' || def == null || def === '0')) def = dkRandomPort();
114 if (t === 'checkbox') {
115 var checked = def === true || def === 'true' || def === '1' ? 'checked' : '';
116 return '<div class="dk-field dk-field-full"><label>'+ label +'</label><label><input type="checkbox" name="'+ esc(key) +'" value="1" '+ checked +'> 启用</label></div>';
117 }
118 if (t === 'textarea') {
119 return '<div class="dk-field dk-field-full"><label>'+ label +'</label><textarea class="dk-input" name="'+ esc(key) +'" rows="2">'+ esc(def || '') +'</textarea></div>';
120 }
121 var inputType = (t === 'port' || t === 'number') ? 'number' : (isPwdField ? 'password' : 'text');
122 return '<div class="dk-field"><label>'+ label +'</label><input class="dk-input" type="'+ inputType +'" name="'+ esc(key) +'" value="'+ esc(def || '') +'"></div>';
123 }
124
125 function dkOpenInstallModal(app){
126 var versions = app.appversion || [];
127 // 构建版本下拉选项(version 字段专用)
128 var verOpts = '';
129 versions.forEach(function(v){
130 var sub = (v.s_version || []);
131 if (sub && sub.length) {
132 sub.forEach(function(sv){ verOpts += '<option value="'+ esc(v.m_version) +'|'+ esc(sv) +'">'+ esc(v.m_version) +'.'+ esc(sv) +'</option>'; });
133 } else {
134 // 无子版本时 s_version 留空:宝塔后端会用 m_version 作为镜像 tag
135 // 之前 fallback 成 '0' 会导致 frps 这类应用拼出 latest.0 无效 tag
136 verOpts += '<option value="'+ esc(v.m_version) +'|">'+ esc(v.m_version) +'</option>';
137 }
138 });
139 // version 字段渲染为下拉框,而非普通输入
140 function versionFieldHtml(){
141 return '<div class="dk-field"><label>版本选择</label><select class="dk-select" name="dk_version">'+ verOpts +'</select></div>';
142 }
143
144 // 字段标签:优先用 field.name(中文),其次用 desc,最后用 key 去掉下划线
145 function fieldLabel(key, name, desc){
146 if (name) return esc(name);
147 if (desc) return esc(desc);
148 return esc(key).replace(/_/g, ' ');
149 }
150
151 var envHtml = '';
152 // 系统字段 + 宝塔内部管理字段(用户无需填写)
153 var seenKeys = {'cpus':1, 'memory_limit':1, 'allow_access':1, 'dk_version':1, 'version':1, 'app_path':1, 'host_ip':1};
154 (app.env || []).forEach(function(e){
155 var k = e.key; if (!k || seenKeys[k]) return; seenKeys[k] = 1;
156 envHtml += fieldInput(k, e.default || '', e.desc, e.type);
157 });
158 (app.field || []).forEach(function(f){
159 var k = f.attr; if (!k || seenKeys[k]) return; seenKeys[k] = 1;
160 envHtml += fieldInput(k, f.default || '', f.name, f.type);
161 });
162
163 var depHtml = '';
164 if (app.depend && app.depend.length) {
165 depHtml = '<div class="dk-alert dk-alert-warn">此应用依赖:'+ app.depend.map(function(d){ return esc((d.appname||[]).join(',')); }).join('、') +',请确保依赖应用已安装。</div>';
166 }
167
168 var body =
169 '<div class="dk-app-head" style="margin-bottom:14px"><div class="dk-app-icon">'+ esc(appIconChar(app.appname)) +'</div>' +
170 '<div><h4>'+ esc(app.apptitle||app.appname) +'</h4><div class="dk-app-type">'+ esc(app.desc||'') +'</div></div></div>' +
171 depHtml +
172 '<form id="dkInstallForm">' +
173 // version 下拉(替换原来的空白输入框)
174 versionFieldHtml() +
175 '<div class="dk-quota-bar">' +
176 '<div class="dk-quota-item"><span class="dk-quota-label">CPU</span><span class="dk-quota-val">'+ CPU_MAX +' 核</span></div>' +
177 '<div class="dk-quota-item"><span class="dk-quota-label">内存</span><span class="dk-quota-val">'+ MEM_MAX +' MB</span></div>' +
178 '<div class="dk-quota-item"><span class="dk-quota-label">磁盘</span><span class="dk-quota-val">'+ (DISK_MAX > 0 ? (DISK_MAX >= 1024 ? (DISK_MAX/1024).toFixed(1)+' GB' : DISK_MAX+' MB') : '不限制') +'</span></div>' +
179 '<div class="dk-quota-item"><span class="dk-quota-label">代理数</span><span class="dk-quota-val">'+ (PROXY_MAX > 0 ? PROXY_MAX+' 个' : '不限制') +'</span></div>' +
180 '</div>' +
181 '<div class="dk-form-grid">' +
182 '<div class="dk-field dk-field-full"><label>允许外网访问</label><label><input type="checkbox" name="allow_access" value="1" checked> 允许(通过主机IP+端口访问,设了域名可不勾)</label></div>' +
183 envHtml +
184 '</div>' +
185 '</form>';
186
187 dkModal(body, '安装应用:'+ (app.apptitle || app.appname));
188 $('#dkModalMask .dk-modal').append('<div class="dk-modal-foot"><button class="dk-btn dk-btn-ghost" onclick="dkCloseModal()">取消</button><button class="dk-btn" id="dkInstallBtn">确认安装</button></div>');
189 $('#dkInstallBtn').on('click', function(){ doInstall(app); });
190 }
191
192 function doInstall(app){
193 var btn = $('#dkInstallBtn');
194 var ver = $('#dkInstallForm [name=dk_version]').val().split('|');
195 var fd = new FormData($('#dkInstallForm')[0]);
196 fd.append('app_name', app.appname);
197 fd.append('m_version', ver[0] || '');
198 // s_version 留空(无子版本的应用):宝塔后端会只用 m_version 作为镜像 tag
199 fd.append('s_version', ver[1] || '');
200 // 配额直接使用套餐值,不允许用户修改
201 fd.set('cpus', String(Math.floor(CPU_MAX)));
202 fd.set('memory_limit', String(MEM_MAX));
203 btn.prop('disabled', true).html('<span class="dk-spin"></span> 提交中…');
204 dkAjax('app_create', fd, {timeout:120000}).then(function(r){
205 dkCloseModal();
206 dkToast('创建请求已提交,正在初始化…', 'success');
207 setTimeout(function(){ window.location.href = 'console.php'; }, 1000);
208 }).fail(function(){
209 btn.prop('disabled', false).text('确认安装');
210 });
211 }
212
213 loadApps();
214})();
215</script>
216<?php include __DIR__ . '/foot.php'; ?>