Upload via GUI (304 Dateien)
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Prüft den Chat der Mitarbeiter untereinander: Abruf über laufende Nummern (keine Doppelten), Anzeige-Angaben für neue Nachrichten,
|
||||
* gleiche Darstellung beim Nachladen wie beim Laden der Seite.
|
||||
*/
|
||||
|
||||
date_default_timezone_set('Europe/Berlin'); // wie in der Test-Installation
|
||||
$root = dirname(__DIR__);
|
||||
$work = sys_get_temp_dir() . '/deskhub-staffchat-' . bin2hex(random_bytes(4));
|
||||
$tmp = $work . '/inst';
|
||||
mkdir($tmp, 0755, true);
|
||||
$webPort = random_int(37000, 37499);
|
||||
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
|
||||
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
|
||||
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
|
||||
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
|
||||
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
|
||||
copy($f->getPathname(), $tmp . '/' . $rel);
|
||||
}
|
||||
@mkdir($tmp . '/uploads');
|
||||
@mkdir($tmp . '/config');
|
||||
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
|
||||
'app_key' => bin2hex(random_bytes(32)),
|
||||
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
|
||||
'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
|
||||
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
|
||||
], true) . ';');
|
||||
|
||||
putenv('DESKHUB_CONFIG_JSON');
|
||||
putenv('DESKHUB_SQLITE_PATH');
|
||||
putenv('DESKHUB_TESTING');
|
||||
putenv('DESKHUB_API_ALLOW_INSECURE=1');
|
||||
require_once $root . '/database/sqlite_schema.php';
|
||||
require_once $root . '/database/migrate.php';
|
||||
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec('PRAGMA foreign_keys = ON');
|
||||
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
|
||||
$mig = run_migrations($db, true);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$db->exec('DELETE FROM staff');
|
||||
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
$ids = [];
|
||||
foreach ([['Chef', 'chef@test.de', 'admin'], ['Agent', 'agent@test.de', 'agent']] as [$n, $e, $r]) {
|
||||
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
|
||||
$ids[$n] = (int) $db->lastInsertId();
|
||||
}
|
||||
$db = null;
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
|
||||
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
|
||||
|
||||
try {
|
||||
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
|
||||
$pdo = function () use ($tmp): PDO {
|
||||
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
return $p;
|
||||
};
|
||||
$call = function (string $method, string $path, string $jar, array $form = []) use ($webPort): array {
|
||||
$ch = curl_init("http://127.0.0.1:$webPort$path");
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar, CURLOPT_HTTPHEADER => ['Sec-Fetch-Mode: navigate', 'Sec-Fetch-User: ?1', 'X-Requested-With: XMLHttpRequest']]);
|
||||
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($form)); }
|
||||
$b = (string) curl_exec($ch);
|
||||
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
return [$s, $b];
|
||||
};
|
||||
$login = function (string $email) use ($call): string {
|
||||
$jar = tempnam(sys_get_temp_dir(), 'chat');
|
||||
[, $lp] = $call('GET', '/staff/login.php', $jar);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
|
||||
$call('POST', '/staff/login.php', $jar, ['csrf_token' => $m[1] ?? '', 'email' => $email, 'password' => 'Sehr-Sicher-1234']);
|
||||
return $jar;
|
||||
};
|
||||
$token = function (string $jar, string $path) use ($call): string { [, $p] = $call('GET', $path, $jar); preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $m); return $m[1] ?? ''; };
|
||||
$ids = $pdo()->query('SELECT email, id FROM staff')->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$chefId = (int) $ids['chef@test.de']; $agentId = (int) $ids['agent@test.de'];
|
||||
$jarA = $login('chef@test.de');
|
||||
$jarB = $login('agent@test.de');
|
||||
|
||||
$send = function (string $jar, int $toId, string $text) use ($call, $token): void {
|
||||
$t = $token($jar, '/staff/chat.php?with=' . $toId);
|
||||
$call('POST', '/staff/chat.php', $jar, ['csrf_token' => $t, 'to_id' => (string) $toId, 'message' => $text]);
|
||||
};
|
||||
$poll = function (string $jar, int $withId, int $after) use ($call): array { [, $b] = $call('GET', "/staff/chat.php?with=$withId&action=poll&after=$after", $jar); return json_decode($b, true) ?? []; };
|
||||
|
||||
$send($jarA, $agentId, 'Hallo Agent');
|
||||
$send($jarA, $agentId, "Zweite Zeile\nmit Umbruch <b>fett</b>");
|
||||
$m = $poll($jarB, $chefId, 0);
|
||||
$check(count($m) === 2 && $m[0]['message'] === 'Hallo Agent' && $m[0]['id'] < $m[1]['id'], 'Abruf liefert die neuen Nachrichten in der Reihenfolge ihrer Nummer');
|
||||
$check(($m[0]['initial'] ?? '') === 'C' && !empty($m[0]['av_class']) && $m[0]['from_name'] === 'Chef' && (int) $m[0]['from_staff_id'] === $chefId, 'Abruf enthält Initiale, Farbklasse und Absender für die Anzeige');
|
||||
$check(count($poll($jarB, $chefId, (int) $m[1]['id'])) === 0, 'Nach der letzten Nummer kommt nichts mehr (keine Doppelten)');
|
||||
$check(count($poll($jarB, $chefId, (int) $m[0]['id'])) === 1, 'Ab einer mittleren Nummer kommt nur die jüngere Nachricht');
|
||||
|
||||
// Auch die eigene Nachricht kommt mit Nummer zurück, damit die Seite sie ohne Neuladen richtig zeigt
|
||||
$own = $poll($jarA, $agentId, 0);
|
||||
$check(count($own) === 2 && (int) $own[0]['from_staff_id'] === $chefId, 'Der Absender sieht seine Nachrichten im Abruf (mit Nummer und Serverzeit)');
|
||||
|
||||
// Seite: Nachrichten tragen ihre Nummer, die Skripte nutzen die Klassen der Seite (nicht die alten)
|
||||
[, $page] = $call('GET', '/staff/chat.php?with=' . $chefId, $jarB);
|
||||
$check(preg_match_all('/class="ac-msg [a-z]+" data-id="\d+"/', $page) === 2, 'Die Seite zeigt beide Nachrichten mit ihrer Nummer');
|
||||
$check(str_contains($page, "'ac-msg-bubble'") && str_contains($page, "'ac-msg-time'") && !str_contains($page, 'chat-bubble') && !str_contains($page, 'bubble-text'), 'Neue Nachrichten werden mit denselben Klassen gebaut wie beim Laden der Seite');
|
||||
$check(str_contains($page, 'var lastId = ' . (int) $m[1]['id'] . ';'), 'Der Abruf startet bei der letzten bekannten Nummer');
|
||||
$check(!str_contains($page, 'toISOString'), 'Die Seite benutzt keine Browser-Uhrzeit mehr (Zeitzonen-Fehler)');
|
||||
$check(str_contains($page, 'Zweite Zeile<br />') && str_contains($page, '<b>fett</b>'), 'Text wird maskiert, Zeilenumbrüche bleiben');
|
||||
|
||||
// fremde Konversation bleibt fremd
|
||||
$jarC = $login('agent@test.de');
|
||||
$check(count($poll($jarC, $chefId, 0)) === 2, 'Gegenstelle sieht dieselben Nachrichten');
|
||||
@unlink($jarA); @unlink($jarB); @unlink($jarC);
|
||||
} finally {
|
||||
if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
||||
}
|
||||
|
||||
usleep(300000);
|
||||
$rm = function (string $d) use (&$rm) {
|
||||
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
|
||||
@rmdir($d);
|
||||
};
|
||||
if (str_starts_with($work, sys_get_temp_dir() . '/deskhub-staffchat-')) $rm($work);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "Staff chat checks passed ({$total} Prüfungen).\n";
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# tools/demo-video/build_video.sh: baut aus den Bildern (Reihenfolge nach Dateiname) das Demo-Video und eine kurze GIF-Vorschau.
|
||||
# bash tools/demo-video/build_video.sh <Bilderordner> <Zielordner>
|
||||
# Benötigt ffmpeg.
|
||||
set -euo pipefail
|
||||
FRAMES="${1:?Bilderordner fehlt}"
|
||||
OUT="${2:?Zielordner fehlt}"
|
||||
SEC="${SECONDS_PER_SLIDE:-3.6}"
|
||||
mkdir -p "$OUT"
|
||||
TMP="$OUT/.build"; rm -rf "$TMP"; mkdir -p "$TMP"
|
||||
LIST="$TMP/list.txt"
|
||||
: > "$LIST"
|
||||
i=0
|
||||
for f in $(ls "$FRAMES"/*.png | sort); do
|
||||
i=$((i + 1))
|
||||
clip="$TMP/clip_$(printf '%02d' $i).mp4"
|
||||
ffmpeg -loglevel error -y -loop 1 -t "$SEC" -i "$f" \
|
||||
-vf "scale=1440:900,format=yuv420p,fade=t=in:st=0:d=0.45,fade=t=out:st=$(awk "BEGIN{print $SEC-0.45}"):d=0.45" \
|
||||
-r 25 -c:v libx264 -crf 22 -preset medium -pix_fmt yuv420p "$clip"
|
||||
echo "file 'clip_$(printf "%02d" $i).mp4'" >> "$LIST"
|
||||
done
|
||||
ffmpeg -loglevel error -y -f concat -safe 0 -i "$LIST" -c copy -movflags +faststart "$OUT/deskhub-demo.mp4"
|
||||
# GIF-Vorschau (kleiner, für die README, weil nicht jede Seite Videos direkt anzeigt)
|
||||
ffmpeg -loglevel error -y -i "$OUT/deskhub-demo.mp4" -vf "fps=4,scale=800:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=96[p];[b][p]paletteuse=dither=bayer:bayer_scale=4" "$OUT/deskhub-demo.gif"
|
||||
rm -rf "$TMP"
|
||||
ls -la "$OUT"/deskhub-demo.*
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
/**
|
||||
* tools/demo-video/capture.js: nimmt die Bilder für das Demo-Video auf (Weboberfläche mit erfundenen Beispieldaten).
|
||||
* 1. php tools/demo-video/seed_demo.php <Ordner> (Demo-Installation anlegen)
|
||||
* 2. php -S 127.0.0.1:8097 -t <Ordner> (im Hintergrund starten)
|
||||
* 3. electron tools/demo-video/capture.js <Bilderordner>
|
||||
* Danach baut build_video.sh aus den Bildern das Video.
|
||||
*/
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const BASE = 'http://127.0.0.1:8097';
|
||||
const OUT = path.resolve(process.argv[2] || path.join(__dirname, 'frames'));
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let win, n = 0;
|
||||
|
||||
const run = (js) => win.webContents.executeJavaScript(js, true);
|
||||
/** Seite laden und Hinweise entfernen, die nur zur Demo-Installation gehören (SQLite-Hinweis, Abmelde-Zähler). */
|
||||
const load = async (url) => {
|
||||
await win.loadURL(BASE + url);
|
||||
await sleep(700);
|
||||
await run(`(() => { document.querySelectorAll('div,p,section').forEach((e) => { if (e.children.length < 6 && /SQLite-Modus aktiv/.test(e.textContent) && e.textContent.length < 260) e.remove(); });
|
||||
document.getElementById('session-timer-bar')?.remove(); })()`);
|
||||
};
|
||||
|
||||
/** Bildunterschrift unten einblenden, Bild speichern, Unterschrift wieder entfernen. */
|
||||
async function shot(caption, name) {
|
||||
n += 1;
|
||||
await run(`(() => { const d = document.createElement('div'); d.id = '__cap'; d.textContent = ${JSON.stringify(caption)};
|
||||
d.style.cssText = 'position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:2147483647;background:rgba(10,30,45,.92);color:#fff;font:600 21px Segoe UI,Arial,sans-serif;padding:13px 26px;border-radius:40px;box-shadow:0 6px 24px rgba(0,0,0,.35);max-width:1180px;text-align:center';
|
||||
document.body.appendChild(d); })()`);
|
||||
await sleep(450);
|
||||
const img = await win.webContents.capturePage();
|
||||
fs.writeFileSync(path.join(OUT, `${String(n).padStart(2, '0')}_${name}.png`), img.toPNG());
|
||||
await run(`document.getElementById('__cap')?.remove()`);
|
||||
}
|
||||
|
||||
async function ask(question) {
|
||||
await run(`(() => { const i = document.getElementById('as-input'); i.value = ${JSON.stringify(question)}; document.getElementById('as-form').dispatchEvent(new Event('submit', { cancelable: true })); })()`);
|
||||
await sleep(1300);
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
win = new BrowserWindow({ width: 1440, height: 900, show: true, useContentSize: true, webPreferences: { contextIsolation: true, backgroundThrottling: false } });
|
||||
try {
|
||||
// ── Kundenportal ──
|
||||
await load('/home.php');
|
||||
await shot('Kundenportal: Hilfe finden und Tickets verfolgen', 'portal');
|
||||
await run(`document.getElementById('as-bubble').click()`);
|
||||
await sleep(900);
|
||||
await ask('Wie lange dauert der Versand?');
|
||||
await shot('Der digitale Assistent antwortet aus der Wissensdatenbank', 'assistent');
|
||||
await ask('Wann seid ihr erreichbar?');
|
||||
await ask('Wie kann ich meinen Vertrag beenden?');
|
||||
await shot('Und bietet ein Ticket an, wenn er nichts findet', 'assistent_ticket');
|
||||
await load('/ticket_new.php');
|
||||
await shot('Tickets mit Thema, Priorität und Anhängen erstellen', 'neues_ticket');
|
||||
|
||||
// ── Mitarbeiterbereich ──
|
||||
await load('/staff/login.php');
|
||||
await run(`(() => { document.getElementById('email').value = 'lena@demo.example'; document.getElementById('password').value = 'Demo-Passwort-1234'; document.querySelector('form button[type=submit]').click(); })()`);
|
||||
await sleep(1500);
|
||||
await load('/staff/dashboard.php');
|
||||
await shot('Übersicht: Zahlen, Status des Teams und priorisierte Tickets', 'uebersicht');
|
||||
await load('/staff/tickets.php?tab=open');
|
||||
await shot('Ticketliste mit Filtern, Tabs und gespeicherten Ansichten', 'tickets');
|
||||
await load('/staff/ticket_view.php?n=260102');
|
||||
await shot('Ticket bearbeiten: Verlauf, Antworten, interne Notizen', 'ticket');
|
||||
await run(`(() => { const el = [...document.querySelectorAll('span')].find((s) => /Wiedervorlage/.test(s.textContent)); if (el) el.scrollIntoView({ block: 'center' }); })()`);
|
||||
await sleep(500);
|
||||
await shot('Wiedervorlage, Beobachter und passende Artikel zum Ticket', 'ticket_extras');
|
||||
await load('/staff/tickets.php?tab=snoozed');
|
||||
await shot('Wiedervorlage: Tickets später wieder vorlegen lassen', 'wiedervorlage');
|
||||
|
||||
// ── Digitaler Assistent (Verwaltung) ──
|
||||
await load('/staff/assistant.php');
|
||||
await shot('Digitaler Assistent: Einstellungen, Name und Begrüßung', 'assistent_admin');
|
||||
await load('/staff/assistant.php?tab=answers');
|
||||
await shot('Eigene Antworten zusätzlich zur Wissensdatenbank', 'assistent_antworten');
|
||||
await load('/staff/assistant.php?tab=log');
|
||||
await shot('Fragen-Log: Was wurde gefragt, was blieb ohne Antwort?', 'assistent_log');
|
||||
|
||||
await load('/staff/reports.php');
|
||||
await shot('Berichte mit Bericht-Baukasten und CSV-Export', 'berichte');
|
||||
} catch (e) {
|
||||
console.error('Fehler:', e);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
console.log(`${n} Bilder in ${OUT}`);
|
||||
app.quit();
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
/**
|
||||
* tools/demo-video/capture_app.js: nimmt Bilder des Windows-Programms mit den erfundenen Beispieldaten aus desktop/test/mock.html auf.
|
||||
* desktop/node_modules/.bin/electron tools/demo-video/capture_app.js <Bilderordner> [Startnummer]
|
||||
*/
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const OUT = path.resolve(process.argv[2] || path.join(__dirname, 'frames'));
|
||||
let n = parseInt(process.argv[3] || '50', 10) - 1;
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const MOCK = path.resolve(__dirname, '..', '..', '..', 'desktop', 'test', 'mock.html');
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let win;
|
||||
const run = (js) => win.webContents.executeJavaScript(js, true);
|
||||
|
||||
async function shot(caption, name) {
|
||||
n += 1;
|
||||
await run(`(() => { const d = document.createElement('div'); d.id = '__cap'; d.textContent = ${JSON.stringify(caption)};
|
||||
d.style.cssText = 'position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:2147483647;background:rgba(10,30,45,.92);color:#fff;font:600 21px Segoe UI,Arial,sans-serif;padding:13px 26px;border-radius:40px;box-shadow:0 6px 24px rgba(0,0,0,.35);max-width:1180px;text-align:center';
|
||||
document.body.appendChild(d); })()`);
|
||||
await sleep(450);
|
||||
fs.writeFileSync(path.join(OUT, `${String(n).padStart(2, '0')}_${name}.png`), (await win.webContents.capturePage()).toPNG());
|
||||
await run(`document.getElementById('__cap')?.remove()`);
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
win = new BrowserWindow({ width: 1440, height: 900, show: true, useContentSize: true, webPreferences: { contextIsolation: false, nodeIntegration: false, backgroundThrottling: false } });
|
||||
try {
|
||||
await win.loadFile(MOCK);
|
||||
await run(`(() => { const o = window.deskhub.api; window.deskhub.api = async (r) => { const res = await o(r); return res.error && /^Mock:/.test(res.error.message) ? { data: { items: [], rules: [], holidays: [], hours: [], staffOptions: [], departments: [], permissionKeys: [], fieldOptions: [], operatorOptions: [], actionOptions: [] } } : res; }; })()`);
|
||||
await sleep(800);
|
||||
await run(`go('dashboard')`); await sleep(900);
|
||||
await shot('Windows-Programm: Übersicht mit Kennzahlen und priorisierten Tickets', 'app_uebersicht');
|
||||
await run(`go('tickets')`); await sleep(900);
|
||||
await shot('Windows-Programm: Tickets mit Reitern, Ansichten und Wiedervorlage', 'app_tickets');
|
||||
await run(`go('ticket', 1)`); await sleep(1100);
|
||||
await shot('Windows-Programm: Ticket bearbeiten, auf später legen, beobachten', 'app_ticket');
|
||||
await run(`state.staff.role = 'admin'; go('admin', 'assistant')`); await sleep(900);
|
||||
await shot('Windows-Programm: Digitalen Assistenten komplett verwalten', 'app_assistent');
|
||||
await run(`[...document.querySelectorAll('#main .tabs button')].find((b) => b.innerText.includes('Fragen-Log')).click()`); await sleep(700);
|
||||
await shot('Fragen-Log mit häufigen Fragen ohne Antwort', 'app_assistent_log');
|
||||
} catch (e) { console.error('Fehler:', e); process.exitCode = 1; }
|
||||
console.log(`${n - (parseInt(process.argv[3] || '50', 10) - 1)} Bilder in ${OUT}`);
|
||||
app.quit();
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
/** tools/demo-video/capture_titles.js: erzeugt Titel-, Android- und Abspannbild für das Demo-Video. */
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const OUT = path.resolve(process.argv[2] || path.join(__dirname, 'frames'));
|
||||
const VERSION = process.argv[3] || '';
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const page = (body) => 'data:text/html;charset=utf-8,' + encodeURIComponent(`<!doctype html><meta charset="utf-8"><style>
|
||||
html,body{margin:0;width:1440px;height:900px;overflow:hidden;font-family:Segoe UI,Arial,sans-serif;color:#fff;background:linear-gradient(135deg,#0E7FBF,#12A594)}
|
||||
.c{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center;padding:0 120px;box-sizing:border-box}
|
||||
h1{font-size:92px;margin:0 0 18px;letter-spacing:-2px}h1 span{color:#FFD79A}
|
||||
p{font-size:34px;margin:8px 0;opacity:.95}.small{font-size:24px;opacity:.8;margin-top:36px}
|
||||
ul{list-style:none;padding:0;margin:26px 0 0;text-align:left;font-size:34px;line-height:1.6}li:before{content:"✔";margin-right:18px;color:#FFD79A}
|
||||
h2{font-size:64px;margin:0}</style><div class="c">${body}</div>`);
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ width: 1440, height: 900, show: true, useContentSize: true });
|
||||
const cards = [
|
||||
['00_intro', `<h1>Desk<span>Hub</span></h1><p>Das Helpdesk-System zum Selbsthosten</p><p class="small">Demo mit erfundenen Beispieldaten ${VERSION ? '· Version ' + VERSION : ''}</p>`],
|
||||
['40_android', `<h2>Android-App</h2><ul><li>Neues, helles Design mit schwebender Navigation</li><li>Start, Tickets, Chat, Kunden und Verwaltung nach Rechten</li><li>Wiedervorlage, Beobachten und „Dazu passend“</li><li>Digitalen Assistenten komplett verwalten</li><li>Widget in drei Größen, hell und dunkel</li></ul>`],
|
||||
['99_outro', `<h2>Alles aus einer Hand</h2><ul><li>Kundenportal mit Wissensdatenbank und Assistent</li><li>Weboberfläche, Windows-Programm und Android-App</li><li>E-Mail-Import, SLA, Regeln, Berichte, Plugins</li></ul><p class="small">Installationsanleitung und Funktionen: siehe README</p>`],
|
||||
];
|
||||
for (const [name, body] of cards) {
|
||||
await win.loadURL(page(body));
|
||||
await sleep(500);
|
||||
fs.writeFileSync(path.join(OUT, name + '.png'), (await win.webContents.capturePage()).toPNG());
|
||||
}
|
||||
console.log(`${cards.length} Titelbilder in ${OUT}`);
|
||||
app.quit();
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/**
|
||||
* tools/demo-video/seed_demo.php
|
||||
*
|
||||
* Baut eine eigene Demo-Installation (SQLite, alles erfunden) in ein Temp-Verzeichnis, damit Screenshots und Video keine echten Kundendaten zeigen.
|
||||
* php tools/demo-video/seed_demo.php [Zielordner]
|
||||
* Danach: php -S 127.0.0.1:8097 -t <Zielordner> (Zugang: lena@demo.example / Demo-Passwort-1234)
|
||||
*/
|
||||
$root = dirname(__DIR__, 2);
|
||||
$tmp = $argv[1] ?? (sys_get_temp_dir() . '/deskhub-demo');
|
||||
function rrm(string $d): void { if (!is_dir($d)) return; foreach (scandir($d) as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? rrm("$d/$e") : @unlink("$d/$e"); } @rmdir($d); }
|
||||
rrm($tmp);
|
||||
mkdir($tmp, 0755, true);
|
||||
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|tools|plugins/[^/]+|docs|\.git)(/|$)#';
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
|
||||
$rel = str_replace('\\', '/', substr($f->getPathname(), strlen($root) + 1));
|
||||
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
|
||||
if ($f->isDir()) { @mkdir("$tmp/$rel", 0755, true); continue; }
|
||||
@mkdir(dirname("$tmp/$rel"), 0755, true);
|
||||
copy($f->getPathname(), "$tmp/$rel");
|
||||
}
|
||||
@mkdir("$tmp/uploads"); @mkdir("$tmp/config");
|
||||
file_put_contents("$tmp/config/config.php", '<?php return ' . var_export([
|
||||
'app_key' => bin2hex(random_bytes(32)),
|
||||
'db' => ['driver' => 'sqlite', 'sqlite_path' => "$tmp/database/deskhub.sqlite"],
|
||||
'site' => ['name' => 'Muster Support', 'url' => 'http://127.0.0.1:8097', 'admin_email' => 'lena@demo.example', 'timezone' => 'Europe/Berlin'],
|
||||
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => "$tmp/uploads"],
|
||||
], true) . ';');
|
||||
putenv('DESKHUB_CONFIG_JSON'); putenv('DESKHUB_SQLITE_PATH');
|
||||
require_once "$root/database/sqlite_schema.php";
|
||||
require_once "$root/database/migrate.php";
|
||||
date_default_timezone_set('Europe/Berlin');
|
||||
$p = new PDO("sqlite:$tmp/database/deskhub.sqlite");
|
||||
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$p->exec('PRAGMA foreign_keys = ON');
|
||||
$p->exec(convert_schema_to_sqlite(file_get_contents("$root/database/schema.sql")));
|
||||
run_migrations($p, true);
|
||||
|
||||
$ts = fn(string $rel) => date('Y-m-d H:i:s', strtotime($rel));
|
||||
$dept = fn(string $n) => (int) $p->query("SELECT id FROM departments WHERE name = " . $p->quote($n))->fetchColumn();
|
||||
$prio = fn(string $n) => (int) $p->query("SELECT id FROM priorities WHERE name = " . $p->quote($n))->fetchColumn();
|
||||
|
||||
// Mitarbeiter
|
||||
$p->exec('DELETE FROM staff');
|
||||
$ins = $p->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
$staff = [];
|
||||
foreach ([['Lena Hartmann', 'lena@demo.example', 'admin', 'Allgemeiner Support'], ['Tim Roth', 'tim@demo.example', 'agent', 'Allgemeiner Support'], ['Mira Schulz', 'mira@demo.example', 'agent', 'Technik']] as [$n, $e, $r, $d]) {
|
||||
$ins->execute([$n, $e, password_hash('Demo-Passwort-1234', PASSWORD_DEFAULT), $r, $dept($d), $ts('-30 days')]);
|
||||
$staff[$n] = (int) $p->lastInsertId();
|
||||
}
|
||||
$av = $p->prepare("INSERT OR REPLACE INTO staff_availability (staff_id, status, updated_at) VALUES (?,?,?)");
|
||||
$av->execute([$staff['Lena Hartmann'], 'available', $ts('now')]);
|
||||
$av->execute([$staff['Tim Roth'], 'busy', $ts('now')]);
|
||||
$av->execute([$staff['Mira Schulz'], 'break', $ts('now')]);
|
||||
|
||||
// Einstellungen
|
||||
$set = $p->prepare('INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES (?, ?)');
|
||||
foreach (['assistant_enabled' => '1', 'live_chat_enabled' => '0', 'kb_enabled' => '1', 'display_name' => 'Muster Support', 'assistant_name' => 'Mia, die digitale Assistentin',
|
||||
'assistant_greeting' => 'Hallo! Ich bin Mia. Ich beantworte Fragen zu Lieferung, Rechnung und Konto oder erstelle ein Ticket für dich.', 'assistant_department_id' => (string) $dept('Allgemeiner Support'),
|
||||
'assistant_priority_id' => (string) $prio('Normal'), 'assistant_synonyms' => "Rechnung, Beleg, Quittung\nKündigung, kündigen, Vertrag beenden",
|
||||
'remind_enabled' => '1', 'remind_after_days' => '3', 'auto_close_enabled' => '1', 'auto_close_days' => '7'] as $k => $v) $set->execute([$k, $v]);
|
||||
|
||||
// Wissensdatenbank
|
||||
$p->exec("DELETE FROM kb_articles"); $p->exec("DELETE FROM kb_categories");
|
||||
$p->exec("INSERT INTO kb_categories (name, slug, sort_order) VALUES ('Konto', 'konto', 1), ('Bestellung und Versand', 'versand', 2), ('Rechnungen', 'rechnungen', 3)");
|
||||
$cat = fn(string $s) => (int) $p->query("SELECT id FROM kb_categories WHERE slug = " . $p->quote($s))->fetchColumn();
|
||||
$art = $p->prepare('INSERT INTO kb_articles (category_id, title, slug, body, is_published, views, created_at, updated_at) VALUES (?,?,?,?,1,?,?,?)');
|
||||
foreach ([
|
||||
['konto', 'Passwort zurücksetzen', 'passwort-zuruecksetzen', '<p>Klicke auf der Anmeldeseite auf <b>Passwort vergessen</b>. Wir senden dir einen Link per E-Mail, mit dem du ein neues Passwort festlegst. Der Link gilt eine Stunde.</p>', 128],
|
||||
['konto', 'E-Mail-Adresse ändern', 'email-aendern', '<p>Unter <b>Mein Profil</b> kannst du deine E-Mail-Adresse ändern. Zur Sicherheit bestätigst du die neue Adresse per Link.</p>', 41],
|
||||
['versand', 'Wie lange dauert der Versand?', 'versandzeiten', '<p>Bestellungen kommen in der Regel in <b>2 bis 3 Werktagen</b> an. Bei Auslandsversand dauert es bis zu 7 Werktage.</p>', 203],
|
||||
['versand', 'Meine Sendung ist nicht angekommen', 'sendung-fehlt', '<p>Prüfe zuerst die Sendungsverfolgung. Kommt das Paket nach 5 Werktagen nicht an, erstelle bitte ein Ticket mit der Bestellnummer.</p>', 77],
|
||||
['rechnungen', 'Rechnungen herunterladen', 'rechnungen', '<p>Alle Rechnungen findest du im Kundenbereich unter <b>Rechnungen</b>. Dort lädst du sie als PDF herunter.</p>', 156],
|
||||
] as [$c, $t, $s, $b, $v]) $art->execute([$cat($c), $t, $s, $b, $v, $ts('-20 days'), $ts('-5 days')]);
|
||||
|
||||
// zusätzliche Antworten des Assistenten
|
||||
$ans = $p->prepare('INSERT INTO assistant_answers (title, keywords, answer, is_active, hits, created_at, updated_at) VALUES (?,?,?,?,?,?,?)');
|
||||
foreach ([
|
||||
['Wie sind eure Öffnungszeiten?', 'geöffnet, Uhrzeit, erreichbar', "Wir sind Montag bis Freitag von 9 bis 17 Uhr für dich da.\nAußerhalb dieser Zeit beantwortet dich Mia oder du erstellst ein Ticket.", 1, 34],
|
||||
['Kann ich eine Bestellung stornieren?', 'Storno, abbrechen, stornieren', 'Solange die Bestellung noch nicht versendet ist, kannst du sie im Kundenbereich unter „Bestellungen“ stornieren.', 1, 19],
|
||||
['Wo finde ich meine Rechnung?', 'Beleg, Quittung', 'Deine Rechnungen findest du im Kundenbereich unter „Rechnungen“ als PDF.', 1, 52],
|
||||
] as $a) $ans->execute([$a[0], $a[1], $a[2], $a[3], $a[4], $ts('-15 days'), $ts('-2 days')]);
|
||||
|
||||
// Fragen-Log
|
||||
$lg = $p->prepare('INSERT INTO assistant_log (question, result, source, matched_title, ticket_number, helpful, created_at) VALUES (?,?,?,?,?,?,?)');
|
||||
foreach ([
|
||||
['Wie lange dauert der Versand?', 'kb', 'kb', 'Wie lange dauert der Versand?', null, 1, '-2 hours'], ['Wo finde ich meine Rechnung?', 'custom', 'custom', 'Wo finde ich meine Rechnung?', null, 1, '-3 hours'],
|
||||
['Wann seid ihr erreichbar?', 'custom', 'custom', 'Wie sind eure Öffnungszeiten?', null, 1, '-5 hours'], ['Passwort vergessen was tun', 'kb', 'kb', 'Passwort zurücksetzen', null, null, '-1 day'],
|
||||
['Wie kann ich meinen Vertrag kündigen?', 'none', null, null, null, null, '-1 day'], ['Wie kann ich meinen Vertrag kündigen?', 'none', null, null, null, null, '-2 days'],
|
||||
['Gibt es Rabatt für Schulen?', 'none', null, null, null, null, '-2 days'], ['Mein Paket ist beschädigt', 'kb', 'kb', 'Meine Sendung ist nicht angekommen', null, 0, '-3 days'],
|
||||
['Ich brauche Hilfe von einem Menschen', 'ticket_request', null, null, null, null, '-3 days'], ['Mein Paket ist beschädigt', 'ticket_created', null, null, '260001', null, '-3 days'],
|
||||
] as [$q, $r, $s, $m, $tn, $h, $when]) $lg->execute([$q, $r, $s, $m, $tn, $h, $ts($when)]);
|
||||
|
||||
// Tickets
|
||||
$tk = $p->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, assigned_staff_id, created_at, updated_at, resolution_due_at, snoozed_until, snoozed_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)');
|
||||
$th = $p->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?)');
|
||||
$defs = [
|
||||
['260101', 'Rechnung für März fehlt', 'Erika Muster', 'Buchhaltung', 'Hoch', 'open', 'Lena Hartmann', '-3 hours', '+5 hours', [['customer', null, 'Guten Tag, in meinem Kundenbereich fehlt die Rechnung für März. Können Sie mir diese bitte zuschicken? Danke!', 0, '-3 hours'], ['staff', 'Lena Hartmann', 'Hallo Frau Muster, ich schaue gleich nach und melde mich noch heute bei Ihnen.', 0, '-2 hours'], ['staff', 'Lena Hartmann', 'Intern: Rechnung wurde nicht automatisch erzeugt, bitte Buchhaltung prüfen lassen.', 1, '-90 minutes']]],
|
||||
['260102', 'Passwort zurücksetzen funktioniert nicht', 'Jonas Weber', 'Allgemeiner Support', 'Dringend', 'open', 'Tim Roth', '-26 hours', '-2 hours', [['customer', null, 'Der Link zum Zurücksetzen meines Passworts kommt nie an. Ich habe schon dreimal gedrückt.', 0, '-26 hours']]],
|
||||
['260103', 'Lieferstatus meiner Bestellung', 'Sophie Klein', 'Allgemeiner Support', 'Normal', 'answered', 'Tim Roth', '-30 hours', '+20 hours', [['customer', null, 'Wo ist meine Bestellung 48211? Sie sollte gestern kommen.', 0, '-30 hours'], ['staff', 'Tim Roth', 'Hallo Sophie, Ihre Bestellung ist beim Zusteller und kommt morgen. Hier die Sendungsnummer: 0034 5521 8890.', 0, '-28 hours']]],
|
||||
['260104', 'Frage zur Kündigung', 'Markus Lehmann', 'Allgemeiner Support', 'Normal', 'open', null, '-45 minutes', '+23 hours', [['customer', null, 'Wie kann ich meinen Vertrag zum Monatsende kündigen?', 0, '-45 minutes']]],
|
||||
['260105', 'App stürzt beim Start ab', 'Lea Wagner', 'Technik', 'Hoch', 'open', 'Mira Schulz', '-5 hours', '+3 hours', [['customer', null, 'Seit dem letzten Update stürzt die App auf meinem Handy direkt nach dem Start ab (Android 14).', 0, '-5 hours'], ['staff', 'Mira Schulz', 'Danke für den Hinweis. Wir können das reproduzieren und arbeiten an einem Fix.', 0, '-4 hours']]],
|
||||
['260106', 'Rückruf gewünscht', 'Dirk Neumann', 'Allgemeiner Support', 'Niedrig', 'open', 'Lena Hartmann', '-2 days', '+1 day', [['customer', null, 'Bitte rufen Sie mich nächste Woche wegen meines Angebots zurück.', 0, '-2 days']], '+1 day 08:00'],
|
||||
['259901', 'Passwort vergessen: Link kommt nicht an', 'Nina Fischer', 'Allgemeiner Support', 'Normal', 'resolved', 'Tim Roth', '-12 days', null, [['customer', null, 'Ich habe mein Passwort vergessen und bekomme keine E-Mail.', 0, '-12 days'], ['staff', 'Tim Roth', 'Der Link war im Spam-Ordner. Ich habe ihn erneut gesendet, jetzt klappt es.', 0, '-12 days']]],
|
||||
['259902', 'Rechnung als PDF anfordern', 'Paul Schmitt', 'Buchhaltung', 'Normal', 'closed', 'Lena Hartmann', '-20 days', null, [['customer', null, 'Bitte schicken Sie mir die Rechnung Nr. 5521 als PDF.', 0, '-20 days'], ['staff', 'Lena Hartmann', 'Die Rechnung ist angehängt. Viele Grüße', 0, '-20 days']]],
|
||||
['259903', 'Paket beschädigt angekommen', 'Clara Braun', 'Allgemeiner Support', 'Hoch', 'closed', 'Tim Roth', '-25 days', null, [['customer', null, 'Der Karton war aufgerissen.', 0, '-25 days'], ['staff', 'Tim Roth', 'Das tut uns leid, Ersatz ist unterwegs.', 0, '-24 days']]],
|
||||
['259904', 'Kündigung zum Jahresende', 'Felix Vogel', 'Allgemeiner Support', 'Normal', 'closed', 'Lena Hartmann', '-40 days', null, [['customer', null, 'Ich möchte kündigen.', 0, '-40 days'], ['staff', 'Lena Hartmann', 'Kündigung bestätigt.', 0, '-39 days']]],
|
||||
];
|
||||
foreach ($defs as $d) {
|
||||
[$no, $subject, $name, $dn, $pn, $status, $assignee, $created, $due, $threads] = $d;
|
||||
$snooze = $d[10] ?? null;
|
||||
$tk->execute([$no, $subject, $name, strtolower(str_replace(' ', '.', $name)) . '@kunde.example', $dept($dn), $prio($pn), $status, 'web', $assignee ? $staff[$assignee] : null,
|
||||
$ts($created), $ts($threads[count($threads) - 1][4]), $due ? $ts($due) : null, $snooze ? $ts($snooze) : null, $snooze ? $staff['Lena Hartmann'] : null]);
|
||||
$id = (int) $p->lastInsertId();
|
||||
foreach ($threads as [$who, $sn, $msg, $note, $when]) $th->execute([$id, $who, $sn ? $staff[$sn] : null, $msg, $note, $ts($when)]);
|
||||
if ($snooze) $p->prepare('INSERT INTO ticket_followers (ticket_id, staff_id, created_at) VALUES (?,?,?)')->execute([$id, $staff['Lena Hartmann'], $ts('-1 day')]);
|
||||
}
|
||||
$p->prepare('INSERT INTO ticket_followers (ticket_id, staff_id, created_at) VALUES ((SELECT id FROM tickets WHERE ticket_number = ?),?,?)')->execute(['260102', $staff['Lena Hartmann'], $ts('-20 hours')]);
|
||||
echo "Demo bereit: $tmp\n";
|
||||
Reference in New Issue
Block a user