This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Документы — SHD MCP</title>
|
||||
<style>
|
||||
:root{color-scheme:light dark;--bg:#f7f8fa;--panel:#fff;--line:#d9dee7;--ink:#172033;--muted:#687386;--accent:#2563eb;--accent-soft:#e8efff;--danger:#b42318}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
.shell{max-width:980px;margin:0 auto;padding:24px}.eyebrow{color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}
|
||||
h1{margin:4px 0 6px;font-size:25px;line-height:1.15}.lede{margin:0;color:var(--muted)}.summary{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0 14px}
|
||||
.summary span{padding:5px 9px;border:1px solid var(--line);border-radius:999px;background:var(--panel);color:var(--muted)}.summary strong{color:var(--ink)}
|
||||
.toolbar{display:flex;gap:8px;margin-bottom:12px}.search{min-width:0;flex:1;padding:9px 11px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:inherit}
|
||||
button{cursor:pointer}.refresh{padding:9px 14px;border:1px solid var(--accent);border-radius:8px;background:var(--accent);color:#fff;font-weight:600}.refresh:disabled{opacity:.6;cursor:wait}
|
||||
.status-message{min-height:22px;color:var(--muted)}.error{color:var(--danger)}.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:10px;background:var(--panel)}
|
||||
table{width:100%;border-collapse:collapse;min-width:560px}th,td{padding:11px 13px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}th{color:var(--muted);font-size:12px;font-weight:600}
|
||||
tbody tr:last-child td{border-bottom:0}.record{width:100%;border:0;background:transparent;color:inherit;text-align:left}.record:hover{background:var(--accent-soft)}
|
||||
.title{font-weight:650}.subtitle{display:block;color:var(--muted);font-size:12px;margin-top:2px}.pill{display:inline-block;padding:3px 8px;border-radius:999px;background:var(--accent-soft);color:var(--accent);font-size:12px;white-space:nowrap}
|
||||
.empty{padding:24px;text-align:center;color:var(--muted)}.details{margin-top:14px;padding:16px;border:1px solid var(--line);border-radius:10px;background:var(--panel)}.details[hidden]{display:none}
|
||||
.details-head{display:flex;justify-content:space-between;gap:12px;align-items:start}.details h2{margin:0;font-size:18px}.close{border:0;background:transparent;color:var(--muted);font-size:20px}.detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-top:14px}
|
||||
.detail{padding:9px;border-radius:8px;background:var(--bg)}.detail-label{display:block;color:var(--muted);font-size:11px;margin-bottom:3px}.detail-value{overflow-wrap:anywhere}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#11151d;--panel:#1b2230;--line:#303a4b;--ink:#edf2fb;--muted:#aab4c5;--accent:#82aaff;--accent-soft:#263858;--danger:#ff9a93}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell" data-widget-module="documents">
|
||||
<header>
|
||||
<div class="eyebrow">SHD MCP</div>
|
||||
<h1>Документы</h1>
|
||||
<p class="lede">Реестр документов с поиском и просмотром деталей.</p>
|
||||
<div class="summary" id="summary"></div>
|
||||
</header>
|
||||
<div class="toolbar">
|
||||
<input class="search" id="search" type="search" placeholder="Поиск по названию, статусу или ID" autocomplete="off">
|
||||
<button class="refresh" id="refresh" type="button">Обновить</button>
|
||||
</div>
|
||||
<div class="status-message" id="status" role="status" aria-live="polite"></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Документ</th><th>Статус</th><th>Обновлено</th><th>Сумма</th></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
<div class="empty" id="empty" hidden>Нет данных для отображения.</div>
|
||||
</div>
|
||||
<section class="details" id="details" hidden aria-live="polite">
|
||||
<div class="details-head"><div><h2 id="details-title"></h2><div class="subtitle" id="details-subtitle"></div></div><button class="close" id="close" type="button" aria-label="Закрыть">×</button></div>
|
||||
<div class="detail-grid" id="detail-grid"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var CONFIG = {"module":"documents","title":"Документы","description":"Реестр документов с поиском и просмотром деталей.","nameLabel":"Документ","statusLabel":"Статус","dateLabel":"Обновлено","valueLabel":"Сумма","listTool":"shd_list_contracts","detailTool":"shd_get_contract","detailKey":"contract_id","listArgs":{"archived":false,"limit":100}};
|
||||
var pending = new Map();
|
||||
var nextRequestId = 1;
|
||||
var records = [];
|
||||
var busy = false;
|
||||
var elements = {
|
||||
search: document.getElementById('search'),
|
||||
refresh: document.getElementById('refresh'),
|
||||
status: document.getElementById('status'),
|
||||
summary: document.getElementById('summary'),
|
||||
rows: document.getElementById('rows'),
|
||||
empty: document.getElementById('empty'),
|
||||
details: document.getElementById('details'),
|
||||
detailsTitle: document.getElementById('details-title'),
|
||||
detailsSubtitle: document.getElementById('details-subtitle'),
|
||||
detailGrid: document.getElementById('detail-grid'),
|
||||
close: document.getElementById('close')
|
||||
};
|
||||
|
||||
function request(method, params) {
|
||||
var openai = typeof window !== 'undefined' ? window.openai : undefined;
|
||||
if (method === 'tools/call' && openai && typeof openai.callTool === 'function') {
|
||||
return openai.callTool(params.name, params.arguments || {});
|
||||
}
|
||||
var id = nextRequestId++;
|
||||
window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*');
|
||||
return new Promise(function (resolve, reject) {
|
||||
var timeout = window.setTimeout(function () {
|
||||
pending.delete(id);
|
||||
reject(new Error('Host request timed out'));
|
||||
}, 10000);
|
||||
pending.set(id, { resolve: resolve, reject: reject, timeout: timeout });
|
||||
});
|
||||
}
|
||||
|
||||
function notify(method, params) {
|
||||
window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');
|
||||
}
|
||||
|
||||
function normalizePayload(value) {
|
||||
if (!value) return null;
|
||||
if (value.structuredContent) return value.structuredContent;
|
||||
if (value.toolOutput) return value.toolOutput;
|
||||
return value;
|
||||
}
|
||||
|
||||
function unwrap(value) {
|
||||
var payload = normalizePayload(value);
|
||||
if (value && value.isError) throw new Error('MCP tool returned an error');
|
||||
if (payload && payload.error) throw new Error(String(payload.error));
|
||||
return payload || value;
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
var payload = normalizePayload(value);
|
||||
if (!payload) return [];
|
||||
var data = payload.data;
|
||||
if (data && !Array.isArray(data) && Array.isArray(data.data)) data = data.data;
|
||||
if (Array.isArray(payload.items)) data = payload.items;
|
||||
return Array.isArray(data) ? data.filter(function (item) { return item && typeof item === 'object'; }) : [];
|
||||
}
|
||||
|
||||
function idOf(item) { return String(item.id || item.code || item.key || item.number || ''); }
|
||||
function titleOf(item) { return String(item.title || item.name || item.display_name || item.number || idOf(item) || '—'); }
|
||||
function subtitleOf(item) { return String(item.subtitle || item.project_code || item.code || item.key || ''); }
|
||||
function statusOf(item) {
|
||||
var value = item.status;
|
||||
if (value && typeof value === 'object') value = value.name || value.id;
|
||||
return String(value || item.phase || '—');
|
||||
}
|
||||
function dateOf(item) { return String(item.date || item.due_date || item.updated_at || item.issued_at || item.created_at || ''); }
|
||||
function valueOf(item) { return item.value === null || item.value === undefined ? '' : String(item.value); }
|
||||
function dateText(value) {
|
||||
if (!value) return '—';
|
||||
var timestamp = Date.parse(value);
|
||||
if (Number.isNaN(timestamp)) return value;
|
||||
try { return new Intl.DateTimeFormat(undefined, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(timestamp)); }
|
||||
catch (error) { return value; }
|
||||
}
|
||||
function make(tag, value, className) {
|
||||
var node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (value !== undefined) node.textContent = value;
|
||||
return node;
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
elements.summary.replaceChildren();
|
||||
var item = make('span');
|
||||
item.append(make('strong', String(records.length)), ' записей');
|
||||
elements.summary.append(item);
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
var query = elements.search.value.trim().toLowerCase();
|
||||
var filtered = records.filter(function (item) {
|
||||
if (!query) return true;
|
||||
return (titleOf(item) + ' ' + subtitleOf(item) + ' ' + statusOf(item) + ' ' + idOf(item)).toLowerCase().indexOf(query) !== -1;
|
||||
});
|
||||
elements.rows.replaceChildren();
|
||||
elements.empty.hidden = filtered.length !== 0;
|
||||
filtered.forEach(function (item) {
|
||||
var row = document.createElement('tr');
|
||||
var name = make('td');
|
||||
name.append(make('span', titleOf(item), 'title'));
|
||||
if (subtitleOf(item)) name.append(make('span', subtitleOf(item), 'subtitle'));
|
||||
var status = make('td');
|
||||
status.append(make('span', statusOf(item), 'pill'));
|
||||
row.append(name, status, make('td', dateText(dateOf(item))), make('td', valueOf(item) || '—'));
|
||||
row.className = 'record';
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('role', 'button');
|
||||
row.setAttribute('aria-label', titleOf(item));
|
||||
row.addEventListener('click', function () { showDetails(item); });
|
||||
row.addEventListener('keydown', function (event) { if (event.key === 'Enter' || event.key === ' ') showDetails(item); });
|
||||
elements.rows.append(row);
|
||||
});
|
||||
renderSummary();
|
||||
}
|
||||
|
||||
function render(value) {
|
||||
records = toArray(value).slice().sort(function (left, right) {
|
||||
var a = Date.parse(dateOf(left)); var b = Date.parse(dateOf(right));
|
||||
if (Number.isNaN(a)) a = Number.POSITIVE_INFINITY;
|
||||
if (Number.isNaN(b)) b = Number.POSITIVE_INFINITY;
|
||||
return a - b || titleOf(left).localeCompare(titleOf(right));
|
||||
});
|
||||
renderRows();
|
||||
}
|
||||
|
||||
function setStatus(value, isError) {
|
||||
elements.status.textContent = value || '';
|
||||
elements.status.className = isError ? 'status-message error' : 'status-message';
|
||||
}
|
||||
|
||||
function detailValue(value) {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (typeof value === 'object') {
|
||||
try { return JSON.stringify(value); } catch (error) { return '—'; }
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function addDetail(label, value) {
|
||||
var item = make('div', undefined, 'detail');
|
||||
item.append(make('span', label, 'detail-label'), make('span', detailValue(value), 'detail-value'));
|
||||
elements.detailGrid.append(item);
|
||||
}
|
||||
|
||||
function showDetails(item) {
|
||||
elements.detailsTitle.textContent = titleOf(item);
|
||||
elements.detailsSubtitle.textContent = subtitleOf(item) || idOf(item);
|
||||
elements.detailGrid.replaceChildren();
|
||||
addDetail('ID', idOf(item));
|
||||
addDetail(CONFIG.statusLabel, statusOf(item));
|
||||
addDetail(CONFIG.dateLabel, dateText(dateOf(item)));
|
||||
if (valueOf(item)) addDetail(CONFIG.valueLabel, valueOf(item));
|
||||
if (item.meta) addDetail('Meta', item.meta);
|
||||
elements.details.hidden = false;
|
||||
var id = idOf(item);
|
||||
if (!id || !CONFIG.detailTool) return;
|
||||
var args = {};
|
||||
args[CONFIG.detailKey] = Number(id) || id;
|
||||
if (CONFIG.detailSection) args.section = CONFIG.detailSection;
|
||||
request('tools/call', { name: CONFIG.detailTool, arguments: args }).then(function (result) {
|
||||
var payload = unwrap(result);
|
||||
var data = payload && payload.data;
|
||||
if (Array.isArray(data)) data = data[0];
|
||||
if (!data || typeof data !== 'object') return;
|
||||
elements.detailGrid.replaceChildren();
|
||||
Object.keys(data).slice(0, 12).forEach(function (key) { addDetail(key, data[key]); });
|
||||
}).catch(function (error) { setStatus(error && error.message ? error.message : 'Не удалось загрузить детали.', true); });
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
elements.refresh.disabled = true;
|
||||
setStatus('Загрузка…', false);
|
||||
request('tools/call', { name: CONFIG.listTool, arguments: CONFIG.listArgs }).then(function (result) {
|
||||
render(unwrap(result));
|
||||
setStatus('', false);
|
||||
}).catch(function (error) {
|
||||
setStatus(error && error.message ? error.message : 'Не удалось загрузить данные.', true);
|
||||
}).finally(function () {
|
||||
busy = false;
|
||||
elements.refresh.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function initializeHost() {
|
||||
return request('ui/initialize', {
|
||||
protocolVersion: '2025-11-25',
|
||||
appInfo: { name: 'SHD ' + CONFIG.title, title: 'SHD ' + CONFIG.title, version: '1.0.0', websiteUrl: 'https://shd.xyz.su' },
|
||||
appCapabilities: {}
|
||||
}).then(function () { notify('ui/notifications/initialized', {}); }).catch(function () {});
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
if (event.source !== window.parent) return;
|
||||
var message = event.data;
|
||||
if (!message || message.jsonrpc !== '2.0') return;
|
||||
if (message.id !== undefined && pending.has(message.id)) {
|
||||
var entry = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
window.clearTimeout(entry.timeout);
|
||||
if (message.error) entry.reject(new Error(message.error.message || 'Host request failed'));
|
||||
else entry.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
if (message.method === 'ui/notifications/tool-input') {
|
||||
if (!records.length) render(message.params || {});
|
||||
}
|
||||
if (message.method === 'ui/notifications/tool-result') render(message.params && message.params.structuredContent);
|
||||
}, { passive: true });
|
||||
|
||||
elements.search.addEventListener('input', renderRows);
|
||||
elements.refresh.addEventListener('click', refresh);
|
||||
elements.close.addEventListener('click', function () { elements.details.hidden = true; });
|
||||
void initializeHost();
|
||||
var initialOutput = typeof window !== 'undefined' && window.openai ? window.openai.toolOutput : null;
|
||||
if (initialOutput) render(initialOutput);
|
||||
else {
|
||||
var initialInput = typeof window !== 'undefined' && window.openai ? window.openai.toolInput : null;
|
||||
if (initialInput) render(initialInput);
|
||||
else refresh();
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user