Compare commits
2 Commits
c3e8ebfe6d
...
59b33ccb42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b33ccb42 | ||
|
|
4e681d08cc |
@@ -221,6 +221,9 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
|
||||
$routes->post('password-change', 'Admin\PasswordChange::update');
|
||||
});
|
||||
|
||||
// 사용자 의견 접수 (로그인 사용자)
|
||||
$routes->post('feedback', 'Feedback::store', ['filter' => 'loginAuth']);
|
||||
|
||||
// Auth
|
||||
$routes->get('login', 'Auth::showLoginForm');
|
||||
$routes->post('login', 'Auth::login');
|
||||
@@ -236,6 +239,13 @@ $routes->post('register', 'Auth::register');
|
||||
$routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): void {
|
||||
$routes->get('select-local-government', 'Admin\SelectLocalGovernment::index');
|
||||
$routes->post('select-local-government', 'Admin\SelectLocalGovernment::store');
|
||||
|
||||
// 사용자 의견 검토
|
||||
$routes->get('feedback', 'Admin\Feedback::index');
|
||||
$routes->get('feedback/image/(:num)', 'Admin\Feedback::image/$1');
|
||||
$routes->get('feedback/file/(:num)', 'Admin\Feedback::file/$1');
|
||||
$routes->get('feedback/(:num)', 'Admin\Feedback::show/$1');
|
||||
$routes->post('feedback/(:num)/status', 'Admin\Feedback::updateStatus/$1');
|
||||
$routes->get('/', 'Admin\Dashboard::index');
|
||||
$routes->get('users', 'Admin\User::index');
|
||||
$routes->get('users/create', 'Admin\User::create');
|
||||
|
||||
145
app/Controllers/Admin/Feedback.php
Normal file
145
app/Controllers/Admin/Feedback.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\FeedbackFileModel;
|
||||
use App\Models\FeedbackModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Roles;
|
||||
|
||||
/**
|
||||
* 사용자 의견 검토(관리자). super·본부는 전체, 지자체관리자는 자기 지자체만.
|
||||
*/
|
||||
class Feedback extends BaseController
|
||||
{
|
||||
private function scopeLgIdx(): ?int
|
||||
{
|
||||
helper('admin');
|
||||
$level = (int) session()->get('mb_level');
|
||||
if (Roles::isSuperAdminEquivalent($level)) {
|
||||
return null; // 전체
|
||||
}
|
||||
|
||||
return admin_effective_lg_idx();
|
||||
}
|
||||
|
||||
public function index(): string
|
||||
{
|
||||
$model = model(FeedbackModel::class);
|
||||
$builder = $model->orderBy('fb_idx', 'DESC');
|
||||
|
||||
$scope = $this->scopeLgIdx();
|
||||
if ($scope !== null) {
|
||||
$builder->where('fb_lg_idx', $scope);
|
||||
}
|
||||
|
||||
$status = (string) ($this->request->getGet('status') ?? '');
|
||||
if (in_array($status, FeedbackModel::STATUSES, true)) {
|
||||
$builder->where('fb_status', $status);
|
||||
}
|
||||
|
||||
$list = $builder->paginate(20);
|
||||
$pager = $model->pager;
|
||||
|
||||
return view('admin/layout', [
|
||||
'title' => '사용자 의견',
|
||||
'content' => view('admin/feedback/index', [
|
||||
'list' => $list,
|
||||
'pager' => $pager,
|
||||
'status' => $status,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id): string|ResponseInterface
|
||||
{
|
||||
$model = model(FeedbackModel::class);
|
||||
$row = $model->find($id);
|
||||
$scope = $this->scopeLgIdx();
|
||||
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) {
|
||||
return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$files = model(FeedbackFileModel::class)
|
||||
->where('ff_fb_idx', $id)
|
||||
->orderBy('ff_idx', 'ASC')
|
||||
->findAll();
|
||||
|
||||
return view('admin/layout', [
|
||||
'title' => '의견 상세',
|
||||
'content' => view('admin/feedback/show', ['row' => $row, 'files' => $files]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateStatus(int $id): ResponseInterface
|
||||
{
|
||||
$model = model(FeedbackModel::class);
|
||||
$row = $model->find($id);
|
||||
$scope = $this->scopeLgIdx();
|
||||
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) {
|
||||
return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$status = (string) $this->request->getPost('fb_status');
|
||||
$memo = (string) $this->request->getPost('fb_admin_memo');
|
||||
$update = ['fb_admin_memo' => mb_substr($memo, 0, 2000)];
|
||||
if (in_array($status, FeedbackModel::STATUSES, true)) {
|
||||
$update['fb_status'] = $status;
|
||||
}
|
||||
$model->update($id, $update);
|
||||
|
||||
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '처리 상태를 저장했습니다.');
|
||||
}
|
||||
|
||||
/** 스크린샷 이미지 서빙 (관리자만, writable 보호 유지) */
|
||||
public function image(int $id): ResponseInterface
|
||||
{
|
||||
$model = model(FeedbackModel::class);
|
||||
$row = $model->find($id);
|
||||
$scope = $this->scopeLgIdx();
|
||||
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope) || empty($row->fb_screenshot)) {
|
||||
return $this->response->setStatusCode(404)->setBody('not found');
|
||||
}
|
||||
|
||||
$path = WRITEPATH . 'uploads/feedback/' . basename((string) $row->fb_screenshot);
|
||||
if (! is_file($path)) {
|
||||
return $this->response->setStatusCode(404)->setBody('not found');
|
||||
}
|
||||
|
||||
$mime = str_ends_with($path, '.png') ? 'image/png' : 'image/jpeg';
|
||||
|
||||
return $this->response
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('Cache-Control', 'private, max-age=300')
|
||||
->setBody((string) file_get_contents($path));
|
||||
}
|
||||
|
||||
/** 첨부 이미지(feedback_file) 서빙 — 관리자만, 테넌트 검증 */
|
||||
public function file(int $ffIdx): ResponseInterface
|
||||
{
|
||||
$ff = model(FeedbackFileModel::class)->find($ffIdx);
|
||||
if ($ff === null) {
|
||||
return $this->response->setStatusCode(404)->setBody('not found');
|
||||
}
|
||||
$fb = model(FeedbackModel::class)->find((int) $ff->ff_fb_idx);
|
||||
$scope = $this->scopeLgIdx();
|
||||
if ($fb === null || ($scope !== null && (int) $fb->fb_lg_idx !== $scope)) {
|
||||
return $this->response->setStatusCode(404)->setBody('not found');
|
||||
}
|
||||
|
||||
$path = WRITEPATH . 'uploads/feedback/' . basename((string) $ff->ff_path);
|
||||
if (! is_file($path)) {
|
||||
return $this->response->setStatusCode(404)->setBody('not found');
|
||||
}
|
||||
|
||||
$mime = (string) ($ff->ff_mime ?: (str_ends_with($path, '.png') ? 'image/png' : 'image/jpeg'));
|
||||
|
||||
return $this->response
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('Cache-Control', 'private, max-age=300')
|
||||
->setBody((string) file_get_contents($path));
|
||||
}
|
||||
}
|
||||
98
app/Controllers/Feedback.php
Normal file
98
app/Controllers/Feedback.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\FeedbackFileModel;
|
||||
use App\Models\FeedbackModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* 사용자 의견 접수. 로그인 사용자가 화면에서 남긴 의견 + 스크린샷을 저장한다.
|
||||
* (계정·지자체는 클라이언트 값을 믿지 않고 세션에서 채운다.)
|
||||
*/
|
||||
class Feedback extends BaseController
|
||||
{
|
||||
public function store(): ResponseInterface
|
||||
{
|
||||
if (! session()->get('logged_in')) {
|
||||
return $this->response->setStatusCode(401)->setJSON(['ok' => false, 'message' => '로그인이 필요합니다.']);
|
||||
}
|
||||
|
||||
$content = trim((string) $this->request->getPost('content'));
|
||||
if ($content === '') {
|
||||
return $this->response->setJSON(['ok' => false, 'message' => '의견 내용을 입력해 주세요.']);
|
||||
}
|
||||
if (mb_strlen($content) > 1000) {
|
||||
$content = mb_substr($content, 0, 1000);
|
||||
}
|
||||
|
||||
helper('admin');
|
||||
$lgIdx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
|
||||
if (! $lgIdx) {
|
||||
$raw = session()->get('mb_lg_idx');
|
||||
$lgIdx = ($raw !== null && $raw !== '') ? (int) $raw : null;
|
||||
}
|
||||
|
||||
$model = model(FeedbackModel::class);
|
||||
$id = $model->insert([
|
||||
'fb_lg_idx' => $lgIdx,
|
||||
'fb_mb_idx' => (int) session()->get('mb_idx'),
|
||||
'fb_mb_name' => (string) (session()->get('mb_name') ?? ''),
|
||||
'fb_mb_level' => (int) session()->get('mb_level'),
|
||||
'fb_content' => $content,
|
||||
'fb_page_url' => mb_substr((string) $this->request->getPost('page_url'), 0, 255),
|
||||
'fb_page_title' => mb_substr((string) $this->request->getPost('page_title'), 0, 255),
|
||||
'fb_user_agent' => mb_substr((string) $this->request->getHeaderLine('User-Agent'), 0, 255),
|
||||
'fb_status' => 'new',
|
||||
], true);
|
||||
|
||||
if (! $id) {
|
||||
return $this->response->setJSON(['ok' => false, 'message' => '저장 중 오류가 발생했습니다.']);
|
||||
}
|
||||
|
||||
$dir = WRITEPATH . 'uploads/feedback/';
|
||||
if (! is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
$fileModel = model(FeedbackFileModel::class);
|
||||
|
||||
// 1) 화면 캡처(dataURL) — 실패해도 의견 본문은 이미 저장됨
|
||||
$shot = (string) $this->request->getPost('screenshot');
|
||||
if ($shot !== '' && preg_match('#^data:image/(png|jpeg);base64,#', $shot, $m) === 1) {
|
||||
$ext = $m[1] === 'png' ? 'png' : 'jpg';
|
||||
$bin = base64_decode(substr($shot, strpos($shot, ',') + 1), true);
|
||||
if ($bin !== false && strlen($bin) <= 5 * 1024 * 1024) {
|
||||
$file = 'fb_' . (int) $id . '_cap_' . bin2hex(random_bytes(4)) . '.' . $ext;
|
||||
if (@file_put_contents($dir . $file, $bin) !== false) {
|
||||
$model->update($id, ['fb_screenshot' => $file]); // 목록 캡처여부 표시용
|
||||
$fileModel->insert(['ff_fb_idx' => $id, 'ff_kind' => 'capture', 'ff_path' => $file, 'ff_mime' => 'image/' . ($ext === 'png' ? 'png' : 'jpeg')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 사용자가 첨부한 이미지(여러 장)
|
||||
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp'];
|
||||
$count = 0;
|
||||
foreach ($this->request->getFileMultiple('files') ?? [] as $f) {
|
||||
if ($count >= 5 || $f === null || ! $f->isValid() || $f->hasMoved()) {
|
||||
continue;
|
||||
}
|
||||
$mime = (string) $f->getMimeType();
|
||||
if (! isset($allowed[$mime]) || $f->getSize() > 5 * 1024 * 1024) {
|
||||
continue;
|
||||
}
|
||||
$file = 'fb_' . (int) $id . '_up_' . bin2hex(random_bytes(4)) . '.' . $allowed[$mime];
|
||||
try {
|
||||
$f->move($dir, $file);
|
||||
$fileModel->insert(['ff_fb_idx' => $id, 'ff_kind' => 'upload', 'ff_path' => $file, 'ff_mime' => $mime]);
|
||||
$count++;
|
||||
} catch (\Throwable $e) {
|
||||
// 개별 파일 실패는 건너뜀
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['ok' => true]);
|
||||
}
|
||||
}
|
||||
23
app/Models/FeedbackFileModel.php
Normal file
23
app/Models/FeedbackFileModel.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
/**
|
||||
* 사용자 의견 첨부 이미지(캡처/업로드).
|
||||
*/
|
||||
class FeedbackFileModel extends Model
|
||||
{
|
||||
protected $table = 'feedback_file';
|
||||
protected $primaryKey = 'ff_idx';
|
||||
protected $returnType = 'object';
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'ff_regdate';
|
||||
protected $updatedField = '';
|
||||
protected $dateFormat = 'datetime';
|
||||
|
||||
protected $allowedFields = ['ff_fb_idx', 'ff_kind', 'ff_path', 'ff_mime'];
|
||||
}
|
||||
40
app/Models/FeedbackModel.php
Normal file
40
app/Models/FeedbackModel.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
/**
|
||||
* 사용자 의견(피드백) 모델.
|
||||
*/
|
||||
class FeedbackModel extends Model
|
||||
{
|
||||
protected $table = 'feedback';
|
||||
protected $primaryKey = 'fb_idx';
|
||||
protected $returnType = 'object';
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'fb_regdate';
|
||||
protected $updatedField = 'fb_updated';
|
||||
protected $dateFormat = 'datetime';
|
||||
|
||||
protected $allowedFields = [
|
||||
'fb_lg_idx', 'fb_mb_idx', 'fb_mb_name', 'fb_mb_level',
|
||||
'fb_content', 'fb_page_url', 'fb_page_title', 'fb_screenshot',
|
||||
'fb_user_agent', 'fb_status', 'fb_admin_memo',
|
||||
];
|
||||
|
||||
/** 허용 상태 값 */
|
||||
public const STATUSES = ['new', 'in_progress', 'done', 'hold'];
|
||||
|
||||
public static function statusLabel(string $s): string
|
||||
{
|
||||
return [
|
||||
'new' => '접수',
|
||||
'in_progress' => '처리중',
|
||||
'done' => '완료',
|
||||
'hold' => '보류',
|
||||
][$s] ?? $s;
|
||||
}
|
||||
}
|
||||
66
app/Views/admin/feedback/index.php
Normal file
66
app/Views/admin/feedback/index.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* @var list<object> $list
|
||||
* @var string $status
|
||||
*/
|
||||
use App\Models\FeedbackModel;
|
||||
|
||||
$list = $list ?? [];
|
||||
$status = (string) ($status ?? '');
|
||||
$badge = static function (string $s): string {
|
||||
$map = [
|
||||
'new' => 'bg-blue-50 text-blue-700',
|
||||
'in_progress' => 'bg-amber-50 text-amber-700',
|
||||
'done' => 'bg-emerald-50 text-emerald-700',
|
||||
'hold' => 'bg-gray-100 text-gray-500',
|
||||
];
|
||||
$c = $map[$s] ?? 'bg-gray-100 text-gray-600';
|
||||
return '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ' . $c . '">' . esc(FeedbackModel::statusLabel($s)) . '</span>';
|
||||
};
|
||||
?>
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm font-bold text-gray-700">사용자 의견</span>
|
||||
<form method="get" class="flex items-center gap-2 text-sm">
|
||||
<select name="status" class="border border-gray-300 rounded px-2 py-1 text-sm" onchange="this.form.submit()">
|
||||
<option value="">전체 상태</option>
|
||||
<?php foreach (FeedbackModel::STATUSES as $s): ?>
|
||||
<option value="<?= esc($s, 'attr') ?>" <?= $status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
|
||||
<table class="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-16 text-left">번호</th>
|
||||
<th class="w-36 text-left">일시</th>
|
||||
<th class="w-20 text-left">상태</th>
|
||||
<th class="text-left">화면</th>
|
||||
<th class="text-left">내용</th>
|
||||
<th class="w-24 text-left">작성자</th>
|
||||
<th class="w-16 text-center">캡처</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($list)): ?>
|
||||
<tr><td colspan="7" class="text-center text-gray-400 py-6">접수된 의견이 없습니다.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($list as $row): ?>
|
||||
<tr class="cursor-pointer hover:bg-blue-50/60" onclick="window.location.href='<?= site_url('admin/feedback/' . (int) $row->fb_idx) ?>'">
|
||||
<td class="text-left text-gray-500"><?= (int) $row->fb_idx ?></td>
|
||||
<td class="text-left text-gray-500 text-[12px]"><?= esc((string) ($row->fb_regdate ?? '')) ?></td>
|
||||
<td class="text-left"><?= $badge((string) ($row->fb_status ?? 'new')) ?></td>
|
||||
<td class="text-left text-[12px] text-gray-600"><?= esc((string) ($row->fb_page_title ?: $row->fb_page_url)) ?></td>
|
||||
<td class="text-left"><?= esc(mb_substr((string) $row->fb_content, 0, 50)) ?><?= mb_strlen((string) $row->fb_content) > 50 ? '…' : '' ?></td>
|
||||
<td class="text-left text-[12px]"><?= esc((string) ($row->fb_mb_name ?? '')) ?></td>
|
||||
<td class="text-center"><?= ! empty($row->fb_screenshot) ? '<i class="fa-regular fa-image text-gray-400"></i>' : '' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
|
||||
64
app/Views/admin/feedback/show.php
Normal file
64
app/Views/admin/feedback/show.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/** @var object $row */
|
||||
use App\Models\FeedbackModel;
|
||||
?>
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-bold text-gray-700">의견 상세 #<?= (int) $row->fb_idx ?></span>
|
||||
<a href="<?= site_url('admin/feedback') ?>" class="text-sm text-gray-600 hover:underline">목록으로</a>
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-2">
|
||||
<section class="border border-gray-300 rounded-lg overflow-hidden">
|
||||
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">의견 내용</div>
|
||||
<div class="p-3 space-y-2 text-sm">
|
||||
<p class="whitespace-pre-wrap text-gray-800"><?= esc((string) $row->fb_content) ?></p>
|
||||
<hr class="my-2"/>
|
||||
<dl class="grid grid-cols-[5rem_1fr] gap-y-1 text-[12px] text-gray-600">
|
||||
<dt class="font-semibold">화면</dt><dd><?= esc((string) ($row->fb_page_title ?: '-')) ?><br><span class="text-gray-400"><?= esc((string) ($row->fb_page_url ?: '')) ?></span></dd>
|
||||
<dt class="font-semibold">작성자</dt><dd><?= esc((string) ($row->fb_mb_name ?? '')) ?> (idx <?= (int) $row->fb_mb_idx ?>, level <?= (int) $row->fb_mb_level ?>)</dd>
|
||||
<dt class="font-semibold">지자체</dt><dd><?= (int) ($row->fb_lg_idx ?? 0) ?></dd>
|
||||
<dt class="font-semibold">일시</dt><dd><?= esc((string) ($row->fb_regdate ?? '')) ?></dd>
|
||||
<dt class="font-semibold">브라우저</dt><dd class="break-all text-gray-400"><?= esc((string) ($row->fb_user_agent ?? '')) ?></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border border-gray-300 rounded-lg overflow-hidden">
|
||||
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">처리</div>
|
||||
<form action="<?= site_url('admin/feedback/' . (int) $row->fb_idx . '/status') ?>" method="post" class="p-3 space-y-3">
|
||||
<?= csrf_field() ?>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-700 mb-1">상태</label>
|
||||
<select name="fb_status" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-40">
|
||||
<?php foreach (FeedbackModel::STATUSES as $s): ?>
|
||||
<option value="<?= esc($s, 'attr') ?>" <?= (string) $row->fb_status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-700 mb-1">처리 메모</label>
|
||||
<textarea name="fb_admin_memo" rows="4" class="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" maxlength="2000"><?= esc((string) ($row->fb_admin_memo ?? '')) ?></textarea>
|
||||
</div>
|
||||
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">저장</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<?php $files = $files ?? []; ?>
|
||||
<?php if (! empty($files)): ?>
|
||||
<section class="border border-gray-300 rounded-lg overflow-hidden mt-3">
|
||||
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">첨부 이미지 (<?= count($files) ?>)</div>
|
||||
<div class="p-3 flex flex-wrap gap-3">
|
||||
<?php foreach ($files as $f): ?>
|
||||
<figure class="m-0">
|
||||
<a href="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" target="_blank" rel="noopener">
|
||||
<img src="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" alt="첨부 이미지"
|
||||
style="max-width:280px;max-height:220px;border:1px solid #e5e7eb;border-radius:6px;"/>
|
||||
</a>
|
||||
<figcaption class="text-[11px] text-gray-400 mt-1"><?= $f->ff_kind === 'capture' ? '화면 캡처' : '사용자 첨부' ?></figcaption>
|
||||
</figure>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
@@ -190,5 +190,6 @@ tailwind.config = {
|
||||
window.addEventListener('storage', function (e) { if (e.key === FONT_KEY) applyScale(curScale()); });
|
||||
})();
|
||||
</script>
|
||||
<?= view('components/feedback_widget') ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -158,9 +158,32 @@ $mapId = 'mainKakaoMap';
|
||||
var bounds = new kakao.maps.LatLngBounds();
|
||||
var placed = 0, pending = SHOPS.length;
|
||||
|
||||
// 컨테이너 크기에 맞춰 지도를 다시 그리고 영역을 재설정한다.
|
||||
function refit() {
|
||||
if (!mapRef) return;
|
||||
mapRef.relayout();
|
||||
if (placed > 0) { mapRef.setBounds(bounds); }
|
||||
else { mapRef.setCenter(new kakao.maps.LatLng(DEFAULT_CENTER.lat, DEFAULT_CENTER.lng)); }
|
||||
}
|
||||
var refitScheduled = false;
|
||||
function scheduleRefit() {
|
||||
if (refitScheduled) return;
|
||||
refitScheduled = true;
|
||||
(window.requestAnimationFrame || window.setTimeout)(function () { refitScheduled = false; refit(); });
|
||||
}
|
||||
|
||||
// 탭(iframe) 안에서 컨테이너 크기가 늦게 확정되거나 바뀌면(표시 전환·창 크기 변경 등)
|
||||
// 지도가 반만 그려지거나 엉뚱한 위치(일본 등)로 보이므로, 크기 변화 때마다 다시 맞춘다.
|
||||
try {
|
||||
if (typeof ResizeObserver !== 'undefined') { new ResizeObserver(scheduleRefit).observe(el); }
|
||||
} catch (e) {}
|
||||
window.addEventListener('resize', scheduleRefit);
|
||||
window.addEventListener('pageshow', function () { setTimeout(refit, 50); });
|
||||
|
||||
function done() {
|
||||
if (placed > 0) map.setBounds(bounds);
|
||||
setTimeout(function () { map.relayout(); if (placed > 0) map.setBounds(bounds); }, 150);
|
||||
refit();
|
||||
setTimeout(refit, 150);
|
||||
setTimeout(refit, 600);
|
||||
}
|
||||
if (pending === 0) { done(); return; }
|
||||
|
||||
|
||||
@@ -218,5 +218,6 @@ tailwind.config = {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true }); else run();
|
||||
})();
|
||||
</script>
|
||||
<?= view('components/feedback_widget') ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -285,5 +285,6 @@ tailwind.config = {
|
||||
if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); });
|
||||
})();
|
||||
</script>
|
||||
<?= view('components/feedback_widget') ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
144
app/Views/components/feedback_widget.php
Normal file
144
app/Views/components/feedback_widget.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* 사용자 의견 위젯 — 화면 우하단 플로팅 버튼 + 모달.
|
||||
* 보내기 시 화면을 html2canvas 로 캡처해 의견과 함께 전송한다.
|
||||
* 필요한 화면(뷰)에서 <?= view('components/feedback_widget') ?> 로 포함.
|
||||
*/
|
||||
$apiPath = (string) parse_url(base_url('feedback'), PHP_URL_PATH);
|
||||
?>
|
||||
<div id="fbWidget" class="no-print" style="position:fixed;right:16px;bottom:16px;z-index:11000;">
|
||||
<button type="button" id="fbOpen" title="이 화면에 대한 의견 보내기"
|
||||
style="display:inline-flex;align-items:center;gap:.4rem;background:#243a5e;color:#fff;border:0;border-radius:999px;padding:.6rem .9rem;font-size:.8rem;font-weight:700;box-shadow:0 4px 14px rgba(0,0,0,.25);cursor:pointer;">
|
||||
<i class="fa-regular fa-comment-dots"></i> 의견
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="fbModal" class="no-print" style="display:none;position:fixed;inset:0;z-index:11001;background:rgba(0,0,0,.35);">
|
||||
<div style="position:absolute;right:16px;bottom:16px;width:min(380px,92vw);background:#fff;border-radius:12px;box-shadow:0 10px 40px rgba(0,0,0,.3);overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:.7rem .9rem;background:#1a2b4b;color:#fff;">
|
||||
<span style="font-size:.85rem;font-weight:700;"><i class="fa-regular fa-comment-dots"></i> 이 화면 의견 보내기</span>
|
||||
<button type="button" id="fbClose" style="background:transparent;border:0;color:#fff;font-size:1.1rem;cursor:pointer;">×</button>
|
||||
</div>
|
||||
<div style="padding:.9rem;">
|
||||
<textarea id="fbContent" rows="5" maxlength="1000" placeholder="이 화면에서 불편하거나 바꿨으면 하는 점을 자유롭게 적어 주세요."
|
||||
style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:.6rem;font-size:.85rem;resize:vertical;box-sizing:border-box;"></textarea>
|
||||
<label style="display:flex;align-items:center;gap:.4rem;margin-top:.5rem;font-size:.75rem;color:#6b7280;">
|
||||
<input type="checkbox" id="fbShot" checked/> 현재 화면 캡처 첨부
|
||||
</label>
|
||||
<div style="margin-top:.5rem;">
|
||||
<label for="fbFiles" style="display:inline-flex;align-items:center;gap:.35rem;font-size:.75rem;color:#243a5e;border:1px dashed #c7cdd6;border-radius:8px;padding:.4rem .6rem;cursor:pointer;">
|
||||
<i class="fa-regular fa-image"></i> 이미지 첨부 (최대 5장)
|
||||
</label>
|
||||
<input type="file" id="fbFiles" accept="image/*" multiple style="display:none;"/>
|
||||
<div id="fbPreview" style="display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.4rem;"></div>
|
||||
</div>
|
||||
<div id="fbMsg" style="font-size:.75rem;margin-top:.4rem;min-height:1em;"></div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:.4rem;margin-top:.5rem;">
|
||||
<button type="button" id="fbCancel" style="border:1px solid #d1d5db;background:#fff;border-radius:8px;padding:.45rem .8rem;font-size:.8rem;cursor:pointer;">취소</button>
|
||||
<button type="button" id="fbSend" style="border:0;background:#243a5e;color:#fff;border-radius:8px;padding:.45rem .9rem;font-size:.8rem;font-weight:700;cursor:pointer;">보내기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var API = location.origin + <?= json_encode($apiPath, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
var widget = document.getElementById('fbWidget');
|
||||
var modal = document.getElementById('fbModal');
|
||||
var content = document.getElementById('fbContent');
|
||||
var msg = document.getElementById('fbMsg');
|
||||
var sendBtn = document.getElementById('fbSend');
|
||||
var shotChk = document.getElementById('fbShot');
|
||||
var fileInput = document.getElementById('fbFiles');
|
||||
var preview = document.getElementById('fbPreview');
|
||||
var attachments = []; // 선택된 이미지 File 목록(최대 5)
|
||||
|
||||
function renderPreview() {
|
||||
preview.innerHTML = '';
|
||||
attachments.forEach(function (file, i) {
|
||||
var url = URL.createObjectURL(file);
|
||||
var box = document.createElement('span');
|
||||
box.style.cssText = 'position:relative;display:inline-block;';
|
||||
box.innerHTML = '<img src="' + url + '" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid #e5e7eb;">'
|
||||
+ '<button type="button" data-i="' + i + '" title="제거" style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;border:0;background:#dc2626;color:#fff;font-size:11px;line-height:1;cursor:pointer;">×</button>';
|
||||
preview.appendChild(box);
|
||||
});
|
||||
}
|
||||
fileInput.addEventListener('change', function () {
|
||||
Array.prototype.forEach.call(fileInput.files || [], function (f) {
|
||||
if (attachments.length < 5 && /^image\//.test(f.type)) { attachments.push(f); }
|
||||
});
|
||||
fileInput.value = '';
|
||||
renderPreview();
|
||||
});
|
||||
preview.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-i]');
|
||||
if (!b) return;
|
||||
attachments.splice(parseInt(b.getAttribute('data-i'), 10), 1);
|
||||
renderPreview();
|
||||
});
|
||||
|
||||
function open() { modal.style.display = 'block'; msg.textContent = ''; content.focus(); }
|
||||
function close() { modal.style.display = 'none'; }
|
||||
document.getElementById('fbOpen').addEventListener('click', open);
|
||||
document.getElementById('fbClose').addEventListener('click', close);
|
||||
document.getElementById('fbCancel').addEventListener('click', close);
|
||||
|
||||
// html2canvas 지연 로드
|
||||
function ensureH2C(cb) {
|
||||
if (typeof html2canvas === 'function') { cb(); return; }
|
||||
var s = document.createElement('script');
|
||||
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js';
|
||||
s.onload = function () { cb(); };
|
||||
s.onerror = function () { cb(); }; // 실패해도 텍스트만 전송
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function capture(cb) {
|
||||
if (!shotChk.checked) { cb(''); return; }
|
||||
ensureH2C(function () {
|
||||
if (typeof html2canvas !== 'function') { cb(''); return; }
|
||||
var prevW = widget.style.display, prevM = modal.style.display;
|
||||
widget.style.display = 'none'; modal.style.display = 'none';
|
||||
var scale = Math.min(1, 1280 / Math.max(1, window.innerWidth));
|
||||
html2canvas(document.body, { scale: scale, useCORS: true, logging: false, backgroundColor: '#ffffff' })
|
||||
.then(function (canvas) {
|
||||
widget.style.display = prevW; modal.style.display = prevM;
|
||||
try { cb(canvas.toDataURL('image/jpeg', 0.7)); } catch (e) { cb(''); }
|
||||
})
|
||||
.catch(function () { widget.style.display = prevW; modal.style.display = prevM; cb(''); });
|
||||
});
|
||||
}
|
||||
|
||||
function send() {
|
||||
var text = (content.value || '').trim();
|
||||
if (!text) { msg.style.color = '#dc2626'; msg.textContent = '의견 내용을 입력해 주세요.'; return; }
|
||||
sendBtn.disabled = true; msg.style.color = '#6b7280'; msg.textContent = '전송 중…';
|
||||
capture(function (shot) {
|
||||
var fd = new FormData();
|
||||
fd.append('content', text);
|
||||
fd.append('page_url', location.pathname + location.search);
|
||||
fd.append('page_title', (document.title || '').slice(0, 200));
|
||||
if (shot) fd.append('screenshot', shot);
|
||||
attachments.forEach(function (file) { fd.append('files[]', file, file.name); });
|
||||
fetch(API, { method: 'POST', body: fd, credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
sendBtn.disabled = false;
|
||||
if (d && d.ok) {
|
||||
msg.style.color = '#059669'; msg.textContent = '의견이 접수되었습니다. 감사합니다.';
|
||||
content.value = '';
|
||||
attachments.length = 0; renderPreview();
|
||||
setTimeout(close, 900);
|
||||
} else {
|
||||
msg.style.color = '#dc2626'; msg.textContent = (d && d.message) || '전송에 실패했습니다.';
|
||||
}
|
||||
})
|
||||
.catch(function () { sendBtn.disabled = false; msg.style.color = '#dc2626'; msg.textContent = '전송 중 오류가 발생했습니다.'; });
|
||||
});
|
||||
}
|
||||
sendBtn.addEventListener('click', send);
|
||||
})();
|
||||
</script>
|
||||
10
writable/database/feedback_file_table.sql
Normal file
10
writable/database/feedback_file_table.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- 사용자 의견 첨부 이미지(여러 장). 캡처/업로드 공통.
|
||||
CREATE TABLE IF NOT EXISTS feedback_file (
|
||||
ff_idx INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ff_fb_idx INT NOT NULL,
|
||||
ff_kind VARCHAR(20) NOT NULL DEFAULT 'upload', -- capture | upload
|
||||
ff_path VARCHAR(255) NOT NULL,
|
||||
ff_mime VARCHAR(50) NULL,
|
||||
ff_regdate DATETIME NULL,
|
||||
INDEX idx_ff_fb (ff_fb_idx)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
21
writable/database/feedback_table.sql
Normal file
21
writable/database/feedback_table.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- 사용자 의견(피드백) 테이블
|
||||
-- 각 화면에서 사용자가 남긴 의견 + 자동수집 맥락(화면·계정·지자체) + 스크린샷 경로 저장.
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
fb_idx INT AUTO_INCREMENT PRIMARY KEY,
|
||||
fb_lg_idx INT NULL,
|
||||
fb_mb_idx INT NULL,
|
||||
fb_mb_name VARCHAR(100) NULL,
|
||||
fb_mb_level INT NULL,
|
||||
fb_content TEXT NOT NULL,
|
||||
fb_page_url VARCHAR(255) NULL,
|
||||
fb_page_title VARCHAR(255) NULL,
|
||||
fb_screenshot VARCHAR(255) NULL,
|
||||
fb_user_agent VARCHAR(255) NULL,
|
||||
fb_status VARCHAR(20) NOT NULL DEFAULT 'new',
|
||||
fb_admin_memo TEXT NULL,
|
||||
fb_regdate DATETIME NULL,
|
||||
fb_updated DATETIME NULL,
|
||||
INDEX idx_fb_lg (fb_lg_idx),
|
||||
INDEX idx_fb_status (fb_status),
|
||||
INDEX idx_fb_regdate (fb_regdate)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
Reference in New Issue
Block a user