commit a3434ec620c32f03426fb6c17dcf29c316a69ad1 Author: Xiaoxiaobai5724 Date: Fri Aug 14 10:55:06 2026 +0800 2.1 版本文件 diff --git a/admin/_common.php b/admin/_common.php new file mode 100644 index 0000000..885093a --- /dev/null +++ b/admin/_common.php @@ -0,0 +1,105 @@ + ['url' => './', 'label' => ' 仪表盘'], + 'products' => ['url' => 'products', 'label' => ' 商品管理'], + 'groups' => ['url' => 'groups', 'label' => ' 商品分组'], + 'orders' => ['url' => 'orders', 'label' => ' 订单管理'], + 'tickets' => ['url' => 'tickets', 'label' => ' 工单管理'], + 'departments' => ['url' => 'departments','label' => ' 工单部门'], + 'announcements' => ['url' => 'announcements', 'label' => ' 公告中心'], + 'users' => ['url' => 'users', 'label' => ' 用户管理'], + 'admins' => ['url' => 'admins', 'label' => ' 管理员'], + 'settings' => ['url' => 'settings', 'label' => ' 设置'], + 'logs' => ['url' => 'logs', 'label' => ' 系统日志'], + ]; + // 管理员与客服均可见全部导航(客服为只读/受限操作,在各页面内单独控制) + $nav = $navAll; + $theme = currentTheme() === 'dark' ? 'dark' : 'light'; +?> + + + + + + <?= h($pageTitle) ?> +' . "\n "; +} +?> + + + + +
+ +
+
+ +
+ + + + + prepare('SELECT id, role, is_admin FROM ' . tn('users') . ' WHERE id = ? AND role IN (\'admin\',\'cs\')')->execute([$id])->fetch(); + if (!$row) { + $err = '账号不存在。'; + } else { + $newIsAdmin = ($role === 'admin') ? 1 : 0; + $db->prepare('UPDATE ' . tn('users') . ' SET role = ?, is_admin = ? WHERE id = ?') + ->execute([$role, $newIsAdmin, $id]); + $msg = '已将账号 #' . $id . ' 角色改为「' . ($role === 'admin' ? '管理员' : '客服') . '」。'; + } + } +} + +// ─── 删除 ── +if (isset($_POST['delete']) && $isAdmin) { + verifyCsrf(); + $id = (int) ($_POST['delete'] ?? 0); + if ($id === (int) $me['id']) { + $err = '不能删除当前登录的账号。'; + } elseif ($id === 1) { + $err = '不能删除终极管理员(ID=1),该账号受系统保护。'; + } else { + $row = $db->prepare('SELECT id, role, is_admin FROM ' . tn('users') . ' WHERE id = ? AND role IN (\'admin\',\'cs\')')->execute([$id])->fetch(); + if (!$row) { + $err = '账号不存在。'; + } elseif ((int) $row['is_admin'] === 1) { + // 管理员:确保至少保留一名管理员(含终极管理员) + $cnt = $db->query('SELECT COUNT(*) FROM ' . tn('users') . ' WHERE role = \'admin\' AND status = 1')->fetchColumn(); + if ((int) $cnt <= 1) { + $err = '至少要保留一名管理员,不能继续删除。'; + } else { + $db->prepare('DELETE FROM ' . tn('users') . ' WHERE id = ?')->execute([$id]); + $msg = '管理员已删除。'; + } + } else { + $db->prepare('DELETE FROM ' . tn('users') . ' WHERE id = ?')->execute([$id]); + $msg = '客服已删除。'; + } + } +} + +// ─── 添加 ── +if (isset($_POST['add']) && $isAdmin) { + verifyCsrf(); + $u = trim($_POST['username'] ?? ''); + $em = trim($_POST['email'] ?? ''); + $pw = $_POST['password'] ?? ''; + $role = trim($_POST['role'] ?? 'admin'); + $role = in_array($role, ['admin', 'cs'], true) ? $role : 'admin'; + if (strlen($u) < 3) $err = '账号至少 3 个字符。'; + elseif (!filter_var($em, FILTER_VALIDATE_EMAIL)) $err = '请填写有效的邮箱。'; + elseif (strlen($pw) < 6) $err = '密码至少 6 位。'; + else { + $chk = $db->prepare('SELECT id FROM ' . tn('users') . ' WHERE username = ?'); + $chk->execute([$u]); + if ($chk->fetch()) { + $err = '该用户名已存在。'; + } else { + $hash = password_hash($pw, PASSWORD_DEFAULT); + $code = genInviteCode(); + $isAdminFlag = $role === 'admin' ? 1 : 0; + $db->prepare('INSERT INTO ' . tn('users') . ' (username,password,email,invite_code,is_admin,role,created_at) VALUES (?,?,?,?,?,?,NOW())') + ->execute([$u, $hash, $em, $code, $isAdminFlag, $role]); + $msg = ($role === 'admin' ? '管理员' : '客服') . '已添加。'; + } + } +} + +$list = $db->query('SELECT id, username, email, status, role, created_at FROM ' . tn('users') . ' WHERE role IN (\'admin\',\'cs\') ORDER BY role DESC, id ASC')->fetchAll(); + +adminHeader('管理员', 'admins'); +?> +

管理员与客服

+ + +

+ +
+

添加账号(管理员 / 客服)

+ +

管理员拥有全部后台权限;客服可查看所有页面但仅能处理工单相关操作。

+
+ + + + + +
+
+ +

客服账号无法添加新管理员,如需操作请联系管理员。

+ +
+ +
+

账号列表(共 名)

+ +

暂无账号。

+ + + + + + + > + + + + + + + + + + +
ID账号邮箱角色状态注册时间操作
超级' : '' ?>我' : '' ?> + +
+ + + +
+ + + 管理员 + + 客服 + + (受保护) + +
正常' : '封禁' ?> + + 当前账号 + + + 受保护 + +
+ + +
+ + + + +
+ +

+ 终极管理员(ID=1)受系统保护,不可被删除或降级。其他管理员可由同级管理员删除或改为客服。 +

+ + +
+ + diff --git a/admin/announcements.php b/admin/announcements.php new file mode 100644 index 0000000..66b14f9 --- /dev/null +++ b/admin/announcements.php @@ -0,0 +1,145 @@ +prepare('DELETE FROM ' . tn('announcements') . ' WHERE id = ?')->execute([$id]); + $msg = '已删除这条公告。'; +} + +if (isset($_POST['save'])) { + verifyCsrf(); + $id = (int) ($_POST['id'] ?? 0); + $title = trim($_POST['title'] ?? ''); + $content = trim($_POST['content'] ?? ''); + $pinned = isset($_POST['pinned']) ? 1 : 0; + $status = isset($_POST['status']) ? 1 : 0; + + if ($title === '') $err = '请填写公告标题。'; + elseif ($content === '') $err = '请填写公告内容。'; + + if ($err === '') { + if ($id > 0) { + $db->prepare('UPDATE ' . tn('announcements') . ' SET title=?, content=?, pinned=?, status=? WHERE id=?') + ->execute([$title, $content, $pinned, $status, $id]); + $msg = '公告已更新!'; + } else { + $db->prepare('INSERT INTO ' . tn('announcements') . ' (title, content, pinned, status, created_at) VALUES (?,?,?,?,NOW())') + ->execute([$title, $content, $pinned, $status]); + $msg = '公告已发布!'; + } + } +} + +$edit = null; +if (isset($_GET['edit'])) { + $stmt = $db->prepare('SELECT * FROM ' . tn('announcements') . ' WHERE id = ?'); + $stmt->execute([(int) $_GET['edit']]); + $edit = $stmt->fetch(); +} +$showForm = ($edit !== null) || isset($_GET['act']) && $_GET['act'] === 'add'; + +$list = $db->query('SELECT * FROM ' . tn('announcements') . ' ORDER BY pinned DESC, created_at DESC')->fetchAll(); + +adminHeader('公告中心', 'announcements'); +?> + +

公告中心

+ + +

+ + +
+

+
+ + + + +
+
+ 实时预览 + +
+
+
+ + +
+ + 取消 +
+
+
+ + +
+
+

公告列表(共 条)

+ 发布公告 +
+ +

还没有公告,点上方「发布公告」。

+ + + + + + + + + + + + + + + +
ID标题置顶显示时间操作
置顶' : '' ?>显示' : '隐藏' ?> + + +
+ + +
+
+ +
+ + + + diff --git a/admin/departments.php b/admin/departments.php new file mode 100644 index 0000000..144b7eb --- /dev/null +++ b/admin/departments.php @@ -0,0 +1,87 @@ +prepare('UPDATE ' . tn('tickets') . ' SET dept_id = 0 WHERE dept_id = ?')->execute([$id]); + $db->prepare('DELETE FROM ' . tn('ticket_departments') . ' WHERE id = ?')->execute([$id]); + $msg = '部门已删除,其工单已移至「未指定」。'; +} + +// 新增 +if (isset($_POST['add'])) { + verifyCsrf(); + $name = trim($_POST['name'] ?? ''); + $desc = trim($_POST['description'] ?? ''); + $sort = (int) ($_POST['sort'] ?? 0); + if ($name === '') { + $err = '请填写部门名称'; + } else { + $db->prepare('INSERT INTO ' . tn('ticket_departments') . ' (name, description, sort, created_at) VALUES (?,?,?,NOW())') + ->execute([$name, $desc, $sort]); + $msg = '部门已添加'; + } +} + +$list = $db->query('SELECT * FROM ' . tn('ticket_departments') . ' ORDER BY sort, id')->fetchAll(); +$counts = $db->query('SELECT dept_id, COUNT(*) c FROM ' . tn('tickets') . ' GROUP BY dept_id')->fetchAll(PDO::FETCH_KEY_PAIR); + +adminHeader('工单部门', 'departments'); +?> +

工单部门

+ + +

+ +
+

新增部门

+
+ + + + +
+
+
+ +
+

部门列表(共 个)

+ +

还没有部门。

+ + + + + + + + + + + + + + +
ID名称说明工单数操作
+
+ + +
+
+ +
+ diff --git a/admin/groups.php b/admin/groups.php new file mode 100644 index 0000000..c78dac5 --- /dev/null +++ b/admin/groups.php @@ -0,0 +1,137 @@ + 40) { + $err = '分组名称过长(最多 40 字)'; + } else { + // 重名检查(排除自身) + $dup = $db->prepare('SELECT COUNT(*) FROM ' . tn('product_groups') . ' WHERE name = ? AND id <> ?'); + $dup->execute([$name, $id]); + if ((int) $dup->fetchColumn() > 0) { + $err = '已存在同名分组,请换一个名称'; + } + } + + if ($err === '') { + if ($id > 0) { + $db->prepare('UPDATE ' . tn('product_groups') . ' SET name=?, icon=?, sort_order=? WHERE id=?') + ->execute([$name, $icon, $sort, $id]); + $msg = '分组已更新'; + } else { + $db->prepare('INSERT INTO ' . tn('product_groups') . ' (name, icon, sort_order, created_at) VALUES (?,?,?,NOW())') + ->execute([$name, $icon, $sort]); + $msg = '分组已添加'; + } + } +} + +// ---------- 删除 ---------- +if (isset($_POST['delete'])) { + verifyCsrf(); + $id = (int) $_POST['delete']; + $row = $db->prepare('SELECT name FROM ' . tn('product_groups') . ' WHERE id = ?'); + $row->execute([$id]); + $g = $row->fetch(); + if ($g) { + // 将该分组下的商品归为「其他」,避免孤儿数据 + $db->prepare('UPDATE ' . tn('products') . ' SET category = ? WHERE category = ?') + ->execute(['其他', $g['name']]); + $db->prepare('DELETE FROM ' . tn('product_groups') . ' WHERE id = ?')->execute([$id]); + $msg = '分组「' . $g['name'] . '」已删除,相关商品已归为「其他」'; + } +} + +// ---------- 编辑回填 ---------- +$edit = null; +if (isset($_GET['edit'])) { + $stmt = $db->prepare('SELECT * FROM ' . tn('product_groups') . ' WHERE id = ?'); + $stmt->execute([(int) $_GET['edit']]); + $edit = $stmt->fetch(); +} +$showForm = ($edit !== null) || (isset($_GET['act']) && $_GET['act'] === 'add'); + +// ---------- 列表(含商品计数) ---------- +$list = $db->query( + 'SELECT g.*, (SELECT COUNT(*) FROM ' . tn('products') . ' p WHERE p.category = g.name) AS cnt + FROM ' . tn('product_groups') . ' g ORDER BY g.sort_order ASC, g.id ASC' +)->fetchAll(); + +adminHeader('商品分组', 'groups'); +?> +

商品分组

+

分组用于首页分类筛选与商品归类。删除分组会将其下商品自动归为「其他」,不会丢失商品数据。

+ + +

+ + +
+

+
+ + + + + +
+ + 取消 +
+
+
+ + +
+
+

分组列表(共 个)

+ 新增分组 +
+ +

还没有分组,点上方「新增分组」添加吧。

+ + + + + + + + + + + + + + + +
ID图标名称排序商品数操作
+ +
+ + +
+
+ +
+ + diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..d486ede --- /dev/null +++ b/admin/index.php @@ -0,0 +1,132 @@ +query("SELECT COUNT(*) FROM " . tn('tickets') . " WHERE status IN ('open','pending')")->fetchColumn(); +$myOpenTickets = 0; +if (!$isAdmin) { + // 客服:仅统计分配给自己的待处理工单 + $stmt = $db->prepare("SELECT COUNT(*) FROM " . tn('tickets') . " WHERE status IN ('open','pending') AND assignee_id = ?"); + $stmt->execute([$me['id']]); + $myOpenTickets = (int) $stmt->fetchColumn(); +} + +$recentTickets = $db->query( + 'SELECT t.*, u.username FROM ' . tn('tickets') . ' t + LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id + ' . ($isAdmin ? '' : ' WHERE t.assignee_id = ' . (int)$me['id']) . ' + ORDER BY t.updated_at DESC LIMIT 5' +)->fetchAll(); + +$statCards = []; +if ($isAdmin) { + $onSale = $db->query('SELECT COUNT(*) FROM ' . tn('products') . ' WHERE status = 1')->fetchColumn(); + $orderCnt = $db->query('SELECT COUNT(*) FROM ' . tn('orders'))->fetchColumn(); + $sales = $db->query("SELECT COALESCE(SUM(total),0) FROM " . tn('orders') . " WHERE status IN ('paid','shipped','completed')")->fetchColumn(); + $pending = $db->query("SELECT COUNT(*) FROM " . tn('orders') . " WHERE status = 'paid'")->fetchColumn(); + $annCnt = $db->query('SELECT COUNT(*) FROM ' . tn('announcements') . ' WHERE status = 1')->fetchColumn(); + $groupCnt = $db->query('SELECT COUNT(*) FROM ' . tn('product_groups'))->fetchColumn(); + + $statCards[] = ['num' => (int)$onSale, 'label' => '在售商品']; + $statCards[] = ['num' => (int)$orderCnt, 'label' => '累计订单']; + $statCards[] = ['num' => money($sales), 'label' => '销售总额']; + $statCards[] = ['num' => (int)$pending, 'label' => '等待发货', 'warn' => true]; + $statCards[] = ['num' => (int)$annCnt, 'label' => '发布公告']; + $statCards[] = ['num' => (int)$groupCnt, 'label' => '商品分组']; +} +$statCards[] = ['num' => ($isAdmin ? (int)$openTickets : $myOpenTickets), 'label' => ($isAdmin ? '待处理工单' : '我待处理工单'), 'warn' => true]; + +adminHeader('仪表盘', 'index'); +?> + +

仪表盘

+ +
+ +
+ +
+ + +

最新订单

+query( + 'SELECT o.*, u.username FROM ' . tn('orders') . ' o + LEFT JOIN ' . tn('users') . ' u ON u.id = o.user_id + ORDER BY o.created_at DESC LIMIT 5' +)->fetchAll(); +?> + +

暂无订单。

+ + + + + + + + + + + + + + +
订单号用户金额状态时间
+ 查看全部订单 → + + + +

+ +

暂无工单。

+ + + + + + + + + + + + + + + +
编号标题提交人状态更新
#处理
+ 查看全部工单 → + + + +

最新公告

+query('SELECT * FROM ' . tn('announcements') . ' ORDER BY pinned DESC, created_at DESC LIMIT 5')->fetchAll(); +?> + +

暂无公告。

+ + + + + + + + + + + + +
标题置顶时间
' : '' ?>
+ 管理公告 → + + + + diff --git a/admin/login.php b/admin/login.php new file mode 100644 index 0000000..6537fec --- /dev/null +++ b/admin/login.php @@ -0,0 +1,60 @@ +prepare('SELECT * FROM ' . tn('users') . ' WHERE username = ? AND role IN (\'admin\', \'cs\')'); + $stmt->execute([$u]); + $row = $stmt->fetch(); + if ($row && password_verify($pw, $row['password'])) { + $_SESSION['admin_id'] = $row['id']; + $_SESSION['admin_user'] = $row['username']; + unset($_SESSION['csrf']); + header('Location: index.php'); + exit; + } + $err = '管理员账号或密码错误'; +} +?> + + + + + + 后台登录 - <?= h(SITE_NAME) ?> + + + + +
+
+

后台登录

+

管理后台

+

+
+ + + + +
+ 返回商城前台 +
+
+ + diff --git a/admin/logout.php b/admin/logout.php new file mode 100644 index 0000000..263ff6c --- /dev/null +++ b/admin/logout.php @@ -0,0 +1,7 @@ + +

系统日志

+ + +

+ + +
+
+ + + + 可用日期: + + + + 14): ?>... 等 + + +
+
+ +
+

+ .log + 0): ?> + (共 条) + +

+ + +

当天暂无日志记录。

+

+ 日志级别:DEBUG / INFO / WARN / ERROR。当前模式:
+ 可在 config.php 中修改 define('DEBUG', true) 以开启 DEBUG 级别记录。 +

+ + + 1): ?> +
+ 1): ?> + « 上一页 + + / + + 下一页 » + +
+ + +
+ + + + 'log-debug', + 'INFO' => 'log-info', + 'WARN' => 'log-warn', + 'ERROR' => 'log-error', + default => '' + }; + ?> + + + + + + + + +
时间级别来源内容
+
+ + + 1): ?> +
+ 1): ?> + « 上一页 + + / + + 下一页 » + +
+ + +
+ + + diff --git a/admin/orders.php b/admin/orders.php new file mode 100644 index 0000000..7ff9ba1 --- /dev/null +++ b/admin/orders.php @@ -0,0 +1,306 @@ +prepare('UPDATE ' . tn('orders') . ' SET status = ? WHERE id = ?')->execute([$status, $id]); + $msg = '订单 #' . $id . ' 状态已更新为「' . orderStatusLabel($status) . '」'; + } +} + +// 发货:填写连接/登录信息 +if (isset($_POST['ship'])) { + verifyCsrf(); + $id = (int) ($_POST['id'] ?? 0); + $serverIp = trim($_POST['server_ip'] ?? ''); + $connAddr = trim($_POST['conn_addr'] ?? ''); + $loginUser = trim($_POST['login_user'] ?? ''); + $loginPass = trim($_POST['login_pass'] ?? ''); + $remark = trim($_POST['remark'] ?? ''); + $activated = isset($_POST['activated']) ? 1 : 0; + $productInfo = trim($_POST['product_info'] ?? ''); + + $chk = $db->prepare('SELECT id, status FROM ' . tn('orders') . ' WHERE id = ?'); + $chk->execute([$id]); + $ord = $chk->fetch(); + if (!$ord) { + $err = '订单不存在'; + } else { + // 已存在发货记录则更新,否则插入 + $ex = $db->prepare('SELECT id FROM ' . tn('order_deliveries') . ' WHERE order_id = ? ORDER BY id DESC LIMIT 1'); + $ex->execute([$id]); + $exId = $ex->fetchColumn(); + if ($exId) { + $db->prepare('UPDATE ' . tn('order_deliveries') . ' SET server_ip=?, conn_addr=?, login_user=?, login_pass=?, remark=?, activated=?, product_info=? WHERE id=?') + ->execute([$serverIp, $connAddr, $loginUser, $loginPass, $remark, $activated, $productInfo, $exId]); + } else { + $db->prepare('INSERT INTO ' . tn('order_deliveries') . ' (order_id, server_ip, conn_addr, login_user, login_pass, remark, activated, product_info, created_at) VALUES (?,?,?,?,?,?,?,?,NOW())') + ->execute([$id, $serverIp, $connAddr, $loginUser, $loginPass, $remark, $activated, $productInfo]); + } + // 由「已付款」发货后自动转为「已发货」 + if ($ord['status'] === 'paid') { + $db->prepare('UPDATE ' . tn('orders') . ' SET status = ? WHERE id = ?')->execute(['shipped', $id]); + } + $msg = '发货信息已保存(订单 #' . $id . ')。'; + // 通过 SMTP 通知客户(失败不影响保存) + $u = $db->prepare('SELECT u.email, u.username FROM ' . tn('orders') . ' o LEFT JOIN ' . tn('users') . ' u ON u.id = o.user_id WHERE o.id = ?'); + $u->execute([$id]); + $urow = $u->fetch(); + if ($urow && !empty($urow['email'])) { + $subject = '[' . SITE_NAME . '] 你的订单 #' . $id . ' 已发货'; + $body = '
' + . '

订单已发货

' + . '

你好 ' . h($urow['username']) . ',你购买的以下服务/商品已开通:

' + . '
' + . '

连接地址:' . h($connAddr) . '

' + . '

登录账户:' . h($loginUser) . '

' + . '

可在「我的订单 → 详情」中查看完整登录凭据。

' + . '
' + . '

如有问题请到「我的工单」联系客服。

'; + fnwSendMail($urow['email'], $subject, $body); + } + } +} + +// 修改到期时间(后台) +if (isset($_POST['save_expire'])) { + verifyCsrf(); + $id = (int) ($_POST['id'] ?? 0); + $clear = isset($_POST['clear_expire']); + $expRaw = trim($_POST['expires_at'] ?? ''); + $newExp = null; + if (!$clear) { + if ($expRaw === '') { + $err = '请填写新的到期时间,或勾选「清除到期时间」'; + } else { + $t = strtotime(str_replace('T', ' ', $expRaw)); + if ($t === false) { + $err = '到期时间格式不正确'; + } else { + $newExp = date('Y-m-d H:i:s', $t); + } + } + } + if ($err === '') { + $db->prepare('UPDATE ' . tn('orders') . ' SET expires_at = ?, expire_notify_sent = 0 WHERE id = ?') + ->execute([$newExp, $id]); + $msg = '订单 #' . $id . ' 的到期时间已更新' . ($newExp ? '为 ' . $newExp : '(已清除)') . ',到期提醒将重新计算。'; + } +} + +// 删除订单(含明细与发货信息) +if (isset($_POST['delete'])) { + verifyCsrf(); + $id = (int) ($_POST['delete'] ?? 0); + $db->prepare('DELETE FROM ' . tn('order_deliveries') . ' WHERE order_id = ?')->execute([$id]); + $db->prepare('DELETE FROM ' . tn('order_items') . ' WHERE order_id = ?')->execute([$id]); + $db->prepare('DELETE FROM ' . tn('orders') . ' WHERE id = ?')->execute([$id]); + $msg = '订单 #' . $id . ' 已删除'; +} + +$filter = trim($_GET['status'] ?? ''); +$where = $filter !== '' ? 'WHERE o.status = ?' : ''; +$params = $filter !== '' ? [$filter] : []; +$stmt = $db->prepare( + 'SELECT o.*, u.username, u.email AS u_email FROM ' . tn('orders') . ' o + LEFT JOIN ' . tn('users') . ' u ON u.id = o.user_id + ' . $where . ' ORDER BY o.created_at DESC' +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +// 预取商品明细 +$itemsOf = []; +foreach ($list as $o) { + $it = $db->prepare('SELECT name, qty, period_days FROM ' . tn('order_items') . ' WHERE order_id = ?'); + $it->execute([$o['id']]); + $itemsOf[$o['id']] = $it->fetchAll(); +} + +// 查看单条 + 发货 +$view = isset($_GET['view']) ? (int) $_GET['view'] : 0; +$order = $delivery = null; +if ($view > 0) { + $ostmt = $db->prepare('SELECT o.*, u.username, u.email AS u_email FROM ' . tn('orders') . ' o LEFT JOIN ' . tn('users') . ' u ON u.id = o.user_id WHERE o.id = ?'); + $ostmt->execute([$view]); + $order = $ostmt->fetch(); + if ($order) { + $istmt = $db->prepare('SELECT * FROM ' . tn('order_items') . ' WHERE order_id = ?'); + $istmt->execute([$order['id']]); + $order['items'] = $istmt->fetchAll(); + $dstmt = $db->prepare('SELECT * FROM ' . tn('order_deliveries') . ' WHERE order_id = ? ORDER BY id DESC LIMIT 1'); + $dstmt->execute([$order['id']]); + $delivery = $dstmt->fetch(); + } +} + +adminHeader('订单管理', 'orders'); +?> + +

订单管理

+ + +

+ +
+ 全部 + + + +
+ + +
+
+
+
订单号:
+
+
+ +
+ +
+
+
订单发起人
+
+
账号邮箱:
+
+
+
联系信息
+
联系人:
+
联系邮箱:
+
收货地址:
+
+
+
周期 / 到期
+
周期:
+
到期时间:
+
已发送「到期前提醒」邮件
+
+ + + + + +
+
+
+
备注
+
+
+
+
支付方式
+
+
+
+ +

商品明细

+ + + + + + + + + + + + + +
商品单价数量小计周期
+ +

发货信息(连接地址 / 登录凭据 / 商品信息)

+ +
+

已填写,当前内容如下(可重新提交覆盖):

+
激活状态:
+
服务器 IP:
+
连接地址:
+
登录账户:
+
登录密码:
+
商品信息:
+
备注:
+
+ + +
+ + + + + + + + + +
+ + 返回列表 +
+
+
+ +
+ +

没有符合条件的订单。

+ + + + + + + + + + + + + + + + + + +
订单号用户商品金额周期支付状态时间操作
+ +
×
+ +
+ +
+ + +
+
+ +
+ + + diff --git a/admin/products.php b/admin/products.php new file mode 100644 index 0000000..c1fa3fa --- /dev/null +++ b/admin/products.php @@ -0,0 +1,150 @@ +query('SELECT name FROM ' . tn('product_groups') . ' ORDER BY sort_order ASC, name ASC')->fetchAll(PDO::FETCH_COLUMN); +$catOptions = $groups; +if (!in_array('其他', $catOptions, true)) $catOptions[] = '其他'; + +// 删除 +if (isset($_POST['delete'])) { + verifyCsrf(); + $id = (int) $_POST['delete']; + $cnt = $db->prepare('SELECT COUNT(*) FROM ' . tn('order_items') . ' WHERE product_id = ?'); + $cnt->execute([$id]); + if ((int) $cnt->fetchColumn() > 0) { + $err = '该商品已有订单记录,无法删除,可将其下架。'; + } else { + $db->prepare('DELETE FROM ' . tn('products') . ' WHERE id = ?')->execute([$id]); + $msg = '商品已删除'; + } +} + +// 保存(新增 / 编辑) +if (isset($_POST['save'])) { + verifyCsrf(); + $id = (int) ($_POST['id'] ?? 0); + $name = trim($_POST['name'] ?? ''); + $stock = max(0, (int) ($_POST['stock'] ?? 0)); + $periodDays = max(0, (int) ($_POST['period_days'] ?? 0)); + $pointsPrice= max(0, (int) ($_POST['points_price'] ?? 0)); + $category = trim($_POST['category'] ?? '其他'); + $icon = trim($_POST['icon'] ?? 'fa-box'); + $image = trim($_POST['image'] ?? ''); + $description= trim($_POST['description'] ?? ''); + $status = isset($_POST['status']) ? 1 : 0; + + if ($name === '') $err = '请填写商品名称'; + elseif (!in_array($category, $catOptions, true)) $err = '分类不合法'; + + if ($err === '') { + if ($id > 0) { + $db->prepare('UPDATE ' . tn('products') . ' SET name=?,stock=?,period_days=?,points_price=?,category=?,icon=?,image=?,description=?,status=? WHERE id=?') + ->execute([$name, $stock, $periodDays, $pointsPrice, $category, $icon, $image, $description, $status, $id]); + $msg = '商品已更新'; + } else { + $db->prepare('INSERT INTO ' . tn('products') . ' (name,stock,period_days,points_price,category,icon,image,description,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,NOW())') + ->execute([$name, $stock, $periodDays, $pointsPrice, $category, $icon, $image, $description, $status]); + $msg = '商品已添加'; + } + } +} + +// 编辑回填 +$edit = null; +if (isset($_GET['edit'])) { + $stmt = $db->prepare('SELECT * FROM ' . tn('products') . ' WHERE id = ?'); + $stmt->execute([(int) $_GET['edit']]); + $edit = $stmt->fetch(); +} +$showForm = ($edit !== null) || isset($_GET['act']) && $_GET['act'] === 'add'; + +// 列表 +$list = $db->query('SELECT * FROM ' . tn('products') . ' ORDER BY created_at DESC')->fetchAll(); + +adminHeader('商品管理', 'products'); +?> + +

商品管理

+ + +

+ + +
+

+
+ + + + + + + + + + + +
+ + 取消 +
+
+
+ + +
+
+

商品列表(共 件)

+ 新增商品 +
+ +

还没有商品,点上方「新增商品」添加吧。

+ + + + + + + + + + + + + + + + + +
ID名称分类积分价库存周期状态操作
0 ? (int)$p['points_price'] . ' 分' : '直接下单' ?>上架' : '下架' ?> + +
+ + +
+
+ +
+ + diff --git a/admin/settings.php b/admin/settings.php new file mode 100644 index 0000000..c1164f6 --- /dev/null +++ b/admin/settings.php @@ -0,0 +1,299 @@ +prepare('SELECT password FROM ' . tn('users') . ' WHERE id = ?'); + $stmt->execute([$admin['id']]); + $hash = $stmt->fetchColumn(); + if (!password_verify($cur, $hash)) { + $err = '当前密码错误。'; + } elseif (strlen($new) < 6) { + $err = '新密码至少 6 位。'; + } elseif ($new !== $new2) { + $err = '两次输入的新密码不一致。'; + } else { + $newHash = password_hash($new, PASSWORD_DEFAULT); + db()->prepare('UPDATE ' . tn('users') . ' SET password = ? WHERE id = ?') + ->execute([$newHash, $admin['id']]); + $msg = '管理员密码已修改。'; + } +} +if (isset($_POST['savesmtp'])) { + verifyCsrf(); + foreach (['smtp_host', 'smtp_port', 'smtp_enc', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_fromname', 'notify_emails'] as $k) { + saveSetting($k, trim((string) ($_POST[$k] ?? ''))); + } + $msg = 'SMTP 配置已保存!'; +} +if (isset($_POST['saveabout'])) { + verifyCsrf(); + saveSetting('mall_about', trim((string) ($_POST['mall_about'] ?? ''))); + $msg = '商城介绍已保存!'; +} +if (isset($_POST['savepoints'])) { + verifyCsrf(); + saveSetting('points_enabled', isset($_POST['points_enabled']) ? '1' : '0'); + saveSetting('points_sign', max(0, (int) ($_POST['points_sign'] ?? 0))); + saveSetting('points_invite', max(0, (int) ($_POST['points_invite'] ?? 0))); + $msg = '积分设置已保存'; +} +if (isset($_POST['savepay_ali'])) { + verifyCsrf(); + saveSetting('pay_alipay_enabled', isset($_POST['pay_alipay_enabled']) ? '1' : '0'); + saveSetting('pay_alipay_appid', trim((string) ($_POST['pay_alipay_appid'] ?? ''))); + saveSetting('pay_alipay_private_key', trim((string) ($_POST['pay_alipay_private_key'] ?? ''))); + saveSetting('pay_alipay_public_key', trim((string) ($_POST['pay_alipay_public_key'] ?? ''))); + saveSetting('pay_alipay_sandbox', isset($_POST['pay_alipay_sandbox']) ? '1' : '0'); + $msg = '支付宝配置已保存!'; +} +if (isset($_POST['savepay_wx'])) { + verifyCsrf(); + saveSetting('pay_wechat_enabled', isset($_POST['pay_wechat_enabled']) ? '1' : '0'); + saveSetting('pay_wechat_mch_id', trim((string) ($_POST['pay_wechat_mch_id'] ?? ''))); + saveSetting('pay_wechat_api_key', trim((string) ($_POST['pay_wechat_api_key'] ?? ''))); + saveSetting('pay_wechat_appid', trim((string) ($_POST['pay_wechat_appid'] ?? ''))); + $msg = '微信支付配置已保存!'; +} +if (isset($_POST['savefavicon']) || isset($_POST['delfavicon'])) { + verifyCsrf(); + if (!$isAdmin) { + $err = '客服账号仅可查看,如需操作请联系管理员。'; + } elseif (isset($_POST['delfavicon'])) { + $old = getSetting('favicon', ''); + if ($old && file_exists(__DIR__ . '/../' . $old)) { @unlink(__DIR__ . '/../' . $old); } + saveSetting('favicon', ''); + $msg = '已恢复默认图标。'; + } else { + if (empty($_FILES['favicon_file']) || $_FILES['favicon_file']['error'] !== UPLOAD_ERR_OK) { + $err = '请选择要上传的 .ico 文件。'; + } else { + $f = $_FILES['favicon_file']; + $ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION)); + if ($ext !== 'ico') { + $err = '仅支持 .ico 格式的图标文件。'; + } elseif ($f['size'] > 256 * 1024) { + $err = '文件过大,请控制在 256KB 以内。'; + } else { + // 校验 ICO 文件头:00 00 01 00 + $head = file_get_contents($f['tmp_name'], false, null, 0, 4); + if ($head !== "\x00\x00\x01\x00") { + $err = '文件内容不是有效的 ICO 图标(文件头校验失败)。'; + } else { + $dest = __DIR__ . '/../assets/favicon.ico'; + if (!move_uploaded_file($f['tmp_name'], $dest) && !copy($f['tmp_name'], $dest)) { + $err = '保存失败,请检查 assets/ 目录写入权限。'; + } else { + saveSetting('favicon', 'assets/favicon.ico'); + $msg = '网站图标已更新,刷新浏览器即可看到(若未变请强制刷新清缓存)。'; + } + } + } + } + } +} +$testResult = null; +if (isset($_POST['testmail'])) { + verifyCsrf(); + $to = trim($_POST['test_to'] ?? ''); + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + $err = '请填写有效的测试收件邮箱。'; + } else { + $cfg = smtpConfig(); + $body = '
' + . '

[' . h(SITE_NAME) . '] SMTP 测试邮件

' + . '

这是一封由商城后台发送的测试邮件,若你收到了它,说明 SMTP 配置正确。

' + . '

发送时间:' . date('Y-m-d H:i:s') . '

'; + $testResult = fnwSendMail($to, '[' . SITE_NAME . '] SMTP 测试邮件', $body); + if ($testResult['ok']) { + $msg = '测试邮件已发送,请查收(含 SMTP 会话日志见下方)。'; + } else { + $err = '发送失败:' . ($testResult['error'] ?? '未知错误'); + } + } +} +$cfg = smtpConfig(); +adminHeader('设置', 'settings'); +?> +

设置

+ +

+
+

修改管理员密码

+

当前管理员:

+
+ + + + + +
+
+ +
+

商城介绍(前台首页展示)

+

这段文字会显示在商城首页「关于商城」区块,向访客介绍你的商城。留空则不显示该区块。

+
+ + +
+ +
+
+
+
+

站点名称

+

该名称会显示在浏览器标题、页面顶部、邮件通知等位置。

+
+ + + +
+
+
+

网站图标(Favicon)

+

上传 .ico 格式图标(建议 16×16 或 32×32,最大 256KB),将显示在浏览器标签页。留空则使用默认图标。

+

+ + 当前图标 + 当前图标( + + 当前:默认图标 + +

+
+ + +
+ +
+
+ +
+ + +
+ +
+
+

积分系统设置

+

开启后,用户可在前台「每日签到」获取积分、通过邀请链接邀请好友获得积分;商品可设置「积分价」,结算时选择「积分支付」。

+
+ + + + +
+ +
+
+
+ +
+

SMTP 邮件配置

+

用于:用户提交工单后通知处理人员、处理回复通知用户。常见配置:QQ 邮箱 smtp.qq.com / 端口 465 / 加密 SSL / 密码填「授权码」。

+
+ + + + + + + + + +
+ +
+
+ +
+

发送测试邮件

+
+ + +
+ +
+
+ + +
+
SMTP 会话日志:
+
+
+ +
+ +
+

支付接口配置

+

配置后用户下单可选择对应支付方式。未启用(或未填密钥)的渠道不会在前台显示。沙箱模式仅支付宝支持,仅用于联调,正式上线请关闭。

+ +

支付宝(电脑网站支付 · RSA2)

+
+ + + + + + +
+ +
+
+ +
+ +

微信支付(Native 扫码支付 · V3)

+
+ + + + + +
+ +
+
+
+ + diff --git a/admin/tickets.php b/admin/tickets.php new file mode 100644 index 0000000..804e8c2 --- /dev/null +++ b/admin/tickets.php @@ -0,0 +1,370 @@ +query('SELECT * FROM ' . tn('ticket_departments') . ' ORDER BY sort, id')->fetchAll(); +$deptMap = []; +foreach ($depts as $d) { $deptMap[$d['id']] = $d['name']; } + +$validStatus = ['open', 'pending', 'resolved', 'closed']; + +// 重新开启已关闭工单(快捷按钮) +if (isset($_POST['reopen'])) { + verifyCsrf(); + $id = (int) ($_POST['id'] ?? 0); + $stmt = $db->prepare('SELECT id FROM ' . tn('tickets') . ' WHERE id = ?'); + $stmt->execute([$id]); + if ($stmt->fetch()) { + $db->prepare('UPDATE ' . tn('tickets') . ' SET status=?, updated_at=NOW(), closed_at=NULL WHERE id=?') + ->execute(['open', $id]); + $msg = '工单已重新开启。'; + } else { + $err = '工单不存在'; + } +} + +// 分配负责人(仅管理员可操作;客服仅能看自己负责的工单) +if (isset($_POST['assign']) && $isAdmin) { + verifyCsrf(); + $id = (int) ($_POST['id'] ?? 0); + $toUid = (int) ($_POST['assignee_id'] ?? 0); + if ($id <= 0) { + $err = '工单不存在'; + } else { + $staffCheck = $db->prepare('SELECT id FROM ' . tn('users') . ' WHERE id = ? AND role IN (\'admin\',\'cs\') AND status = 1'); + $staffCheck->execute([$toUid]); + $realUid = $toUid > 0 && $staffCheck->fetch() ? $toUid : null; + $db->prepare('UPDATE ' . tn('tickets') . ' SET assignee_id = ? WHERE id = ?') + ->execute([$realUid, $id]); + $msg = $realUid ? '已分配负责人。' : '已取消分配(工单转为未分配,仅管理员可见)。'; + } +} + +// 处理提交(追加回复 / 状态变更) +if (isset($_POST['reply'])) { + verifyCsrf(); + $id = (int) ($_POST['id'] ?? 0); + $status = trim($_POST['status'] ?? 'open'); + $reply = trim($_POST['admin_reply'] ?? ''); + $notify = !empty($_POST['notify']); + + // 客服仅能处理分配给自己的工单 + if (!$isAdmin) { + $chk = $db->prepare('SELECT assignee_id FROM ' . tn('tickets') . ' WHERE id = ?'); + $chk->execute([$id]); + $ar = $chk->fetch(); + if (!$ar || (int) $ar['assignee_id'] !== (int) $me['id']) { + $err = '该工单未分配给你,无权操作。'; + $id = 0; + } + } + + if (!in_array($status, $validStatus, true)) { + $err = '状态不合法'; + } else { + $stmt = $db->prepare('SELECT t.*, u.email AS user_email, u.username AS user_name FROM ' . tn('tickets') . ' t LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id WHERE t.id = ?'); + $stmt->execute([$id]); + $t = $stmt->fetch(); + if (!$t) { + $err = '工单不存在'; + } else { + $adminId = $me['id']; + $closedAt = $status === 'closed' ? date('Y-m-d H:i:s') : null; + + if ($reply !== '') { + $db->prepare('INSERT INTO ' . tn('ticket_replies') . ' (ticket_id, user_id, is_admin, message, created_at) VALUES (?,?,?,?,NOW())') + ->execute([$id, $adminId, 1, $reply]); + } + + $db->prepare('UPDATE ' . tn('tickets') . ' SET status=?, admin_id=?, updated_at=NOW(), closed_at=? WHERE id=?') + ->execute([$status, $adminId, $closedAt, $id]); + + if ($notify && $t['user_email'] !== '') { + $base = siteBaseUrl(); + $url = rtrim($base, '/') . '/mytickets.php?view=' . $id; + $subject = '[' . SITE_NAME . '] 您的工单 #' . $id . ' 有新回复'; + $body = '
' + . '

工单处理通知

' + . '

您好 ' . h($t['user_name']) . ',您提交的工单已更新:

' + . '

工单标题:' . h($t['subject']) . '

' + . '

当前状态:' . ticketStatusLabel($status) . '

' + . ($reply !== '' ? '
' . nl2br(h($reply)) . '
' : '') + . '

查看工单详情

' + . '
'; + $r = fnwSendMail($t['user_email'], $subject, $body); + if (!$r['ok']) { + $msg = '工单已保存,但邮件通知发送失败:' . $r['error']; + } else { + $msg = '工单已保存,并已邮件通知用户。'; + } + } else { + $msg = '工单已保存。'; + } + } + } +} + +// 批准续期(续期工单专用) +if (isset($_POST['approve_renew'])) { + verifyCsrf(); + if (!$isAdmin) { + $err = '仅管理员可批准续期。'; + } else { + $id = (int) ($_POST['id'] ?? 0); + $reply = trim($_POST['admin_reply'] ?? ''); + $stmt = $db->prepare('SELECT t.*, o.expires_at, o.period_days, u.email AS user_email, u.username AS user_name FROM ' . tn('tickets') . ' t LEFT JOIN ' . tn('orders') . ' o ON o.id = t.order_id LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id WHERE t.id = ?'); + $stmt->execute([$id]); + $t = $stmt->fetch(); + if (!$t || $t['type'] !== 'renewal' || !$t['order_id']) { + $err = '该工单不是有效的续期申请'; + } elseif (empty($t['period_days']) || $t['period_days'] <= 0) { + $err = '关联订单无有效周期,无法续期'; + } else { + // 从当前到期(未过期则顺延)或现在起,延长一个周期 + $base = ($t['expires_at'] && !isExpired($t['expires_at'])) ? $t['expires_at'] : date('Y-m-d H:i:s'); + $newExpiry = date('Y-m-d H:i:s', strtotime($base . ' +' . (int)$t['period_days'] . ' days')); + $db->prepare('UPDATE ' . tn('orders') . ' SET expires_at = ? WHERE id = ?')->execute([$newExpiry, $t['order_id']]); + + $reply = $reply !== '' ? $reply : ('已为你续期,新到期时间:' . $newExpiry); + $adminId = $me['id']; + $db->prepare('INSERT INTO ' . tn('ticket_replies') . ' (ticket_id, user_id, is_admin, message, created_at) VALUES (?,?,?,?,NOW())') + ->execute([$id, $adminId, 1, $reply]); + $db->prepare('UPDATE ' . tn('tickets') . ' SET admin_reply=?, status=?, admin_id=?, updated_at=NOW(), closed_at=NULL WHERE id=?') + ->execute([$reply, 'resolved', $adminId, $id]); + + $msg = '已批准续期,关联订单 #' . $t['order_id'] . ' 到期时间已延长至 ' . $newExpiry; + if (!empty($t['user_email'])) { + $baseUrl = siteBaseUrl(); + $url = rtrim($baseUrl, '/') . '/mytickets.php?view=' . $id; + $subject = '[' . SITE_NAME . '] 你的续期申请已通过(订单 #' . $t['order_id'] . ')'; + $body = '
' + . '

续期成功

' + . '

你好 ' . h($t['user_name']) . ',你的续期申请已通过:

' + . '

关联订单:#' . (int)$t['order_id'] . '

' + . '

新到期时间:' . h($newExpiry) . '

' + . '
' . nl2br(h($reply)) . '
' + . '

查看工单

' + . '

可在「我的订单 → 详情」中查看完整凭据。

'; + fnwSendMail($t['user_email'], $subject, $body); + } + } + } +} + +// 列表筛选 +$filterStatus = trim($_GET['status'] ?? ''); +$filterDept = isset($_GET['dept']) ? (int) $_GET['dept'] : 0; +$where = []; +$params = []; +if ($filterStatus !== '') { $where[] = 't.status = ?'; $params[] = $filterStatus; } +if ($filterDept > 0) { $where[] = 't.dept_id = ?'; $params[] = $filterDept; } +// 客服仅能看到分配给自己的工单 +if (!$isAdmin) { + $where[] = 't.assignee_id = ?'; + $params[] = $me['id']; +} +$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + +$totalStmt = $db->prepare('SELECT COUNT(*) FROM ' . tn('tickets') . ' t ' . $whereSql); +$totalStmt->execute($params); +$total = $totalStmt->fetchColumn(); + +$stmt = $db->prepare( + 'SELECT t.*, u.username AS user_name, u.email AS user_email, d.name AS dept_name, + a.username AS assignee_name + FROM ' . tn('tickets') . ' t + LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id + LEFT JOIN ' . tn('ticket_departments') . ' d ON d.id = t.dept_id + LEFT JOIN ' . tn('users') . ' a ON a.id = t.assignee_id + ' . $whereSql . ' + ORDER BY t.updated_at DESC' +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +// 查看单条 +$view = isset($_GET['view']) ? (int) $_GET['view'] : 0; +$ticket = null; +$replies = []; +if ($view > 0) { + $stmt = $db->prepare( + 'SELECT t.*, u.username AS user_name, u.email AS user_email, d.name AS dept_name, + a.username AS assignee_name + FROM ' . tn('tickets') . ' t + LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id + LEFT JOIN ' . tn('ticket_departments') . ' d ON d.id = t.dept_id + LEFT JOIN ' . tn('users') . ' a ON a.id = t.assignee_id + WHERE t.id = ?' + ); + $stmt->execute([$view]); + $ticket = $stmt->fetch(); + + // 客服仅能查看分配给自己的工单 + if ($ticket && !$isAdmin && (int) $ticket['assignee_id'] !== (int) $me['id']) { + $ticket = null; + $err = '该工单未分配给你,无权查看。'; + } + + if ($ticket) { + $rstmt = $db->prepare( + 'SELECT r.*, u.username FROM ' . tn('ticket_replies') . ' r + LEFT JOIN ' . tn('users') . ' u ON u.id = r.user_id + WHERE r.ticket_id = ? ORDER BY r.created_at ASC' + ); + $rstmt->execute([$view]); + $replies = $rstmt->fetchAll(); + } +} + +// 负责人候选(管理员/客服),仅管理员可在表单中分配 +$assignees = []; +if ($isAdmin) { + $assignees = $db->query("SELECT id, username, role FROM " . tn('users') . " WHERE role IN ('admin','cs') AND status = 1 ORDER BY role DESC, username")->fetchAll(); +} + +adminHeader('工单管理', 'tickets'); +?> +

工单管理(共

+ + +

+ + +
+
+ + 类型: + 提交人: + 部门: + 优先级: + 负责人:未分配' ?> + 提交: +
+ + prepare('SELECT order_no, period_days, expires_at, status FROM ' . tn('orders') . ' WHERE id = ?'); $ord->execute([$ticket['order_id']]); $ord = $ord->fetch(); ?> +
+ 续期申请 · 关联订单 + #) + + · 周期 · 当前到期 + +
+ + +
+
+
·
+

+
+
+ + + +
+
处理人员() ·
+
+
+ +
+
·
+
+
+ + +
+ +
+ + + +

提示:历史回复以上方时间线为准,此处内容仅用于新增回复。

+ + + + + +
+ + + + + + + + + + + 返回列表 +
+
+
+ +
+
+ + + 重置 +
+ +

暂无工单。

+ + + + + + + + + + + + + + + + + + + +
编号标题提交人部门类型优先级状态负责人更新
#未分配' ?>处理
+ +
+ + diff --git a/admin/users.php b/admin/users.php new file mode 100644 index 0000000..d3e3040 --- /dev/null +++ b/admin/users.php @@ -0,0 +1,199 @@ +prepare('SELECT id, is_admin, status FROM ' . tn('users') . ' WHERE id = ?'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if (!$row) { + $err = '用户不存在'; + } elseif ((int) $row['is_admin'] === 1) { + $err = '不能对管理员执行该操作'; + } else { + $next = ((int) $row['status'] === 1) ? 0 : 1; + $db->prepare('UPDATE ' . tn('users') . ' SET status = ? WHERE id = ?')->execute([$next, $id]); + $msg = $next === 0 ? '该用户已被封禁,无法再登录。' : '已解除封禁。'; + } +} + +// 删除 +if (isset($_POST['delete'])) { + verifyCsrf(); + $id = (int) ($_POST['delete'] ?? 0); + $stmt = $db->prepare('SELECT id, is_admin FROM ' . tn('users') . ' WHERE id = ?'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if (!$row) { + $err = '用户不存在'; + } elseif ((int) $row['is_admin'] === 1) { + $err = '不能删除管理员账户'; + } else { + $db->prepare('DELETE FROM ' . tn('users') . ' WHERE id = ? AND is_admin = 0')->execute([$id]); + $msg = '用户已删除'; + } +} + +// 添加 +if (isset($_POST['add'])) { + verifyCsrf(); + $u = trim($_POST['username'] ?? ''); + $em = trim($_POST['email'] ?? ''); + $pw = $_POST['password'] ?? ''; + if (strlen($u) < 3) $err = '用户名至少 3 个字符'; + elseif (!filter_var($em, FILTER_VALIDATE_EMAIL)) $err = '请填写有效的邮箱'; + elseif (strlen($pw) < 6) $err = '密码至少 6 位'; + else { + $chk = $db->prepare('SELECT id FROM ' . tn('users') . ' WHERE username = ?'); + $chk->execute([$u]); + if ($chk->fetch()) { + $err = '该用户名已存在'; + } else { + $hash = password_hash($pw, PASSWORD_DEFAULT); + $code = genInviteCode(); + $db->prepare('INSERT INTO ' . tn('users') . ' (username,password,email,invite_code,created_at) VALUES (?,?,?,?,NOW())') + ->execute([$u, $hash, $em, $code]); + $msg = '用户已添加'; + } + } +} + +// 手动调整积分 +if (isset($_POST['adjust'])) { + verifyCsrf(); + $target = trim($_POST['target'] ?? ''); + $amount = (int) ($_POST['amount'] ?? 0); + $remark = trim($_POST['remark'] ?? ''); + if ($target === '') $err = '请填写用户 ID 或用户名'; + elseif ($amount === 0) $err = '积分变动不能为 0'; + elseif ($remark === '') $err = '请填写调整备注(便于留痕)'; + else { + if (is_numeric($target)) { + $stmt = $db->prepare('SELECT id, is_admin FROM ' . tn('users') . ' WHERE id = ?'); + $stmt->execute([(int) $target]); + } else { + $stmt = $db->prepare('SELECT id, is_admin FROM ' . tn('users') . ' WHERE username = ?'); + $stmt->execute([$target]); + } + $row = $stmt->fetch(); + if (!$row) { + $err = '未找到该用户'; + } else { + $res = addPoints((int) $row['id'], 'manual', $amount, '后台手动调整:' . $remark); + if ($res['ok']) { + $msg = '已为用户 #' . (int) $row['id'] . ' ' . ($amount > 0 ? '增加' : '扣减') . ' ' . abs($amount) . ' 积分,当前余额 ' . $res['balance'] . ' 分。'; + } else { + $err = '调整失败:' . $res['error']; + } + } + } +} + +// 切换邮箱验证状态 +if (isset($_POST['toggle_verify'])) { + verifyCsrf(); + $id = (int) ($_POST['toggle_verify'] ?? 0); + $stmt = $db->prepare('SELECT id, verified FROM ' . tn('users') . ' WHERE id = ?'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if (!$row) { + $err = '用户不存在'; + } else { + $next = (int) $row['verified'] === 1 ? 0 : 1; + $db->prepare('UPDATE ' . tn('users') . ' SET verified = ? WHERE id = ?')->execute([$next, $id]); + $msg = $next === 1 ? '已标记该用户邮箱为已验证。' : '已将该用户邮箱设为未验证。'; + } +} + +$list = $db->query('SELECT id, username, email, status, points, verified, created_at FROM ' . tn('users') . ' WHERE is_admin = 0 ORDER BY created_at DESC')->fetchAll(); +$orderCnt = $db->query('SELECT user_id, COUNT(*) c FROM ' . tn('orders') . ' GROUP BY user_id')->fetchAll(PDO::FETCH_KEY_PAIR); + +adminHeader('用户管理', 'users'); +?> +

用户管理(共 人)

+ + +

+ +
+

添加用户

+
+ + + + +
+
+
+ +
+

手动调整积分

+
+ + + + +
+ +
+
+
+ +
+

用户列表

+ +

还没有普通用户。

+ + + + + + + + + + + + + + + + + + +
ID用户名邮箱积分订单数验证状态注册时间操作
+ + 已验证 + + 未验证 + + 正常' : '已封禁' ?> +
+ + +
+
+ + +
+
+ + +
+
+ +
+ diff --git a/announcement.php b/announcement.php new file mode 100644 index 0000000..f24749c --- /dev/null +++ b/announcement.php @@ -0,0 +1,64 @@ +prepare('SELECT * FROM ' . tn('announcements') . ' WHERE id = ? AND status = 1'); +$stmt->execute([$id]); +$a = $stmt->fetch(); + +if (!$a) { + http_response_code(404); + $pageTitle = '公告不存在'; + require 'includes/header.php'; +?> +
+
+

公告不存在

+

该公告不存在或已被隐藏。

+ 返回公告中心 +
+
+ +
+ +
+ + + + diff --git a/announcements.php b/announcements.php new file mode 100644 index 0000000..5ef3713 --- /dev/null +++ b/announcements.php @@ -0,0 +1,38 @@ +query( + 'SELECT * FROM ' . tn('announcements') . ' WHERE status = 1 ORDER BY pinned DESC, created_at DESC' +)->fetchAll(); + +require 'includes/header.php'; +?> +
+

公告中心

+ + +

暂无公告。

+ +
+ 90) $excerpt .= '…'; + ?> + + +
+ +
+ diff --git a/api.php b/api.php new file mode 100644 index 0000000..c5b1bec --- /dev/null +++ b/api.php @@ -0,0 +1,316 @@ + true, 'msg' => $msg, 'data' => $data], JSON_UNESCAPED_UNICODE); + exit; +} + +function json_err($code = 400, $msg = '操作失败') { + http_response_code($code); + echo json_encode(['ok' => false, 'msg' => $msg], JSON_UNESCAPED_UNICODE); + exit; +} + +// ─── CSRF 校验(写操作必须带 token)───────────────────────────── +function requireCsrf() { + $token = trim($_SERVER['HTTP_X_CSRF_TOKEN'] ?? $_POST['csrf_token'] ?? ''); + if (!$token || !verifyCsrfToken($token)) { + json_err(403, 'CSRF 验证失败,请刷新页面重试'); + } +} + +$action = trim($_GET['action'] ?? $_POST['action'] ?? ''); + +// ─── 路由表 ─── +$ROUTES = [ + // 购物车(需登录) + 'cart_get' => true, // GET - 获取购物车 + 'cart_add' => true, // POST - 加入购物车 + 'cart_update' => true, // POST - 修改数量 + 'cart_remove' => true, // POST - 删除一项 + 'cart_clear' => true, // POST - 清空购物车 + + // 用户操作(需登录) + 'sign_in' => true, // POST - 每日签到 + 'ticket_submit'=> true, // POST - 提交工单 + + // 公开读取 + 'search' => false, // GET - 搜索商品 + 'products' => false, // GET - 分类筛选商品 + 'product_info' => false, // GET - 商品详情 + 'announcements'=> false, // GET - 公告列表 +]; + +if (!isset($ROUTES[$action])) { + json_err(404, '未知操作: ' . h($action)); +} + +$needAuth = $ROUTES[$action]; +if ($needAuth && empty($_SESSION['user_id'])) { + json_err(401, '请先登录'); +} + +$user = null; +if ($needAuth) { + $user = currentUser(); + if (!$user) { json_err(401, '登录已过期'); } +} + +// ─── 写操作统一 CSRF ─── +$WRITE_ACTIONS = ['cart_add', 'cart_update', 'cart_remove', 'cart_clear', 'sign_in', 'ticket_submit']; +if (in_array($action, $WRITE_ACTIONS)) { + requireCsrf(); +} + +// ─── 分发处理 ─── +switch ($action) { + +/* ================================================================ + * 购物车 + * ================================================================ */ + +case 'cart_get': + $items = []; + $total = 0; + if (!empty($_SESSION['cart']) && is_array($_SESSION['cart'])) { + $ids = array_keys($_SESSION['cart']); + if (!empty($ids)) { + $inPlaceholders = str_repeat('?', count($ids)); + $stmt = db()->prepare( + 'SELECT id,name,image,icon,category,points_price,stock,status FROM ' . tn('products') . + ' WHERE id IN (' . $inPlaceholders . ') AND status=1' + ); + $stmt->execute($ids); + while ($p = $stmt->fetch()) { + $qty = (int)($_SESSION['cart'][$p['id']] ?? 0); + if ($qty <= 0) continue; + $price = !empty($p['points_price']) ? (int)$p['points_price'] : 0; + $items[] = [ + 'id' => (int)$p['id'], + 'name' => $p['name'], + 'image' => $p['image'] ?: '', + 'icon' => $p['icon'] ?: '', + 'category' => $p['category'], + 'qty' => $qty, + 'points_price' => $price, + 'stock' => (int)$p['stock'], + 'subtotal' => $price * $qty, + ]; + $total += $price * $qty; + } + } + } + json_ok(['items' => $items, 'count' => count($items), 'total_points' => $total]); + +case 'cart_add': + $pid = (int)($_POST['product_id'] ?? 0); + $qty = max(1, min(99, (int)($_POST['qty'] ?? 1))); + if ($pid <= 0) { json_err(400, '商品 ID 无效'); } + $stmt = db()->prepare('SELECT id,status,stock FROM ' . tn('products') . ' WHERE id=?'); + $stmt->execute([$pid]); + $p = $stmt->fetch(); + if (!$p || (int)$p['status'] !== 1) { json_err(400, '商品不存在或已下架'); } + if (!isset($_SESSION['cart'])) { $_SESSION['cart'] = []; } + $current = (int)($_SESSION['cart'][$pid] ?? 0); + $newQty = $current + $qty; + if ((int)$p['stock'] >= 0 && $newQty > (int)$p['stock']) { + json_err(400, '库存不足(剩余 ' . (int)$p['stock'] . ' 件)'); + } + $_SESSION['cart'][$pid] = $newQty; + $count = array_sum(array_map('intval', $_SESSION['cart'])); + json_ok(['cart_count' => $count, 'item_qty' => $newQty], '已加入购物车'); + +case 'cart_update': + $pid = (int)($_POST['product_id'] ?? 0); + $qty = max(1, min(99, (int)($_POST['qty'] ?? 1))); + if ($pid <= 0) { json_err(400, '商品 ID 无效'); } + if (!isset($_SESSION['cart'][$pid])) { json_err(400, '购物车中没有该商品'); } + $stmt = db()->prepare('SELECT stock FROM ' . tn('products') . ' WHERE id=? AND status=1'); + $stmt->execute([$pid]); + $p = $stmt->fetch(); + if (!$p) { json_err(400, '商品不存在或已下架'); } + if ((int)$p['stock'] >= 0 && $qty > (int)$p['stock']) { + json_err(400, '库存不足'); + } + $_SESSION['cart'][$pid] = $qty; + $count = array_sum(array_map('intval', $_SESSION['cart'])); + json_ok(['cart_count' => $count], '数量已更新'); + +case 'cart_remove': + $pid = (int)($_POST['product_id'] ?? 0); + if ($pid <= 0) { json_err(400, '商品 ID 无效'); } + unset($_SESSION['cart'][$pid]); + $count = isset($_SESSION['cart']) ? array_sum(array_map('intval', $_SESSION['cart'])) : 0; + json_ok(['cart_count' => $count], '已移除'); + +case 'cart_clear': + $_SESSION['cart'] = []; + json_ok([], '购物车已清空'); + +/* ================================================================ + * 每日签到 + * ================================================================ */ + +case 'sign_in': + $enabled = getSetting('points_enabled', '1') === '1'; + if (!$enabled) { json_err(400, '积分系统未启用'); } + $today = date('Y-m-d'); + $stmt = db()->prepare('SELECT id FROM ' . tn('sign_logs') . ' WHERE user_id=? AND sign_date=?'); + $stmt->execute([$user['id'], $today]); + if ($stmt->fetch()) { json_err(400, '今天已经签到了'); } + $points = max(0, (int)getSetting('points_sign', 5)); + db()->beginTransaction(); + try { + db()->prepare('INSERT INTO ' . tn('sign_logs') . ' (user_id,sign_date,points) VALUES (?,?,?)') + ->execute([$user['id'], $today, $points]); + db()->prepare('UPDATE ' . tn('users') . ' SET points=points+? WHERE id=?') + ->execute([$points, $user['id']]); + db()->commit(); + } catch (Exception $e) { + db()->rollBack(); + json_err(500, '签到失败: 数据库错误'); + } + $newTotal = getUserPoints($user['id']); + json_ok(['points_gained' => $points, 'points_total' => $newTotal], "签到成功!+{$points} 积分"); + +/* ================================================================ + * 提交工单 + * ================================================================ */ + +case 'ticket_submit': + $deptId = (int)($_POST['dept_id'] ?? 0); + $title = trim($_POST['title'] ?? ''); + $body = trim($_POST['body'] ?? ''); + $priority = in_array($_POST['priority'] ?? '', ['low','normal','high']) ? $_POST['priority'] : 'normal'; + if ($title === '' || strlen($title) < 3) { json_err(400, '标题至少 3 个字'); } + if ($body === '' || strlen($body) < 5) { json_err(400, '描述至少 5 个字'); } + + // 验证部门是否存在 + if ($deptId > 0) { + $ds = db()->prepare('SELECT id FROM ' . tn('departments') . ' WHERE id=?'); + $ds->execute([$deptId]); + if (!$ds->fetch()) { $deptId = 0; } + } + + $orderId = !empty($_POST['order_id']) ? (int)$_POST['order_id'] : null; + + db()->prepare( + 'INSERT INTO ' . tn('tickets') . ' (user_id,dept_id,title,body,priority,order_id,status,created_at) VALUES (?,?,?,?,?,?,\'open\',NOW())' + )->execute([$user['id'], $deptId, $title, $body, $priority, $orderId]); + + $ticketId = (int)db()->lastInsertId(); + + // 通知处理人员 + notifyAdmins('新工单 #' . $ticketId . ': ' . $title, "用户 {$user['username']} 提交了新工单,请及时处理。"); + + json_ok(['ticket_id' => $ticketId], '工单提交成功!我们会尽快处理。'); + +/* ================================================================ + * 商品搜索 / 分类筛选(公开) + * ================================================================ */ + +case 'search': + $q = trim($_GET['q'] ?? ''); + if ($q === '') { json_err(400, '请输入搜索关键词'); } + $where = ['status = 1', 'name LIKE ?']; + $params = ['%' . $q . '%']; + $sql = 'SELECT id,name,category,image,icon,points_price,stock,description FROM ' . tn('products') . + ' WHERE ' . implode(' AND ', $where) . ' ORDER BY created_at DESC LIMIT 50'; + $stmt = db()->prepare($sql); + $stmt->execute($params); + $products = []; + while ($r = $stmt->fetch()) { + $products[] = [ + 'id' => (int)$r['id'], + 'name' => $r['name'], + 'category' => $r['category'], + 'image' => $r['image'] ?: '', + 'icon' => $r['icon'] ?: '', + 'points_price' => !empty($r['points_price']) ? (int)$r['points_price'] : null, + 'stock' => (int)$r['stock'], + 'description' => mb_substr($r['description'] ?? '', 0, 100), + ]; + } + json_ok(['products' => $products, 'query' => $q, 'count' => count($products)]); + +case 'products': + $cat = trim($_GET['cat'] ?? ''); + $where = ['status = 1']; + $params = []; + if ($cat !== '') { + $where[] = 'category = ?'; + $params[] = $cat; + } + $sql = 'SELECT id,name,category,image,icon,points_price,stock FROM ' . tn('products') . + ' WHERE ' . implode(' AND ', $where) . ' ORDER BY created_at DESC LIMIT 100'; + $stmt = db()->prepare($sql); + $stmt->execute($params); + $products = []; + while ($r = $stmt->fetch()) { + $products[] = [ + 'id' => (int)$r['id'], + 'name' => $r['name'], + 'category' => $r['category'], + 'image' => $r['image'] ?: '', + 'icon' => $r['icon'] ?: '', + 'points_price' => !empty($r['points_price']) ? (int)$r['points_price'] : null, + 'stock' => (int)$r['stock'], + ]; + } + json_ok(['products' => $products, 'category' => $cat, 'count' => count($products)]); + +case 'product_info': + $pid = (int)($_GET['id'] ?? 0); + if ($pid <= 0) { json_err(400, '商品 ID 无效'); } + $stmt = db()->prepare( + 'SELECT id,name,category,image,icon,points_price,cash_price,stock,period,description,created_at FROM ' . tn('products') . ' WHERE id=? AND status=1' + ); + $stmt->execute([$pid]); + $p = $stmt->fetch(); + if (!$p) { json_err(404, '商品不存在'); } + json_ok([ + 'id' => (int)$p['id'], + 'name' => $p['name'], + 'category' => $p['category'], + 'image' => $p['image'] ?: '', + 'icon' => $p['icon'] ?: '', + 'points_price' => !empty($p['points_price']) ? (int)$p['points_price'] : null, + 'cash_price' => !empty($p['cash_price']) ? floatval($p['cash_price']) : null, + 'stock' => (int)$p['stock'], + 'period' => $p['period'] ?: '', + 'description' => $p['description'] ?: '', + ]); + +case 'announcements': + $limit = min(20, max(1, (int)($_GET['limit'] ?? 10))); + $stmt = db()->query( + 'SELECT id,title,pinned,content,created_at FROM ' . tn('announcements') . + ' WHERE status=1 ORDER BY pinned DESC, created_at DESC LIMIT ' . $limit + ); + $list = []; + while ($a = $stmt->fetch()) { + $list[] = [ + 'id' => (int)$a['id'], + 'title' => $a['title'], + 'pinned' => (int)$a['pinned'], + 'excerpt' => mb_substr(strip_tags($a['content'] ?? ''), 0, 120), + 'created_at' => $a['created_at'], + ]; + } + json_ok(['announcements' => $list]); + +default: + json_err(404, '未知操作: ' . h($action)); +} diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..17404ed --- /dev/null +++ b/assets/app.js @@ -0,0 +1,305 @@ +/** + * 商城前台 AJAX 封装 + * + * 提供统一的 XHR 请求方法、购物车操作、签到、工单提交等。 + * 依赖:页面中需有 或全局变量 FNW_CSRF + */ +var Fnw = (function () { + 'use strict'; + + // ─── CSRF Token ─── + function getCsrfToken() { + var m = document.querySelector('meta[name="csrf"]'); + return m ? m.getAttribute('content') : (window.FNW_CSRF || ''); + } + + // ─── 通用 XHR ─── + /** + * 发送请求 + * @param {Object} opts + * @param {string} opts.action - API action 名称 + * @param {string} [opts.method='GET'] - HTTP 方法 + * @param {Object} [opts.data] - POST 数据 + * @param {function} [opts.success] - 成功回调 (res) + * @param {function} [opts.error] - 失败回调 (res) + * @param {function} [opts.complete] - 完成回调 + */ + function request(opts) { + var method = (opts.method || 'GET').toUpperCase(); + var url = 'api.php?action=' + encodeURIComponent(opts.action); + var xhr = new XMLHttpRequest(); + xhr.open(method, url, true); + xhr.setRequestHeader('Accept', 'application/json'); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + + if (method === 'POST') { + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + } + + // CSRF header for POST + if (method === 'POST') { + xhr.setRequestHeader('X-CSRF-Token', getCsrfToken()); + } + + xhr.onreadystatechange = function () { + if (xhr.readyState !== 4) return; + if (opts.complete) opts.complete(); + if (xhr.status === 0) { if (opts.error) opts.error({ ok: false, msg: '网络错误,请检查连接' }); return; } + var res; + try { res = JSON.parse(xhr.responseText); } catch (e) { res = { ok: false, msg: '服务器响应异常' }; } + if (res.ok) { + if (opts.success) opts.success(res); + } else { + if (opts.error) opts.error(res); + } + }; + + if (method === 'POST' && opts.data) { + var params = []; + // Always include csrf_token in POST body as fallback + opts.data.csrf_token = getCsrfToken(); + for (var k in opts.data) { + if (opts.data.hasOwnProperty(k)) { + params.push(encodeURIComponent(k) + '=' + encodeURIComponent(opts.data[k])); + } + } + xhr.send(params.join('&')); + } else { + // Append GET params + if (opts.data && method === 'GET') { + var qs = []; + for (var k in opts.data) { + if (opts.data.hasOwnProperty(k)) { + qs.push(encodeURIComponent(k) + '=' + encodeURIComponent(opts.data[k])); + } + } + if (qs.length) url += '&' + qs.join('&'); + } + xhr.send(); + } + } + + // ─── Toast 提示 ─── + var toastTimer = null; + function showToast(msg, type) { + type = type || 'ok'; // 'ok' | 'err' | 'info' + var existing = document.getElementById('fnwToast'); + if (existing) existing.remove(); + + var el = document.createElement('div'); + el.id = 'fnwToast'; + el.className = 'fnw-toast fnw-toast-' + type; + el.innerHTML = '' + + (type === 'ok' ? '✓' : type === 'err' ? '✗' : 'ⓘ') + + '' + msg + ''; + + Object.assign(el.style, { + position: 'fixed', + top: '-60px', + left: '50%', + transform: 'translateX(-50%)', + zIndex: '9999', + padding: '12px 24px', + borderRadius: '10px', + fontSize: '.92rem', + fontWeight: '500', + display: 'flex', + alignItems: 'center', + gap: '8px', + boxShadow: '0 8px 24px rgba(0,0,0,.15)', + transition: 'top .3s ease', + maxWidth: '90vw' + }); + + var colors = { + ok: { bg: '#f0fdf4', border: '#86efac', color: '#166534', icon: '#22c55e' }, + err: { bg: '#fef2f2', border: '#fca5a5', color: '#991b1b', icon: '#ef4444' }, + info: { bg: '#eff6ff', border: '#93c5fd', color: '#1e40af', icon: '#3b82f6' } + }; + var c = colors[type] || colors.info; + el.style.background = c.bg; + el.style.border = '1px solid ' + c.border; + el.style.color = c.color; + el.querySelector('.fnw-toast-icon').style.color = c.icon; + + document.body.appendChild(el); + + // Animate in + requestAnimationFrame(function () { + el.style.top = '20px'; + }); + + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { + el.style.top = '-60px'; + setTimeout(function () { el.remove(); }, 320); + }, 2800); + + return el; + } + + // ─── 购物车 ─── + var cart = { + add: function (productId, qty, callback) { + request({ + action: 'cart_add', + method: 'POST', + data: { product_id: productId, qty: qty || 1 }, + success: function (res) { + showToast(res.msg || '已加入购物车', 'ok'); + cart.updateBadge(res.data.cart_count); + if (callback) callback(null, res.data); + }, + error: function (res) { + showToast(res.msg || '加入购物车失败', 'err'); + if (callback) callback(new Error(res.msg)); + } + }); + }, + update: function (productId, qty, callback) { + request({ + action: 'cart_update', + method: 'POST', + data: { product_id: productId, qty: qty }, + success: function (res) { + cart.updateBadge(res.data.cart_count); + if (callback) callback(null, res.data); + }, + error: function (res) { + showToast(res.msg || '更新失败', 'err'); + if (callback) callback(new Error(res.msg)); + } + }); + }, + remove: function (productId, callback) { + request({ + action: 'cart_remove', + method: 'POST', + data: { product_id: productId }, + success: function (res) { + showToast('已移除', 'ok'); + cart.updateBadge(res.data.cart_count); + if (callback) callback(null, res.data); + }, + error: function (res) { + showToast(res.msg || '移除失败', 'err'); + if (callback) callback(new Error(res.msg)); + } + }); + }, + clear: function (callback) { + request({ + action: 'cart_clear', + method: 'POST', + success: function (res) { + showToast('购物车已清空', 'ok'); + cart.updateBadge(0); + if (callback) callback(null); + }, + error: function (res) { + showToast(res.msg || '清空失败', 'err'); + if (callback) callback(new Error(res.msg)); + } + }); + }, + updateBadge: function (count) { + var badges = document.querySelectorAll('.badge.cart-badge'); + badges.forEach(function (b) { + b.textContent = count; + b.style.display = count > 0 ? 'inline' : 'none'; + }); + // Also try nav-link badge + var navBadge = document.querySelector('a[href="cart"] .badge'); + if (navBadge) { + navBadge.textContent = count; + navBadge.style.display = count > 0 ? 'inline' : 'none'; + } + } + }; + + // ─── 签到 ─── + var sign = { + do: function (callback) { + request({ + action: 'sign_in', + method: 'POST', + success: function (res) { + showToast(res.msg || '签到成功!', 'ok'); + if (callback) callback(null, res.data); + }, + error: function (res) { + showToast(res.msg || '签到失败', 'err'); + if (callback) callback(new Error(res.msg)); + } + }); + } + }; + + // ─── 工单 ─── + var ticket = { + submit: function (data, callback) { + request({ + action: 'ticket_submit', + method: 'POST', + data: data, + success: function (res) { + showToast(res.msg || '工单提交成功!', 'ok'); + if (callback) callback(null, res.data); + }, + error: function (res) { + showToast(res.msg || '提交失败', 'err'); + if (callback) callback(new Error(res.msg)); + } + }); + } + }; + + // ─── 搜索(防抖)───────────────────────────────────────────── + var searchTimer = null; + var search = { + /** + * 带防抖的搜索 + * @param {string} q 关键词 + * @param {number} [delay=400] 防抖毫秒数 + * @param {function} callback 回调 (err, results) + */ + query: function (q, delay, callback) { + if (typeof delay === 'function') { callback = delay; delay = 400; } + clearTimeout(searchTimer); + if (!q || q.length < 1) { if (callback) callback(null, []); return; } + searchTimer = setTimeout(function () { + request({ + action: 'search', + data: { q: q }, + success: function (res) { if (callback) callback(null, res.data); }, + error: function (res) { if (callback) callback(new Error(res.msg)); } + }); + }, delay); + } + }; + + // ─── 公开 API ─── + return { + request: request, + toast: showToast, + cart: cart, + sign: sign, + ticket: ticket, + search: search, + csrf: getCsrfToken + }; +})(); + +// ─── 页面加载后初始化:注入 CSRF meta ─── +(function () { + // 如果页面还没有 csrf meta tag,尝试从表单中获取并注入 + if (!document.querySelector('meta[name="csrf"]')) { + var csrfInput = document.querySelector('input[name="csrf"]'); + if (csrfInput) { + var meta = document.createElement('meta'); + meta.name = 'csrf'; + meta.content = csrfInput.value; + document.head.appendChild(meta); + } + } +})(); diff --git a/assets/lang/en_US.json b/assets/lang/en_US.json new file mode 100644 index 0000000..cbb2a69 --- /dev/null +++ b/assets/lang/en_US.json @@ -0,0 +1,441 @@ +{ + "_meta": { + "name": "English", + "code": "en_US", + "direction": "ltr" + }, + "common": { + "site_name": "Free Cloud Mall", + "home": "Home", + "all_products": "All Products", + "search": "Search", + "search_placeholder": "Search products…", + "cart": "Cart", + "my_orders": "My Orders", + "my_tickets": "My Tickets", + "submit_ticket": "Submit Ticket", + "announcements": "Announcements", + "settings": "Settings", + "sign_in": "Daily Sign-in", + "invite_friends": "Invite Friends", + "logout": "Logout", + "login": "Login", + "register": "Register", + "loading": "Loading…", + "no_data": "No data available", + "confirm": "Confirm", + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "close": "Close", + "more": "More", + "back": "Back", + "next": "Next", + "prev": "Previous", + "submit": "Submit", + "reset": "Reset", + "total": "Total", + "price": "Price", + "stock": "Stock", + "category": "Category", + "status": "Status", + "action": "Action", + "name": "Name", + "description": "Description", + "date": "Date", + "time": "Time", + "quantity": "Quantity", + "amount": "Amount", + "points": "Points", + "remaining": "Remaining", + "items": "items", + "unit_yuan": "CNY", + "email_verified": "Email Verified", + "email_unverified": "Email Not Verified" + }, + "hero": { + "title": "Free Cloud Mall", + "subtitle": "Public Welfare · Affordable · Carefully Selected for You" + }, + "product": { + "buy_now": "Buy Now", + "add_to_cart": "Add to Cart", + "out_of_stock": "Out of Stock", + "in_stock": "In Stock", + "points_price": "{points} pts", + "cash_price": "Order Directly", + "period": "Period", + "detail": "Product Details", + "purchased": "Purchase History", + "about_mall": "About Mall" + }, + "cart": { + "title": "Shopping Cart", + "empty": "Your cart is empty, go shopping!", + "clear": "Clear Cart", + "checkout": "Checkout", + "continue_shop": "Continue Shopping", + "total": "Total", + "pay_method": "Payment Method", + "points_pay": "Points Payment", + "cash_pay": "Cash Payment (Requires Admin Approval)", + "confirm_order": "Confirm Order", + "remove": "Remove", + "update_qty": "Update Quantity" + }, + "order": { + "order_list": "My Orders", + "order_no": "Order No.", + "order_time": "Order Time", + "order_detail": "View Details", + "pending": "Pending", + "paid": "Paid", + "shipped": "Shipped", + "completed": "Completed", + "cancelled": "Cancelled", + "address": "Shipping Address", + "phone": "Phone Number", + "remark": "Remark", + "expire_at": "Expires At", + "renew": "Renew", + "delivery_info": "Delivery Info" + }, + "auth": { + "login_title": "Welcome Back", + "register_title": "Create Account", + "username": "Username", + "password": "Password", + "confirm_password": "Confirm Password", + "email": "Email", + "captcha": "CAPTCHA", + "refresh_captcha": "Refresh", + "forgot_password": "Forgot password?", + "no_account": "No account?", + "has_account": "Already have an account?", + "go_register": "Register", + "go_login": "Login", + "login_btn": "Sign In", + "register_btn": "Sign Up" + }, + "ticket": { + "title": "Submit Ticket", + "dept": "Department", + "select_dept": "Select Department", + "subject": "Subject", + "content": "Description", + "priority": "Priority", + "low": "Low", + "normal": "Normal", + "high": "High", + "related_order": "Related Order (Optional)", + "submit": "Submit Ticket", + "my_tickets": "My Tickets", + "ticket_no": "Ticket No.", + "open": "Open", + "processing": "Processing", + "resolved": "Resolved", + "closed": "Closed", + "reply": "Reply", + "admin_reply": "Admin Reply", + "assignee": "Assignee" + }, + "sign": { + "title": "Daily Sign-in", + "signed_today": "Already signed in today", + "not_signed": "Not signed in today", + "sign_btn": "Sign In", + "points_gained": "Earned {points} points", + "consecutive": "{days} consecutive days", + "rule": "Rule: Earn points daily by signing in. Points can be used to redeem products." + }, + "user": { + "profile": "Profile", + "nickname": "Nickname", + "avatar": "Avatar", + "email_verified": "Email Verified", + "email_unverified": "Email Not Verified", + "verify_email": "Verify Email", + "resend": "Resend", + "change_pwd": "Change Password", + "current_pwd": "Current Password", + "new_pwd": "New Password", + "confirm_new_pwd": "Confirm New Password", + "my_points": "My Points", + "invite_code": "My Invite Code", + "invite_link": "Invite Link", + "invite_reward": "Invite Reward", + "invite_earned": "Earned from invites", + "copy_link": "Copy Link", + "copied": "Copied" + }, + "announcement": { + "title": "Announcements", + "pinned": "Pinned", + "read_more": "Read More", + "published": "Published on", + "no_announcements": "No announcements" + }, + "theme": { + "light": "Light Mode", + "dark": "Dark Mode", + "switch_theme": "Switch Theme" + }, + "footer": { + "brand_slogan": "Carefully Selected · Public Welfare · Affordable", + "copyright": "All Rights Reserved", + "nav_home": "Mall Home", + "nav_all": "All Products", + "nav_cart": "Cart", + "nav_ticket": "Submit Ticket", + "nav_announce": "Announcements", + "nav_mytickets": "My Tickets", + "nav_myorders": "My Orders" + }, + "admin": { + "dashboard": "Dashboard", + "products": "Products", + "groups": "Groups", + "orders": "Orders", + "tickets": "Tickets", + "departments": "Departments", + "users": "Users", + "admins": "Admins / CS", + "settings": "Settings", + "total_users": "Total Users", + "total_orders": "Total Orders", + "total_products": "Products", + "open_tickets": "Open Tickets", + "today_signins": "Today Sign-ins", + "recent_orders": "Recent Orders", + "my_pending": "My Pending", + "my_assigned": "Assigned to Me", + "add_product": "Add Product", + "edit_product": "Edit Product", + "delete_confirm": "Are you sure? This cannot be undone.", + "site_name_setting": "Site Name", + "change_password": "Change Password", + "mall_intro": "Mall Introduction", + "points_settings": "Points Settings", + "smtp_config": "SMTP Configuration", + "test_mail": "Send Test Email", + "save_success": "Saved Successfully", + "role_admin": "Admin", + "role_cs": "Customer Service" + }, + "index": { + "announcement": "Announcement", + "more": "More", + "all": "All", + "no_result": "No matching products found, try another keyword.", + "points_unit": "pts" + }, + "login": { + "title": "Login / Register", + "err_login_failed": "Incorrect username or password", + "err_captcha_wrong": "CAPTCHA is incorrect, please try again", + "err_username_short": "Username must be at least 3 characters", + "err_password_short": "Password must be at least 6 characters", + "err_password_mismatch": "Passwords do not match", + "err_email_invalid": "Please enter a valid email address", + "err_invite_invalid": "Invalid invite code, please check or leave blank", + "err_ip_limit": "Daily registration limit reached (2/day) from this network, please try again tomorrow", + "err_username_exists": "This username is already taken", + "tab_login": "Login", + "tab_register": "Register", + "label_username": "Username", + "label_password": "Password", + "label_email": "Email (for order & ticket notifications and email verification)", + "label_email_required": "Email *", + "label_captcha": "CAPTCHA", + "captcha_placeholder": "Enter the 4 digits above", + "captcha_hint": "Can't see clearly? Click to refresh", + "label_confirm_pwd": "Confirm Password", + "label_invite": "Invite Code (optional)", + "invite_placeholder": "Enter invite code if you have one for rewards", + "username_placeholder": "At least 3 characters", + "email_placeholder": "Required, e.g. you@example.com", + "pwd_placeholder": "At least 6 characters", + "btn_login": "Login", + "btn_register": "Register & Login", + "register_tip": "By registering you agree to our terms of service; max 2 registrations per network per day." + }, + "cart_page": { + "title": "My Shopping Cart", + "empty": "Your cart is empty, go {link}~", + "empty_link": "find something good", + "points_each": "pts/item", + "update_qty": "Update Quantity", + "clear_confirm": "Clear the cart?", + "clear_btn": "Clear", + "checkout_btn": "Checkout", + "continue_btn": "Continue Shopping", + "remove_title": "Remove", + "subtotal_free": "—" + }, + "ticket_page": { + "title": "Submit Ticket", + "desc_hint": "Having issues? Submit a ticket and staff will respond in the admin panel. You'll also receive email notifications.", + "type_general": "General Ticket", + "type_renewal": "Renewal Request", + "select_dept": "Select Department", + "related_order_label": "Related Order (required for renewal)", + "order_no_link": "No association", + "subject_placeholder": "Briefly describe your issue", + "subject_renewal_prefix": "Renewal: Order #", + "message_placeholder": "Please describe your issue in detail, including reproduction steps", + "contact_email": "Contact Email", + "submit_btn": "Submit Ticket", + "view_my_tickets": "View My Tickets", + "err_subject_empty": "Please enter a subject", + "err_subject_long": "Subject too long (max 160 chars)", + "err_message_empty": "Please enter a description", + "err_priority_bad": "Invalid priority level", + "err_order_invalid": "Please select a valid renewal order", + "renewal_period": "Period", + "renewal_expire": "Expires" + }, + "mytickets": { + "title": "My Tickets", + "ok_msg": "Operation successful. Staff will review and respond as soon as possible (you'll receive email notifications if SMTP is configured).", + "ticket_detail": "Ticket #{id}", + "meta_type": "Type: ", + "meta_dept": "Department: ", + "meta_priority": "Priority: ", + "meta_time": "Submitted: ", + "meta_unspecified": "Unspecified", + "renewal_note": "Related Order: {link} (staff will renew this order after approval)", + "bubble_me": "Me · ", + "bubble_staff": "Staff · ", + "reply_label": "Follow-up Reply", + "reply_placeholder": "Add more details or feedback about your issue", + "send_reply": "Send Reply", + "back_list": "Back to List", + "closed_lock": "This ticket is closed. You can reopen it to continue, or submit a new ticket.", + "reopen_btn": "Reopen Ticket", + "list_title": "My Tickets", + "new_ticket": "New Ticket", + "no_tickets": "You haven't submitted any tickets yet.", + "view_btn": "View", + "col_id": "#", + "col_subject": "Subject", + "col_type": "Type", + "col_dept": "Department", + "col_priority": "Priority", + "col_status": "Status", + "col_updated": "Updated", + "err_no_perm": "Ticket not found or no permission", + "err_not_closed": "This ticket is not closed, no need to reopen", + "err_closed_reply": "This ticket is closed. Cannot reply; please submit a new ticket for further feedback", + "err_reply_empty": "Please enter reply content", + "err_reply_long": "Reply too long (max 5000 chars)" + }, + "myorders": { + "title": "My Orders", + "paid_success": "Order {no} submitted successfully, marked as \"Paid\".", + "no_orders": "You don't have any orders yet, go {link}~", + "shop_link": "browse the mall", + "order_no_label": "Order No.: ", + "detail_btn": "View Details", + "total_label": "Total: ", + "pay_points": "· {pts} pts", + "shipping_label": "Shipping: ", + "period_label": "Period: ", + "expire_label": "Expires: ", + "renew_btn": "Request Renewal", + "items_fmt": "{name} × {qty}" + }, + "settings_page": { + "title": "User Settings", + "saved": "Profile saved.", + "saved_email_change": "Profile saved. Email verification required due to email change.", + "err_username_short": "Username must be at least 3 characters", + "err_email_invalid": "Please enter a valid email address", + "err_nickname_long": "Nickname must be at most 30 characters", + "err_username_taken": "This username is already taken", + "avatar_section": "Avatar", + "select_image": "Select Image", + "avatar_hint": "JPG / PNG / WebP / GIF accepted, max 2MB, min 64×64", + "uploading": "Uploading…", + "upload_ok": "Avatar updated successfully", + "upload_fail": "Upload failed", + "img_too_big": "Image must not exceed 2MB", + "network_err": "Network error ({code})", + "network_retry": "Network error, please retry", + "response_err": "Unexpected response", + "save_btn": "Save Changes", + "back_orders": "Back to My Orders", + "verify_section": "Email Verification", + "verify_done": "Your email has been verified.", + "verify_not_done": "Email not verified yet. {link}.", + "verify_send_link": "Click to send verification email", + "nickname_placeholder": "Optional, max 30 chars" + }, + "product_page": { + "back_list": "Back to Products", + "category_label": "", + "points_price_label": "Points Price: ", + "points_suffix": " pts", + "cash_price_label": "Order Directly", + "stock_label": "Stock: ", + "stock_unit": " items", + "period_label": "Period: ", + "qty_label": "Quantity", + "add_cart_btn": "Add to Cart", + "sold_out": "Out of Stock", + "err_out_of_stock": "This product is temporarily out of stock", + "err_low_stock": "Insufficient stock, maximum {max} can be purchased", + "purchased_title": "You Have Purchased This Product", + "purchased_toggle": "View Product Details", + "purchased_toggle_close": "Collapse", + "purchased_activated": "Activated", + "purchased_not_activated": "Not Activated", + "purchased_product_info": "Product Info: ", + "server_ip": "Server IP: ", + "conn_addr": "Connection Address: ", + "login_user": "Login Account: ", + "login_pass": "Login Password: ", + "remark_label": "Remark: ", + "pending_delivery": "Order placed, waiting for admin activation and product info.", + "reveal_btn": "Reveal", + "none_value": "—" + }, + "admin_extra": { + "logs": "System Logs", + "log_viewer_title": "System Logs", + "log_date_select": "Select Date: ", + "log_view_btn": "View", + "log_available_dates": "Available dates: ", + "log_total_entries": "({n} entries)", + "log_no_records": "No log records for this day.", + "log_mode_hint": "Log levels: DEBUG / INFO / WARN / ERROR. Current mode: {mode}", + "log_debug_config": "You can enable DEBUG level by setting define('DEBUG', true) in config.php.", + "log_col_time": "Time", + "log_col_level": "Level", + "log_col_source": "Source", + "log_col_content": "Content", + "log_prev": "« Previous", + "log_next": "Next »", + "log_page_info": "Page {p} of {t}", + "cs_readonly": "Customer Service accounts are read-only. Please contact an administrator for changes.", + "admins_title": "Administrators & CS", + "admins_add_title": "Add Account (Admin / CS)", + "admins_add_desc": "Administrators have full access; CS staff can view all pages but only handle tickets.", + "admins_cs_cannot_add": "CS accounts cannot add administrators. Please contact an admin.", + "admins_list_title": "Account List", + "admins_super_tag": "Super", + "admins_protected": "(Protected)", + "admins_current": "Current Account", + "admins_super_protected": "Protected", + "admins_role_confirm": "Change {user}'s role to \"{role}\"?", + "admins_super_warn": "The Super Admin (ID=1) is protected from deletion or demotion. Other admins can be deleted or demoted by fellow admins.", + "admins_err_self_role": "Cannot change the role of the currently logged-in account.", + "admins_err_super_role": "Cannot change the role of the Super Admin (ID=1).", + "admins_err_self_del": "Cannot delete the currently logged-in account.", + "admins_err_super_del": "Cannot delete the Super Admin (ID=1). This account is system-protected.", + "admins_err_last_admin": "At least one admin must remain. Cannot delete.", + "admins_role_changed": "Account #{id} role changed to \"{role}\".", + "users_title": "User Management ({n} total)", + "cs_readonly_users": "CS accounts can only view the user list." + } +} \ No newline at end of file diff --git a/assets/lang/zh_CN.json b/assets/lang/zh_CN.json new file mode 100644 index 0000000..01aca97 --- /dev/null +++ b/assets/lang/zh_CN.json @@ -0,0 +1,441 @@ +{ + "_meta": { + "name": "简体中文", + "code": "zh_CN", + "direction": "ltr" + }, + "common": { + "site_name": "自由云商城", + "home": "首页", + "all_products": "全部商品", + "search": "搜索", + "search_placeholder": "搜索商品名称…", + "cart": "购物车", + "my_orders": "我的订单", + "my_tickets": "我的工单", + "submit_ticket": "提交工单", + "announcements": "公告中心", + "settings": "用户设置", + "sign_in": "每日签到", + "invite_friends": "邀请好友", + "logout": "退出登录", + "login": "登录", + "register": "注册", + "loading": "加载中…", + "no_data": "暂无数据", + "confirm": "确认", + "cancel": "取消", + "save": "保存", + "delete": "删除", + "edit": "编辑", + "close": "关闭", + "more": "更多", + "back": "返回", + "next": "下一步", + "prev": "上一步", + "submit": "提交", + "reset": "重置", + "total": "合计", + "price": "价格", + "stock": "库存", + "category": "分类", + "status": "状态", + "action": "操作", + "name": "名称", + "description": "描述", + "date": "日期", + "time": "时间", + "quantity": "数量", + "amount": "金额", + "points": "积分", + "remaining": "剩余", + "items": "件", + "unit_yuan": "元", + "email_verified": "邮箱已验证", + "email_unverified": "邮箱未验证" + }, + "hero": { + "title": "自由云商城", + "subtitle": "公益 · 实惠 · 用心挑选的每一件好物" + }, + "product": { + "buy_now": "立即购买", + "add_to_cart": "加入购物车", + "out_of_stock": "缺货", + "in_stock": "有货", + "points_price": "{points} 分", + "cash_price": "直接下单", + "period": "周期", + "detail": "商品详情", + "purchased": "已购买记录", + "about_mall": "关于商城" + }, + "cart": { + "title": "购物车", + "empty": "购物车是空的,去逛逛吧", + "clear": "清空购物车", + "checkout": "去结算", + "continue_shop": "继续购物", + "total": "总计", + "pay_method": "支付方式", + "points_pay": "积分支付", + "cash_pay": "现金支付(需管理员审核)", + "confirm_order": "确认下单", + "remove": "移除", + "update_qty": "更新数量" + }, + "order": { + "order_list": "我的订单", + "order_no": "订单号", + "order_time": "下单时间", + "order_detail": "查看详情", + "pending": "待处理", + "paid": "已付款", + "shipped": "已发货", + "completed": "已完成", + "cancelled": "已取消", + "address": "收货地址", + "phone": "联系电话", + "remark": "备注", + "expire_at": "到期时间", + "renew": "续期", + "delivery_info": "发货信息" + }, + "auth": { + "login_title": "欢迎回来", + "register_title": "创建账户", + "username": "用户名", + "password": "密码", + "confirm_password": "确认密码", + "email": "邮箱", + "captcha": "验证码", + "refresh_captcha": "换一张", + "forgot_password": "忘记密码?", + "no_account": "没有账号?", + "has_account": "已有账号?", + "go_register": "去注册", + "go_login": "去登录", + "login_btn": "登录", + "register_btn": "注册" + }, + "ticket": { + "title": "提交工单", + "dept": "部门", + "select_dept": "选择部门", + "subject": "标题", + "content": "描述内容", + "priority": "优先级", + "low": "低", + "normal": "普通", + "high": "高", + "related_order": "关联订单(可选)", + "submit": "提交工单", + "my_tickets": "我的工单", + "ticket_no": "工单号", + "open": "待处理", + "processing": "处理中", + "resolved": "已解决", + "closed": "已关闭", + "reply": "回复", + "admin_reply": "处理回复", + "assignee": "负责人" + }, + "sign": { + "title": "每日签到", + "signed_today": "今天已签到", + "not_signed": "今日尚未签到", + "sign_btn": "签到", + "points_gained": "获得 {points} 积分", + "consecutive": "连续签到 {days} 天", + "rule": "签到规则:每天签到可获得积分,积分可用于兑换商品。" + }, + "user": { + "profile": "个人资料", + "nickname": "昵称", + "avatar": "头像", + "email_verified": "邮箱已验证", + "email_unverified": "邮箱未验证", + "verify_email": "验证邮箱", + "resend": "重新发送", + "change_pwd": "修改密码", + "current_pwd": "当前密码", + "new_pwd": "新密码", + "confirm_new_pwd": "确认新密码", + "my_points": "我的积分", + "invite_code": "我的邀请码", + "invite_link": "邀请链接", + "invite_reward": "邀请奖励", + "invite_earned": "已获得邀请积分", + "copy_link": "复制链接", + "copied": "已复制" + }, + "announcement": { + "title": "公告中心", + "pinned": "置顶", + "read_more": "阅读全文", + "published": "发布于", + "no_announcements": "暂无公告" + }, + "theme": { + "light": "浅色模式", + "dark": "深色模式", + "switch_theme": "切换主题" + }, + "footer": { + "brand_slogan": "用心挑选的每一件好物 · 公益 · 实惠", + "copyright": "版权所有", + "nav_home": "商城首页", + "nav_all": "全部商品", + "nav_cart": "购物车", + "nav_ticket": "提交工单", + "nav_announce": "公告中心", + "nav_mytickets": "我的工单", + "nav_myorders": "我的订单" + }, + "admin": { + "dashboard": "仪表盘", + "products": "商品管理", + "groups": "商品分组", + "orders": "订单管理", + "tickets": "工单管理", + "departments": "工单部门", + "users": "用户管理", + "admins": "管理员/客服", + "settings": "设置", + "total_users": "总用户数", + "total_orders": "总订单数", + "total_products": "商品数", + "open_tickets": "待处理工单", + "today_signins": "今日签到", + "recent_orders": "最近订单", + "my_pending": "我待处理", + "my_assigned": "我负责的工单", + "add_product": "新增商品", + "edit_product": "编辑商品", + "delete_confirm": "确定删除吗?此操作不可撤销。", + "site_name_setting": "站点名称", + "change_password": "修改密码", + "mall_intro": "商城介绍", + "points_settings": "积分系统设置", + "smtp_config": "SMTP 邮件配置", + "test_mail": "发送测试邮件", + "save_success": "保存成功", + "role_admin": "管理员", + "role_cs": "客服" + }, + "index": { + "announcement": "公告", + "more": "更多", + "all": "全部", + "no_result": "没有找到匹配的商品,换个关键词试试吧。", + "points_unit": "分" + }, + "login": { + "title": "登录 / 注册", + "err_login_failed": "用户名或密码错误", + "err_captcha_wrong": "图形验证码不正确,请重新输入", + "err_username_short": "用户名至少 3 个字符", + "err_password_short": "密码至少 6 位", + "err_password_mismatch": "两次密码不一致", + "err_email_invalid": "请填写有效的邮箱地址", + "err_invite_invalid": "邀请码无效,请检查或留空", + "err_ip_limit": "当前网络今日注册次数已达上限(2 个/天),请明日再试", + "err_username_exists": "该用户名已被注册", + "tab_login": "登录", + "tab_register": "注册", + "label_username": "用户名", + "label_password": "密码", + "label_email": "邮箱(用于接收订单与工单通知,并完成邮箱验证)", + "label_email_required": "邮箱 *", + "label_captcha": "图形验证码", + "captcha_placeholder": "输入上图 4 位", + "captcha_hint": "看不清?点击刷新", + "label_confirm_pwd": "确认密码", + "label_invite": "邀请码(选填)", + "invite_placeholder": "如有邀请码可填写,享受邀请奖励", + "username_placeholder": "至少3位", + "email_placeholder": "必填,如 you@example.com", + "pwd_placeholder": "至少6位", + "btn_login": "登录", + "btn_register": "注册并登录", + "register_tip": "注册即表示同意公益服务条款;同一网络每日最多注册 2 个账号。" + }, + "cart_page": { + "title": "我的购物车", + "empty": "购物车还是空的,去 {link} 吧~", + "empty_link": "挑点好物", + "points_each": "积分/件", + "update_qty": "更新数量", + "clear_confirm": "确定清空购物车?", + "clear_btn": "清空", + "checkout_btn": "去结算", + "continue_btn": "继续购物", + "remove_title": "移除", + "subtotal_free": "—" + }, + "ticket_page": { + "title": "提交工单", + "desc_hint": "遇到问题?提交工单后,处理人员会在后台看到并回复,回复也会通过邮件通知你。", + "type_general": "普通工单", + "type_renewal": "续期申请", + "select_dept": "选择部门", + "related_order_label": "关联订单(续期申请时必选)", + "order_no_link": "不关联", + "subject_placeholder": "一句话概括你的问题", + "subject_renewal_prefix": "续期申请:订单 #", + "message_placeholder": "请详细描述你遇到的问题、复现步骤等", + "contact_email": "联系邮箱", + "submit_btn": "提交工单", + "view_my_tickets": "查看我的工单", + "err_subject_empty": "请填写工单标题", + "err_subject_long": "标题过长(最多 160 字)", + "err_message_empty": "请填写问题描述", + "err_priority_bad": "优先级不合法", + "err_order_invalid": "请选择有效的续期订单", + "renewal_period": "周期", + "renewal_expire": "到期" + }, + "mytickets": { + "title": "我的工单", + "ok_msg": "操作成功,处理人员会尽快查看并回复(若已配置 SMTP 将邮件通知)。", + "ticket_detail": "工单 #{id}", + "meta_type": "类型:", + "meta_dept": "部门:", + "meta_priority": "优先级:", + "meta_time": "提交时间:", + "meta_unspecified": "未指定", + "renewal_note": "关联订单:{link}(处理人员批准后将为该订单续期)", + "bubble_me": "我 · ", + "bubble_staff": "处理人员 · ", + "reply_label": "追加回复", + "reply_placeholder": "补充说明你的问题或反馈处理结果", + "send_reply": "发送回复", + "back_list": "返回列表", + "closed_lock": "该工单已关闭,你可以重新开启后继续反馈,也可以提交新工单。", + "reopen_btn": "重新开启工单", + "list_title": "我的工单", + "new_ticket": "提交新工单", + "no_tickets": "你还没有提交过工单。", + "view_btn": "查看", + "col_id": "编号", + "col_subject": "标题", + "col_type": "类型", + "col_dept": "部门", + "col_priority": "优先级", + "col_status": "状态", + "col_updated": "更新时间", + "err_no_perm": "工单不存在或无权限", + "err_not_closed": "该工单未关闭,无需重新开启", + "err_closed_reply": "该工单已关闭,无法继续回复;如需反馈请提交新工单", + "err_reply_empty": "请填写回复内容", + "err_reply_long": "回复内容过长(最多 5000 字)" + }, + "myorders": { + "title": "我的订单", + "paid_success": "订单 {no} 提交成功,已视为「已付款」。", + "no_orders": "你还没有订单,去 {link} 吧~", + "shop_link": "逛逛商城", + "order_no_label": "订单号:", + "detail_btn": "查看详情", + "total_label": "合计:", + "pay_points": "· {pts} 分", + "shipping_label": "收货:", + "period_label": "周期:", + "expire_label": "到期:", + "renew_btn": "申请续期", + "items_fmt": "{name} × {qty}" + }, + "settings_page": { + "title": "用户设置", + "saved": "资料已保存。", + "saved_email_change": "资料已保存,因更换邮箱需重新完成邮箱验证。", + "err_username_short": "用户名至少 3 个字符", + "err_email_invalid": "请填写有效的邮箱地址", + "err_nickname_long": "昵称最多 30 个字符", + "err_username_taken": "该用户名已被占用", + "avatar_section": "头像", + "select_image": "选择图片", + "avatar_hint": "支持 JPG / PNG / WebP / GIF,最大 2MB,最小 64×64", + "uploading": "上传中…", + "upload_ok": "头像更新成功", + "upload_fail": "上传失败", + "img_too_big": "图片不能超过 2MB", + "network_err": "网络错误 ({code})", + "network_retry": "网络错误,请重试", + "response_err": "响应异常", + "save_btn": "保存修改", + "back_orders": "返回我的订单", + "verify_section": "邮箱验证", + "verify_done": "你的邮箱已完成验证。", + "verify_not_done": "邮箱尚未验证,{link}。", + "verify_send_link": "点击发送验证邮件", + "nickname_placeholder": "选填,最多 30 字" + }, + "product_page": { + "back_list": "返回商品列表", + "category_label": "", + "points_price_label": "积分价:", + "points_suffix": " 积分", + "cash_price_label": "直接下单", + "stock_label": "库存:", + "stock_unit": " 件", + "period_label": "周期:", + "qty_label": "数量", + "add_cart_btn": "加入购物车", + "sold_out": "暂时缺货", + "err_out_of_stock": "该商品暂时缺货", + "err_low_stock": "库存不足,最多可购买 {max} 件", + "purchased_title": "你已购买该商品", + "purchased_toggle": "查看产品详情", + "purchased_toggle_close": "收起", + "purchased_activated": "已激活", + "purchased_not_activated": "未激活", + "purchased_product_info": "商品信息:", + "server_ip": "服务器 IP:", + "conn_addr": "连接地址:", + "login_user": "登录账户:", + "login_pass": "登录密码:", + "remark_label": "备注:", + "pending_delivery": "已下单,等待管理员开通并填写商品信息。", + "reveal_btn": "显示", + "none_value": "—" + }, + "admin_extra": { + "logs": "系统日志", + "log_viewer_title": "系统日志", + "log_date_select": "选择日期:", + "log_view_btn": "查看", + "log_available_dates": "可用日期:", + "log_total_entries": "(共 {n} 条)", + "log_no_records": "当天暂无日志记录。", + "log_mode_hint": "日志级别:DEBUG / INFO / WARN / ERROR。当前模式:{mode}", + "log_debug_config": "可在 config.php 中修改 define('DEBUG', true) 以开启 DEBUG 级别记录。", + "log_col_time": "时间", + "log_col_level": "级别", + "log_col_source": "来源", + "log_col_content": "内容", + "log_prev": "« 上一页", + "log_next": "下一页 »", + "log_page_info": "第 {p} / {t} 页", + "cs_readonly": "客服账号仅可查看,如需操作请联系管理员。", + "admins_title": "管理员与客服", + "admins_add_title": "添加账号(管理员 / 客服)", + "admins_add_desc": "管理员拥有全部后台权限;客服可查看所有页面但仅能处理工单相关操作。", + "admins_cs_cannot_add": "客服账号无法添加新管理员,如需操作请联系管理员。", + "admins_list_title": "账号列表", + "admins_super_tag": "超级", + "admins_protected": "(受保护)", + "admins_current": "当前账号", + "admins_super_protected": "受保护", + "admins_role_confirm": "确定将 {user} 的角色改为「{role}」?", + "admins_super_warn": "终极管理员受系统保护,不可被删除或降级。其他管理员可由同级管理员删除或改为客服。", + "admins_err_self_role": "不能修改当前登录账号的角色。", + "admins_err_super_role": "不能修改终极管理员(ID=1)的角色。", + "admins_err_self_del": "不能删除当前登录的账号。", + "admins_err_super_del": "不能删除终极管理员(ID=1),该账号受系统保护。", + "admins_err_last_admin": "至少要保留一名管理员,不能继续删除。", + "admins_role_changed": "已将账号 #{id} 角色改为「{role}」。", + "users_title": "用户管理(共 {n} 人)", + "cs_readonly_users": "客服账号仅可查看用户列表,无法执行操作。" + } +} \ No newline at end of file diff --git a/assets/marked.js b/assets/marked.js new file mode 100644 index 0000000..aba7e36 --- /dev/null +++ b/assets/marked.js @@ -0,0 +1,263 @@ +/* + * marked.js —— 轻量 Markdown 解析器(本地自带,不依赖任何 CDN) + * 用法:marked.parse(markdownString) -> htmlString + * 支持:标题、粗体/斜体/删除线、行内代码、代码块、引用、有序/无序列表(含嵌套)、 + * 链接、图片、分隔线、GFM 表格、段落与软换行。 + * 安全:先转义原文 HTML,链接 URL 做白名单过滤,避免 XSS。 + */ +(function (global) { + 'use strict'; + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>'); + } + + // 仅转义属性里的引号(文本已在 escapeHtml 中转义过 < > &) + function attrSafe(s) { + return String(s).replace(/"/g, '"'); + } + + // URL 白名单:仅允许 http/https/mailto/tel/锚点/相对路径;拦截 javascript:/vbscript:/危险 data: + function safeUrl(url) { + var u = String(url || '').trim(); + if (/^\s*(javascript|vbscript):/i.test(u)) return '#'; + if (/^\s*data:/i.test(u) && !/^data:image\//i.test(u)) return '#'; + return u; + } + + // 行内解析:输入为「未转义」的 markdown 文本 + function parseInline(text) { + var codes = []; + // 1. 先抽出行内代码,避免内部内容被后续规则破坏 + text = String(text).replace(/`([^`]+)`/g, function (_m, c) { + codes.push(c); + return 'CODE' + (codes.length - 1) + ''; + }); + // 2. 转义 HTML(代码占位符只含字母数字与空字符,不受影响) + text = escapeHtml(text); + // 3. 图片 ![alt](url "title") + text = text.replace(/!\[([^\]]*)\]\(((?:[^()\s]|\([^()]*\))+)(?:\s+"([^)]*?)")?\)/g, function (_m, alt, url, title) { + var t = title ? ' title="' + attrSafe(title) + '"' : ''; + return '' + attrSafe(alt) + ''; + }); + // 4. 链接 [text](url "title") + text = text.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()]*\))+)(?:\s+"([^)]*?)")?\)/g, function (_m, txt, url, title) { + var t = title ? ' title="' + attrSafe(title) + '"' : ''; + var ext = /^(https?:)?\/\//i.test(url); + var extAttr = ext ? ' target="_blank" rel="noopener noreferrer"' : ''; + return '' + txt + ''; + }); + // 5. 粗体 **x** / __x__ + text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); + text = text.replace(/__([^_]+)__/g, '$1'); + // 6. 斜体 *x* / _x_ + text = text.replace(/\*([^*]+)\*/g, '$1'); + text = text.replace(/(^|[^\w])_([^_]+)_(?=[^\w]|$)/g, '$1$2'); + // 7. 删除线 ~~x~~ + text = text.replace(/~~([^~]+)~~/g, '$1'); + // 8. 还原行内代码 + text = text.replace(/CODE(\d+)/g, function (_m, n) { + return '' + escapeHtml(codes[+n]) + ''; + }); + return text; + } + + // 列表解析:返回 { html, next } + function parseList(lines, start) { + var first = lines[start]; + var m = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(first); + var baseIndent = m[1].length; + var ordered = /\d+\./.test(m[2]); + var items = []; + var i = start; + + while (i < lines.length) { + var line = lines[i]; + if (/^\s*$/.test(line)) break; // 空行结束列表 + var mm = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(line); + if (!mm) { + // 同级的后续非标记行视为当前条目的延续(懒继续) + if (items.length && /^\s*\S/.test(line) && !/^\s*[-*+]\s/.test(line) && !/^\s*\d+\.\s/.test(line)) { + items[items.length - 1].text.push(line.trim()); + i++; + continue; + } + break; + } + var ind = mm[1].length; + if (ind < baseIndent) break; // 减缩进,结束 + if (ind > baseIndent) { // 嵌套列表 + var sub = parseList(lines, i); + items[items.length - 1].nested += sub.html; + i = sub.next; + continue; + } + // 同级新条目 + items.push({ text: [mm[3]], nested: '' }); + i++; + } + + var tag = ordered ? 'ol' : 'ul'; + var out = '<' + tag + '>'; + for (var k = 0; k < items.length; k++) { + out += '
  • ' + parseInline(items[k].text.join(' ')); + if (items[k].nested) out += items[k].nested; + out += '
  • '; + } + out += ''; + return { html: out, next: i }; + } + + function splitRow(line) { + var t = line.trim(); + if (t.charAt(0) === '|') t = t.slice(1); + if (t.charAt(t.length - 1) === '|') t = t.slice(0, -1); + return t.split('|').map(function (c) { return c.trim(); }); + } + + // 表格解析:返回 { html, next } + function parseTable(lines, start) { + var header = splitRow(lines[start]); + var sep = lines[start + 1]; + var aligns = splitRow(sep).map(function (c) { + var left = c.charAt(0) === ':'; + var right = c.charAt(c.length - 1) === ':'; + if (left && right) return 'center'; + if (right) return 'right'; + if (left) return 'left'; + return ''; + }); + var rows = []; + var j = start + 2; + while (j < lines.length && /\|/.test(lines[j]) && lines[j].trim() !== '') { + rows.push(splitRow(lines[j])); + j++; + } + function cell(tag, c, idx) { + var a = aligns[idx] ? ' style="text-align:' + aligns[idx] + '"' : ''; + return '<' + tag + a + '>' + parseInline(c) + ''; + } + var html = ''; + for (var h = 0; h < header.length; h++) html += cell('th', header[h], h); + html += ''; + for (var r = 0; r < rows.length; r++) { + html += ''; + for (var c = 0; c < rows[r].length; c++) html += cell('td', rows[r][c], c); + html += ''; + } + html += '
    '; + return { html: html, next: j }; + } + + // 块级解析 + function parse(src) { + src = (src || '').replace(/\r\n?/g, '\n'); + var lines = src.split('\n'); + var html = []; + var para = []; + var i = 0; + + function flushPara() { + if (para.length) { + var text = para.join('\n'); + // 段内软换行 ->
    + var inline = parseInline(text).replace(/\n/g, '
    \n'); + html.push('

    ' + inline + '

    '); + para = []; + } + } + + while (i < lines.length) { + var line = lines[i]; + + // 代码围栏 ``` + if (/^\s*```/.test(line)) { + flushPara(); + var lang = line.replace(/^\s*```/, '').trim(); + var buf = []; + i++; + while (i < lines.length && !/^\s*```/.test(lines[i])) { + buf.push(lines[i]); + i++; + } + i++; // 跳过结束围栏 + html.push('
    ' + escapeHtml(buf.join('\n')) + '
    '); + continue; + } + + // 分隔线 + if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { + flushPara(); + html.push('
    '); + i++; + continue; + } + + // 标题 # ~ ###### + var hm = /^\s*(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line); + if (hm) { + flushPara(); + var lvl = hm[1].length; + html.push('' + parseInline(hm[2].trim()) + ''); + i++; + continue; + } + + // 引用 > + if (/^\s*>\s?/.test(line)) { + flushPara(); + var qbuf = []; + while (i < lines.length && /^\s*>\s?/.test(lines[i])) { + qbuf.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + html.push('
    ' + parse(qbuf.join('\n')) + '
    '); + continue; + } + + // 表格(当前行含 |,且下一行是分隔行) + if (/\|/.test(line) && i + 1 < lines.length && + /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && /-/.test(lines[i + 1])) { + flushPara(); + var tbl = parseTable(lines, i); + html.push(tbl.html); + i = tbl.next; + continue; + } + + // 列表 + if (/^\s*([-*+]|\d+\.)\s+/.test(line)) { + flushPara(); + var lst = parseList(lines, i); + html.push(lst.html); + i = lst.next; + continue; + } + + // 空行 + if (/^\s*$/.test(line)) { + flushPara(); + i++; + continue; + } + + // 段落文本 + para.push(line); + i++; + } + flushPara(); + return html.join('\n'); + } + + var api = { parse: parse, escapeHtml: escapeHtml }; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } + global.marked = api; +})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this)); diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..149fdd3 --- /dev/null +++ b/assets/style.css @@ -0,0 +1,3355 @@ +@import url("https://cdn.jsdelivr.net/gh/willow-god/Sharding-fonts/Yozai-Medium/result.min.css"); + +:root { + --primary: #1a73e8; + --primary-hover: #1559c4; + --on-primary: #ffffff; + --primary-container: #dbeafe; + --on-primary-container: #0b3b8f; + --accent: #3b82f6; + --secondary: #eef2f7; + --on-secondary: #1f2430; + --surface: #ffffff; + --surface-1: #f8fafc; + --surface-2: #eef1f6; + --bg: #f5f8fc; + --card: #ffffff; + --text: #1f2430; + --muted: #5f6b7a; + --border: #d8dee9; + --outline: #d8dee9; + --outline-variant: #e5eaf1; + --danger: #e5484d; + --on-danger: #ffffff; + --ok: #16a34a; + --shadow-1: 0 1px 2px rgba(16, 24, 40, .06), 0 1px 3px rgba(16, 24, 40, .10); + --shadow-2: 0 2px 6px rgba(16, 24, 40, .08), 0 6px 14px rgba(16, 24, 40, .10); + --shadow-3: 0 8px 18px rgba(16, 24, 40, .10), 0 14px 30px rgba(16, 24, 40, .12); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 999px +} + +[data-theme="dark"] { + --primary: #8ab4f8; + --primary-hover: #a9c8fb; + --on-primary: #0b1f3a; + --primary-container: #16345f; + --on-primary-container: #d6e4ff; + --accent: #60a5fa; + --secondary: #1a2230; + --on-secondary: #e6ebf2; + --surface: #161c26; + --surface-1: #1b212c; + --surface-2: #20283a; + --bg: #0f141c; + --card: #161c26; + --text: #e6ebf2; + --muted: #9aa7b8; + --border: #2a3340; + --outline: #2a3340; + --outline-variant: #222a36; + --danger: #f08a8e; + --on-danger: #2a0d10; + --ok: #4ade80; + --shadow-1: 0 1px 2px rgba(0, 0, 0, .40); + --shadow-2: 0 2px 8px rgba(0, 0, 0, .45); + --shadow-3: 0 8px 24px rgba(0, 0, 0, .50) +} + +* { + font-family: "Yozai Medium"; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; + margin: 0; + padding: 0 +} + +html { + scroll-behavior: smooth +} + +body { + background: var(--bg); + color: var(--text); + line-height: 1.6; + transition: background-color .3s, color .3s +} + +.container { + max-width: 1080px; + margin: 0 auto; + padding: 0 clamp(16px, 4vw, 60px) +} + +a { + color: inherit; + text-decoration: none +} + +img { + max-width: 100% +} + +.topbar { + height: 4px; + background: var(--primary) +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 18px; + border: none; + border-radius: var(--radius-full); + font-size: .95rem; + font-weight: 600; + cursor: pointer; + transition: transform .15s, box-shadow .2s, background .2s, color .2s; + font-family: inherit +} + +.btn-primary { + background: var(--primary); + color: var(--on-primary) +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-2), 0 8px 20px rgba(26, 115, 232, .30) +} + +.btn-ghost { + background: var(--secondary); + color: var(--text) +} + +.btn-ghost:hover { + background: var(--outline-variant) +} + +.site-header { + position: sticky; + top: 0; + z-index: 50; + background: var(--surface); + border-bottom: 1px solid var(--border) +} + +.site-header .nav { + display: flex; + align-items: center; + gap: 18px; + height: 64px; + position: relative +} + +.brand { + font-size: 1.25rem; + font-weight: 700; + color: var(--primary); + display: flex; + align-items: center; + gap: 8px +} + +.nav-links { + display: flex; + gap: 18px; + margin-left: 10px; + flex: 1 +} + +.nav-links a { + color: var(--text); + font-weight: 500; + padding: 6px 2px; + position: relative +} + +.nav-links a:hover, +.nav-links a.active { + color: var(--primary) +} + +.nav-actions { + display: flex; + align-items: center; + gap: 10px +} + +.theme-toggle { + width: 38px; + height: 38px; + border-radius: 50%; + border: 1px solid var(--outline); + background: var(--secondary); + color: var(--text); + cursor: pointer; + font-size: 1rem +} + +.theme-toggle:hover { + color: var(--primary) +} + +.lang-switch { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border-radius: var(--radius-full); + border: 1px solid var(--outline); + background: var(--secondary); + color: var(--text); + font-size: .82rem; + font-weight: 600; + cursor: pointer; + transition: transform .15s, box-shadow .2s, background .2s, color .2s; + text-decoration: none +} + +.lang-switch:hover { + color: var(--primary); + border-color: var(--primary); + transform: translateY(-1px) +} + +.lang-label { + font-size: .8rem +} + +.nav-toggle { + display: none; + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--outline); + background: var(--surface); + color: var(--text); + cursor: pointer; + font-size: 1.05rem; + align-items: center; + justify-content: center +} + +.badge { + background: var(--danger); + color: var(--on-danger); + font-size: .72rem; + border-radius: var(--radius-full); + padding: 1px 7px; + margin-left: 4px +} + +.main { + padding: 28px clamp(16px, 4vw, 60px) 50px +} + +.section { + margin-bottom: 30px +} + +.sec-title { + color: var(--primary); + font-size: 1.4rem; + margin-bottom: 18px; + display: flex; + align-items: center; + gap: 8px +} + +.empty { + color: var(--muted); + padding: 30px 0; + text-align: center +} + +.muted { + color: var(--muted) +} + +.form-err { + background: #fef2f2; + border: 1px solid #fecaca; + color: #b91c1c; + padding: 10px 14px; + border-radius: var(--radius-sm); + margin-bottom: 14px; + font-size: .92rem +} + +[data-theme="dark"] .form-err { + background: #3a1d1d; + border-color: #5b2a2a; + color: #fca5a5 +} + +.banner-ok { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #166534; + padding: 10px 14px; + border-radius: var(--radius-sm); + margin-bottom: 14px +} + +[data-theme="dark"] .banner-ok { + background: #11271a; + border-color: #1f4a32; + color: #86efac +} + +.hero { + background: var(--primary); + color: var(--on-primary); + padding: 56px 20px; + text-align: center; + margin-bottom: 26px; + border-radius: 12px +} + +.hero-content h1 { + font-size: 2.4rem; + margin-bottom: 8px +} + +.hero-content p { + opacity: .92; + margin-bottom: 22px; + font-size: 1.05rem +} + +.search-bar { + display: flex; + align-items: center; + gap: 8px; + max-width: 520px; + margin: 0 auto; + background: var(--surface); + padding: 8px 8px 8px 16px; + border-radius: var(--radius-full); + box-shadow: var(--shadow-2) +} + +.search-bar i { + color: var(--muted) +} + +.search-bar .btn i, +.hero .search-bar .btn i { + color: inherit +} + +.search-bar input { + flex: 1; + border: none; + outline: none; + font-size: 1rem; + background: transparent; + color: var(--text) +} + +.search-bar .btn { + border-radius: var(--radius-full) +} + +.cat-chips { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 22px +} + +.chip { + padding: 7px 16px; + border-radius: var(--radius-full); + background: var(--surface); + border: 1px solid var(--outline); + color: var(--muted); + font-size: .92rem; + transition: .2s +} + +.chip:hover { + color: var(--primary); + border-color: var(--primary) +} + +.chip.active { + background: var(--primary); + color: var(--on-primary); + border-color: transparent +} + +.chip i { + margin-right: 6px +} + +.product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 20px +} + +.product-card { + background: var(--surface); + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--outline); + transition: transform .2s, box-shadow .2s; + display: flex; + flex-direction: column +} + +.product-card:hover { + transform: translateY(-5px); + box-shadow: var(--shadow-2) +} + +.pc-media { + height: 150px; + display: flex; + align-items: center; + justify-content: center; + background: var(--secondary); + color: var(--primary); + font-size: 3rem; + overflow: hidden +} + +.pc-media img { + width: 100%; + height: 100%; + object-fit: cover +} + +.pc-body { + padding: 16px; + flex: 1; + display: flex; + flex-direction: column; + gap: 6px +} + +.pc-cat { + font-size: .78rem; + color: var(--accent); + font-weight: 600 +} + +.pc-name { + font-size: 1.05rem +} + +.pc-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: auto +} + +.pc-price { + color: var(--danger); + font-weight: 700; + font-size: 1.1rem +} + +.pc-stock { + font-size: .8rem; + color: var(--muted) +} + +.back-link { + display: inline-flex; + gap: 6px; + color: var(--muted); + margin-bottom: 16px +} + +.back-link:hover { + color: var(--primary) +} + +.product-detail { + display: grid; + grid-template-columns: 300px 1fr; + gap: 30px; + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + padding: 28px +} + +.pd-media { + height: 280px; + border-radius: var(--radius-md); + background: var(--secondary); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + font-size: 5rem; + overflow: hidden +} + +.pd-media img { + width: 100%; + height: 100%; + object-fit: cover +} + +.pd-name { + font-size: 1.7rem; + margin: 6px 0 +} + +.pd-price { + color: var(--danger); + font-weight: 700; + font-size: 1.6rem +} + +.pd-stock { + color: var(--muted); + margin-bottom: 12px +} + +.pd-desc { + color: var(--text); + white-space: pre-wrap; + margin-bottom: 18px +} + +.pd-buy { + display: flex; + align-items: flex-end; + gap: 14px; + flex-wrap: wrap +} + +.pd-buy label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: .9rem; + color: var(--muted) +} + +.qty-input { + width: 90px; + padding: 9px 10px; + border: 2px solid var(--outline); + border-radius: var(--radius-sm); + font-size: 1rem; + background: var(--bg); + color: var(--text) +} + +.cart-form { + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + padding: 22px +} + +.cart-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 18px +} + +.cart-item { + display: grid; + grid-template-columns: 70px 1fr 90px 90px 36px; + align-items: center; + gap: 12px; + padding: 12px; + border: 1px solid var(--outline); + border-radius: var(--radius-md) +} + +.ci-media { + width: 70px; + height: 70px; + border-radius: var(--radius-sm); + background: var(--secondary); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.6rem; + overflow: hidden +} + +.ci-media img { + width: 100%; + height: 100%; + object-fit: cover +} + +.ci-name { + font-weight: 600 +} + +.ci-price { + color: var(--muted); + font-size: .9rem +} + +.ci-qty input { + width: 70px; + padding: 7px; + border: 2px solid var(--outline); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text) +} + +.ci-sub { + font-weight: 700 +} + +.ci-del { + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 1.1rem +} + +.ci-del:hover { + color: var(--danger) +} + +.cart-foot { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + border-top: 1px solid var(--outline); + padding-top: 16px +} + +.cart-total { + font-size: 1.1rem +} + +.cart-total strong { + color: var(--danger); + font-size: 1.4rem +} + +.cart-actions { + display: flex; + gap: 10px; + flex-wrap: wrap +} + +.ci-info { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px +} + +.uc-id { + display: flex; + flex-direction: column; + min-width: 0 +} + +.ann-strip-title { + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap +} + +.pd-info { + min-width: 0 +} + +.sold-out { + display: inline-block; + background: var(--secondary); + color: var(--muted); + padding: 4px 14px; + border-radius: var(--radius-full); + font-size: .9rem; + font-weight: 600 +} + +.checkout { + display: grid; + grid-template-columns: 360px 1fr; + gap: 24px; + align-items: start +} + +.co-items, +.co-form { + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + padding: 22px +} + +.co-items h3, +.co-form h3 { + color: var(--primary); + font-size: 1.15rem; + margin-bottom: 16px +} + +.co-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + border-bottom: 1px dashed var(--outline-variant); + font-size: .95rem +} + +.co-row:last-of-type { + border-bottom: none +} + +.co-total { + padding-top: 14px; + margin-top: 6px; + border-top: 2px solid var(--outline); + font-size: 1.1rem; + font-weight: 700 +} + +.co-total strong { + color: var(--danger); + font-size: 1.3rem +} + +.co-form label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: .9rem; + color: var(--muted); + margin-bottom: 14px +} + +.co-form input { + padding: 11px 13px; + border: 2px solid var(--outline); + border-radius: var(--radius-sm); + font-size: 1rem; + background: var(--bg); + color: var(--text); + font-family: inherit +} + +.co-form input:focus { + outline: none; + border-color: var(--primary) +} + +.co-tip { + background: var(--primary-container); + color: var(--on-primary-container); + padding: 12px 14px; + border-radius: var(--radius-md); + font-size: .88rem; + line-height: 1.6; + margin: 4px 0 16px +} + +.auth-section { + display: flex; + justify-content: center +} + +.auth-card { + width: 100%; + max-width: 420px; + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + padding: 30px; + margin-top: 20px; + box-shadow: var(--shadow-2) +} + +.auth-tabs { + display: flex; + gap: 8px; + margin-bottom: 20px +} + +.auth-tabs a { + flex: 1; + text-align: center; + padding: 10px; + border-radius: var(--radius-full); + background: var(--secondary); + color: var(--muted); + font-weight: 600 +} + +.auth-tabs a.active { + background: var(--primary); + color: var(--on-primary) +} + +.auth-form label { + display: block; + margin-bottom: 14px; + font-size: .9rem; + color: var(--muted) +} + +.auth-form input { + width: 100%; + margin-top: 6px; + padding: 11px 13px; + border: 2px solid var(--outline); + border-radius: var(--radius-sm); + font-size: 1rem; + background: var(--bg); + color: var(--text) +} + +.auth-form .btn { + width: 100%; + margin-top: 6px; + justify-content: center +} + +.form-tip { + font-size: .82rem; + color: var(--muted); + margin-top: 6px; + line-height: 1.5 +} + +.captcha-box { + display: flex; + gap: 12px; + align-items: flex-end; + flex-wrap: wrap +} + +.captcha-img { + height: 44px; + border-radius: var(--radius-sm); + border: 1px solid var(--outline); + cursor: pointer; + background: var(--surface-2); + display: block +} + +.captcha-box label { + flex: 1; + min-width: 150px +} + +.order-list { + display: flex; + flex-direction: column; + gap: 14px +} + +.order-card { + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-md); + padding: 18px +} + +.oc-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px +} + +.oc-no { + font-weight: 700 +} + +.oc-items { + border-top: 1px dashed var(--outline-variant); + border-bottom: 1px dashed var(--outline-variant); + padding: 10px 0 +} + +.oc-row { + display: flex; + justify-content: space-between; + font-size: .94rem; + padding: 3px 0 +} + +.oc-foot { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 10px; + gap: 10px; + flex-wrap: wrap +} + +.oc-time { + color: var(--muted); + font-size: .85rem +} + +.oc-detail { + color: var(--primary); + font-weight: 600; + font-size: .9rem; + display: inline-flex; + align-items: center; + gap: 4px +} + +.oc-detail:hover { + text-decoration: underline +} + +.oc-total strong { + color: var(--danger); + font-size: 1.15rem +} + +.oc-addr { + margin-top: 8px; + font-size: .85rem; + color: var(--muted) +} + +.badge-status { + display: inline-block; + padding: 2px 10px; + border-radius: var(--radius-full); + font-size: .8rem; + font-weight: 600 +} + +.status-pending { + background: #fef3c7; + color: #92670a +} + +.status-paid { + background: #dbeafe; + color: #1e40af +} + +.status-shipped { + background: #e0e7ff; + color: #3730a3 +} + +.status-completed { + background: #dcfce7; + color: #166534 +} + +.status-cancelled { + background: #f3f4f6; + color: #6b7280 +} + +[data-theme="dark"] .status-pending { + background: #3a2f0a; + color: #fde68a +} + +[data-theme="dark"] .status-paid { + background: #1e2a4a; + color: #93c5fd +} + +[data-theme="dark"] .status-shipped { + background: #1e1e4a; + color: #c7d2fe +} + +[data-theme="dark"] .status-completed { + background: #11271a; + color: #86efac +} + +[data-theme="dark"] .status-cancelled { + background: #2a2f37; + color: #9aa0a6 +} + +.verify-banner { + display: flex; + align-items: center; + gap: 10px; + margin: 16px auto 0; + max-width: 1080px; + padding: 12px 16px; + border-radius: var(--radius-md); + font-size: .92rem +} + +.verify-banner.ok { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #166534 +} + +.verify-banner.warn { + background: #fffbeb; + border: 1px solid #fde68a; + color: #92670a +} + +.verify-banner a { + color: inherit; + text-decoration: underline; + font-weight: 600 +} + +[data-theme="dark"] .verify-banner.ok { + background: #11271a; + border-color: #1f4a32; + color: #86efac +} + +[data-theme="dark"] .verify-banner.warn { + background: #2a230a; + border-color: #5b4a16; + color: #fde68a +} + +.verify-card { + text-align: center +} + +.verify-icon { + width: 64px; + height: 64px; + border-radius: 50%; + margin: 0 auto 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.8rem +} + +.verify-icon.ok { + background: #dcfce7; + color: #16a34a +} + +.verify-icon.bad { + background: #fef3c7; + color: #d97706 +} + +[data-theme="dark"] .verify-icon.ok { + background: #11271a; + color: #4ade80 +} + +[data-theme="dark"] .verify-icon.bad { + background: #2a230a; + color: #fbbf24 +} + +.verify-title { + color: var(--text); + font-size: 1.4rem; + margin-bottom: 8px +} + +.verify-msg { + color: var(--muted); + margin-bottom: 18px +} + +.verify-actions { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap +} + +.order-detail { + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + padding: 22px +} + +.od-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + gap: 12px +} + +.od-no { + font-weight: 700; + font-size: 1.05rem +} + +.od-time { + color: var(--muted); + font-size: .85rem; + margin-top: 2px +} + +.od-items { + border-top: 1px solid var(--outline-variant); + border-bottom: 1px solid var(--outline-variant); + padding: 8px 0 +} + +.od-th, +.od-row { + display: grid; + grid-template-columns: 1fr 96px 64px 110px; + gap: 10px; + align-items: center; + padding: 9px 4px +} + +.od-th { + color: var(--muted); + font-size: .82rem; + font-weight: 600; + border-bottom: 1px solid var(--outline-variant) +} + +.od-row { + border-bottom: 1px dashed var(--outline-variant) +} + +.od-row:last-child { + border-bottom: none +} + +.od-name { + font-weight: 500 +} + +.od-sub { + font-weight: 700; + text-align: right +} + +.od-info { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin: 16px 0 +} + +.od-block { + background: var(--surface-1); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + padding: 14px +} + +.od-label { + font-size: .8rem; + color: var(--muted); + margin-bottom: 6px; + font-weight: 600 +} + +.od-line { + font-size: .94rem; + line-height: 1.6 +} + +.od-foot { + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--outline-variant); + padding-top: 14px; + gap: 10px; + flex-wrap: wrap +} + +.od-total strong { + color: var(--danger); + font-size: 1.2rem +} + +.settings-form { + max-width: 560px +} + +.verify-panel { + max-width: 560px; + margin-top: 20px +} + +.verify-panel a { + text-decoration: underline; + font-weight: 600 +} + +.ok-line { + color: #166534; + display: flex; + gap: 8px; + align-items: center +} + +.warn-line { + color: #92670a; + display: flex; + gap: 8px; + align-items: center +} + +[data-theme="dark"] .ok-line { + color: #86efac +} + +[data-theme="dark"] .warn-line { + color: #fde68a +} + +.user-menu { + position: relative; + flex: none +} + +.avatar { + width: 38px; + height: 38px; + border-radius: 50%; + border: none; + cursor: pointer; + background: var(--primary-container); + color: var(--on-primary-container); + font-weight: 700; + font-size: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + transition: box-shadow .2s, transform .15s +} + +.avatar:hover { + box-shadow: var(--shadow-2); + transform: translateY(-1px) +} + +.avatar-caret { + width: 22px; + height: 22px; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + flex: none +} + +.avatar-caret:hover { + background: var(--primary-container); + color: var(--on-primary-container) +} + +.avatar-caret .fas { + font-size: 12px +} + +.user-card { + position: absolute; + right: 0; + top: calc(100% + 8px); + width: 252px; + z-index: 60; + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-3); + padding: 14px +} + +.user-card[hidden] { + display: none !important +} + +.user-card:not([hidden]) { + display: flex; + flex-direction: column; + animation: uc-pop .16s ease +} + +@keyframes uc-pop { + from { + opacity: .6; + transform: translateY(-6px) + } + + to { + opacity: 1; + transform: none + } +} + +.uc-head { + display: flex; + gap: 12px; + align-items: center; + padding-bottom: 12px; + border-bottom: 1px solid var(--outline-variant); + margin-bottom: 8px +} + +.uc-avatar { + width: 46px; + height: 46px; + border-radius: 50%; + background: var(--primary-container); + color: var(--on-primary-container); + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + flex: none +} + +.uc-name { + font-weight: 700; + color: var(--text) +} + +.uc-handle { + font-size: .82rem; + color: var(--muted) +} + +.uc-badge { + display: inline-block; + margin-top: 4px; + font-size: .72rem; + padding: 1px 8px; + border-radius: var(--radius-full) +} + +.uc-badge.verified { + background: #dcfce7; + color: #166534 +} + +.uc-badge.unverified { + background: #fef3c7; + color: #92670a +} + +[data-theme="dark"] .uc-badge.verified { + background: #11271a; + color: #86efac +} + +[data-theme="dark"] .uc-badge.unverified { + background: #3a2f0a; + color: #fde68a +} + +.uc-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border-radius: var(--radius-sm); + color: var(--text); + font-size: .94rem; + margin: 2px 0 +} + +.uc-item .fas { + width: 22px; + text-align: center; + font-size: 1rem; + color: var(--muted); + flex: none +} + +.uc-item:hover { + background: var(--secondary) +} + +.uc-item.danger { + color: var(--danger) +} + +.uc-item.danger .fas { + color: var(--danger) +} + +.uc-item.danger:hover { + background: rgba(229, 72, 77, .12) +} + +.uc-points { + font-size: .92rem; + color: var(--muted); + padding: 0 4px 10px; + border-bottom: 1px solid var(--outline-variant); + margin-bottom: 8px +} + +.uc-points strong { + color: var(--primary); + font-size: 1.05rem +} + +.pd-points { + font-size: .98rem; + color: var(--muted); + margin-top: 4px +} + +.pd-points strong { + color: var(--primary); + font-size: 1.1rem +} + +.pd-stock { + font-size: .9rem; + color: var(--muted); + margin-top: 4px +} + +.sign-card { + max-width: 520px; + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + padding: 24px; + margin-top: 8px +} + +.sign-points { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + background: var(--secondary); + border-radius: var(--radius-md); + padding: 18px; + margin-bottom: 16px +} + +.sp-num { + font-size: 2.4rem; + font-weight: 800; + color: var(--primary); + line-height: 1 +} + +.sp-label { + font-size: .85rem; + color: var(--muted) +} + +.sign-tip { + margin-bottom: 16px +} + +.sign-tip strong { + color: var(--primary) +} + +.sign-form { + margin-top: 4px +} + +.invite-card { + max-width: 680px; + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + padding: 22px; + margin-top: 8px +} + +.invite-head { + display: flex; + flex-wrap: wrap; + gap: 12px 24px; + margin-bottom: 18px +} + +.invite-reward { + font-size: .95rem +} + +.invite-reward strong, +.invite-earned strong { + color: var(--primary) +} + +.invite-earned { + font-size: .9rem; + color: var(--muted) +} + +.invite-code-box, +.invite-link-box { + margin-bottom: 16px +} + +.ic-label { + font-size: .85rem; + color: var(--muted); + margin-bottom: 6px; + font-weight: 600 +} + +.ic-code { + display: inline-block; + font-size: 1.4rem; + font-weight: 800; + letter-spacing: 2px; + color: var(--primary); + background: var(--secondary); + padding: 8px 16px; + border-radius: var(--radius-sm); + margin-right: 10px +} + +.invite-link-row { + display: flex; + gap: 8px +} + +.invite-link-row input { + flex: 1 +} + +.invite-tip { + font-size: .85rem +} + +.co-pay { + margin: 8px 0; + padding: 14px; + background: var(--surface-1); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md) +} + +.co-pay h3 { + margin-bottom: 10px +} + +.pay-opt { + display: flex; + align-items: center; + gap: 8px; + font-size: .95rem; + margin: 6px 0; + color: var(--text) +} + +.pay-opt input { + width: 16px; + height: 16px +} + +.pay-opt input:disabled+* { + color: var(--muted) +} + +.pay-cash { + color: var(--muted) +} + +.pay-points { + color: #16a34a; + font-weight: 600 +} + +[data-theme="dark"] .pay-points { + color: #4ade80 +} + +.site-footer { + background: var(--surface); + border-top: 1px solid var(--outline); + margin-top: 30px +} + +.footer-inner { + display: flex; + flex-wrap: wrap; + gap: 24px; + justify-content: space-between; + padding: 30px 20px +} + +.footer-brand { + font-size: 1.1rem; + font-weight: 700; + color: var(--primary); + display: flex; + flex-direction: column; + gap: 4px +} + +.footer-brand p { + font-size: .85rem; + color: var(--muted); + font-weight: 400 +} + +.footer-links { + display: flex; + flex-wrap: wrap; + gap: 14px; + align-items: center +} + +.footer-links a { + color: var(--muted) +} + +.footer-links a:hover { + color: var(--primary) +} + +.footer-copy { + text-align: center; + padding: 14px; + color: var(--muted); + font-size: .85rem; + border-top: 1px solid var(--outline) +} + +.admin-bar { + position: sticky; + top: 0; + z-index: 50; + background: var(--primary); + color: var(--on-primary) +} + +.admin-bar .nav { + height: 60px; + display: flex; + align-items: center; + gap: 16px +} + +.admin-bar .brand { + color: var(--on-primary) +} + +.admin-bar .nav-links a { + color: rgba(255, 255, 255, .85); + font-weight: 500 +} + +.admin-bar .nav-links a:hover, +.admin-bar .nav-links a.active { + color: #fff +} + +.admin-bar .nav-actions { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto +} + +.admin-user { + font-size: .9rem +} + +.admin-bar .btn-ghost { + background: rgba(255, 255, 255, .15); + color: #fff +} + +.admin-bar .btn-ghost:hover { + background: rgba(255, 255, 255, .28) +} + +.admin-main { + padding: 28px 10px 50px +} + +.admin-footer { + text-align: center; + padding: 20px; + color: var(--muted); + font-size: .85rem; + border-top: 1px solid var(--outline) +} + +/* 深色模式下后台顶栏:用深色底 + 浅色字,避免浅蓝主色背景上白字看不清 */ +[data-theme="dark"] .admin-bar { + background: #11161f; + color: #e6ebf2; + border-bottom: 1px solid rgba(255, 255, 255, .08) +} +[data-theme="dark"] .admin-bar .brand, +[data-theme="dark"] .admin-bar .nav-links a { + color: rgba(255, 255, 255, .82) +} +[data-theme="dark"] .admin-bar .nav-links a:hover, +[data-theme="dark"] .admin-bar .nav-links a.active { + color: #fff +} +[data-theme="dark"] .admin-user { + color: #d6e4ff +} +[data-theme="dark"] .admin-bar .btn-ghost { + background: rgba(255, 255, 255, .12); + color: #fff +} +[data-theme="dark"] .admin-bar .btn-ghost:hover { + background: rgba(255, 255, 255, .22) +} + +/* ===== 后台全局深色模式补全 ===== */ +[data-theme="dark"] .admin-main .panel { + background: var(--card); + border-color: var(--border) +} + +[data-theme="dark"] .admin-main .panel h3 { + color: var(--text) +} + +[data-theme="dark"] .admin-main .data-table th { + background: var(--surface-2); + color: var(--muted); + border-bottom-color: var(--border) +} + +[data-theme="dark"] .admin-main .data-table td { + border-bottom-color: var(--outline-variant) +} + +[data-theme="dark"] .admin-main .data-table tr:hover td { + background: var(--surface-1) +} + +[data-theme="dark"] .admin-main .data-table { + color: var(--text) +} + +[data-theme="dark"] .admin-main .grid-form input, +[data-theme="dark"] .admin-main .grid-form select, +[data-theme="dark"] .admin-main .grid-form textarea { + background: var(--surface-1); + border-color: var(--border); + color: var(--text) +} + +[data-theme="dark"] .admin-main .grid-form input:focus, +[data-theme="dark"] .admin-main .grid-form select:focus, +[data-theme="dark"] .admin-main .grid-form textarea:focus { + border-color: var(--primary) +} + +[data-theme="dark"] .admin-main .grid-form label { + color: var(--muted) +} + +[data-theme="dark"] .admin-main .filter-bar select { + background: var(--surface-1); + border-color: var(--border); + color: var(--text) +} + +[data-theme="dark"] .admin-main .auth-form input { + background: var(--surface-1); + border-color: var(--border); + color: var(--text) +} + +[data-theme="dark"] .admin-main .auth-form label { + color: var(--muted) +} + +[data-theme="dark"] .admin-main .pagination a, +[data-theme="dark"] .admin-main .pagination span { + background: var(--surface-1); + border-color: var(--border); + color: var(--text) +} + +[data-theme="dark"] .admin-main .pagination a:hover { + background: var(--surface-2); + border-color: var(--primary); + color: var(--primary) +} + +[data-theme="dark"] .admin-main .pagination .active { + background: var(--primary); + color: var(--on-primary); + border-color: var(--primary) +} + +[data-theme="dark"] .admin-main .mini-btn { + background: var(--surface-1); + border-color: var(--border); + color: var(--text) +} + +[data-theme="dark"] .admin-main .mini-btn:hover { + background: var(--surface-2); + border-color: var(--primary); + color: var(--primary) +} + +[data-theme="dark"] .admin-main .mini-btn.danger:hover { + background: rgba(229, 72, 77, .15); + border-color: var(--danger); + color: var(--danger) +} + +[data-theme="dark"] .admin-main .modal-overlay { + background: rgba(0, 0, 0, .65) +} + +[data-theme="dark"] .admin-main .modal-box { + background: var(--card); + border-color: var(--border) +} + +[data-theme="dark"] .admin-main .modal-head h3 { + color: var(--text) +} + +[data-theme="dark"] .admin-main .modal-body { + color: var(--text) +} + +[data-theme="dark"] .admin-main .modal-close { + background: var(--surface-1); + color: var(--muted) +} + +[data-theme="dark"] .admin-main .modal-close:hover { + background: var(--outline-variant); + color: var(--text) +} + +[data-theme="dark"] .admin-main .form-err { + background: #3a1d1d; + border-color: #5b2a2a; + color: #fca5a5 +} + +[data-theme="dark"] .admin-main .banner-ok { + background: #11271a; + border-color: #1f4a32; + color: #86efac +} + +[data-theme="dark"] .admin-main .stat-card { + background: var(--card); + border-color: var(--border) +} + +[data-theme="dark"] .admin-main .stat-label { + color: var(--muted) +} + +[data-theme="dark"] .admin-main .page-title { + color: var(--primary) +} + +[data-theme="dark"] .admin-main .cell-status select { + background: var(--surface-1); + border-color: var(--border); + color: var(--text) +} + +[data-theme="dark"] .admin-footer { + border-top-color: var(--border) +} + +[data-theme="dark"] .admin-main code, +[data-theme="dark"] .admin-main pre { + background: var(--surface-2); + color: var(--text) +} + +[data-theme="dark"] .admin-main .smtp-log { + background: var(--surface-1); + border-color: var(--border) +} + +.page-title { + font-size: 1.6rem; + margin-bottom: 22px; + display: flex; + align-items: center; + gap: 8px; + color: var(--primary) +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 18px; + margin-bottom: 30px +} + +.stat-card { + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + padding: 24px; + text-align: center +} + +.stat-card.warn { + border-color: #fcd34d +} + +.stat-num { + font-size: 2.1rem; + font-weight: 800; + color: var(--primary) +} + +.stat-card.warn .stat-num { + color: #d97706 +} + +.stat-label { + color: var(--muted); + margin-top: 4px +} + +.panel { + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + padding: 22px; + margin-bottom: 24px +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + flex-wrap: wrap; + gap: 10px +} + +.panel h3 { + color: var(--text); + margin-bottom: 14px +} + +.grid-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px +} + +.grid-form label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: .9rem; + color: var(--muted) +} + +.grid-form input, +.grid-form select, +.grid-form textarea { + padding: 10px 12px; + border: 2px solid var(--outline); + border-radius: var(--radius-sm); + font-size: .98rem; + background: var(--bg); + color: var(--text); + font-family: inherit +} + +.grid-form .span2 { + grid-column: 1 / -1 +} + +.grid-form .checkbox { + flex-direction: row; + align-items: center; + gap: 8px; + grid-column: 1 / -1; + color: var(--text) +} + +.grid-form .form-actions { + grid-column: 1 / -1; + display: flex; + gap: 10px +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: .94rem +} + +.data-table th, +.data-table td { + text-align: left; + padding: 11px 12px; + border-bottom: 1px solid var(--outline) +} + +.data-table th { + color: var(--muted); + font-weight: 600; + background: var(--secondary) +} + +.data-table tr:hover td { + background: var(--secondary) +} + +.badge-on { + background: #dcfce7; + color: #166534; + padding: 2px 10px; + border-radius: var(--radius-full); + font-size: .82rem +} + +.badge-off { + background: #f3f4f6; + color: #6b7280; + padding: 2px 10px; + border-radius: var(--radius-full); + font-size: .82rem +} + +[data-theme="dark"] .badge-on { + background: #11271a; + color: #86efac +} + +[data-theme="dark"] .badge-off { + background: #2a2f37; + color: #9aa0a6 +} + +.row-actions { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-start; + vertical-align: middle +} + +.mini-btn { + width: 38px; + height: 38px; + border-radius: var(--radius-sm); + border: 1px solid var(--outline); + background: var(--secondary); + color: var(--text); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: .95rem; + transition: transform .15s, box-shadow .2s, background .2s, color .2s; + flex-shrink: 0; + text-decoration: none; + padding: 0; + line-height: 1 +} + +.mini-btn:hover { + color: var(--primary); + border-color: var(--primary) +} + +.mini-btn.danger:hover { + color: var(--danger); + border-color: var(--danger) +} + +/* 文字型操作按钮(如工单"处理"链接) */ +.mini-btn:has(:not(i)):not(:empty), +a.mini-btn:not(:has(i)) { + width: auto; + padding: 0 14px; + font-size: .88rem; + font-weight: 500; + border-radius: var(--radius-sm); + letter-spacing: .5px +} + +.cell-items div { + font-size: .86rem +} + +.cell-status .status-form { + display: flex; + gap: 6px; + align-items: center +} + +.cell-status select { + padding: 6px 8px; + border: 1px solid var(--outline); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text) +} + +.admin-login { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); + padding: 20px +} + +.admin-login .auth-card { + max-width: 380px; + text-align: center +} + +.al-title { + color: var(--primary); + font-size: 1.5rem +} + +.al-sub { + color: var(--muted); + margin-bottom: 18px +} + +.al-back { + display: inline-block; + margin-top: 16px; + color: var(--muted); + font-size: .88rem +} + +.al-back:hover { + color: var(--primary) +} + +.ticket-meta { + display: flex; + flex-wrap: wrap; + gap: 14px 22px; + align-items: center; + color: var(--muted); + font-size: .9rem +} + +.ticket-msg { + background: var(--secondary); + border-radius: var(--radius-md); + padding: 14px 16px; + white-space: pre-wrap; + line-height: 1.7 +} + +.ticket-reply { + margin-top: 16px; + border-left: 4px solid var(--primary); + background: var(--surface); + border-radius: var(--radius-sm); + padding: 12px 16px +} + +.reply-head { + font-weight: 600; + color: var(--primary); + margin-bottom: 8px +} + +.ticket-timeline { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 16px +} + +.ticket-bubble { + border-radius: var(--radius-md); + padding: 14px 16px; + max-width: 88%; + line-height: 1.7 +} + +.ticket-bubble .bubble-head { + font-weight: 600; + font-size: .88rem; + margin-bottom: 6px; + opacity: .85 +} + +.user-bubble { + align-self: flex-start; + background: var(--secondary); + border-left: 4px solid var(--primary) +} + +.admin-bubble { + align-self: flex-end; + background: var(--surface); + border-left: 4px solid #22c55e +} + +.admin-bubble .bubble-head { + color: #22c55e +} + +@media (max-width:600px) { + .ticket-bubble { + max-width: 100% + } +} + +.filter-bar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 16px +} + +.filter-bar select { + padding: 8px 10px; + border: 1px solid var(--outline); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text) +} + +.smtp-log { + margin-top: 14px; + background: var(--secondary); + border: 1px solid var(--outline); + border-radius: var(--radius-sm); + padding: 12px 14px +} + +.smtp-log pre { + white-space: pre-wrap; + word-break: break-all; + font-size: .82rem; + margin: 0; + color: var(--muted) +} + +.mini-btn.warn:hover { + color: #d97706; + border-color: #d97706 +} + +.mini-btn.ok:hover { + color: #16a34a; + border-color: #16a34a +} + +.status-open { + background: #dbeafe; + color: #1e40af +} + +.status-resolved { + background: #dcfce7; + color: #166534 +} + +.status-closed { + background: #f3f4f6; + color: #6b7280 +} + +[data-theme="dark"] .status-open { + background: #1e2a4a; + color: #93c5fd +} + +[data-theme="dark"] .status-resolved { + background: #11271a; + color: #86efac +} + +[data-theme="dark"] .status-closed { + background: #2a2f37; + color: #9aa0a6 +} + +.pd-period { + color: var(--accent); + font-weight: 600; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 6px +} + +.btn-sm { + padding: 7px 14px; + font-size: .85rem +} + +.btn-ok { + background: var(--ok); + color: #fff +} + +.btn-ok:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(22, 163, 74, .28) +} + +.oc-period { + margin-top: 8px; + font-size: .82rem; + color: var(--muted) +} + +.oc-renew { + margin-top: 8px +} + +.od-period { + font-style: normal; + color: var(--accent); + font-size: .85rem +} + +.od-delivery { + margin-top: 16px +} + +.od-renew { + margin-top: 16px; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap +} + +.secret { + font-family: 'Roboto Mono', 'Consolas', monospace; + letter-spacing: .5px +} + +.reveal-btn { + width: auto; + height: auto; + padding: 3px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--outline); + background: var(--secondary); + color: var(--text); + cursor: pointer; + font-size: .8rem +} + +.reveal-btn:hover { + color: var(--primary); + border-color: var(--primary) +} + +.mall-about { + margin-top: 8px +} + +.about-card { + background: var(--surface-1); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + padding: 18px 20px; + color: var(--text); + line-height: 1.8 +} + +.pd-purchased { + margin-top: 18px +} + +.pp-head { + display: flex; + align-items: center; + gap: 12px; + background: var(--secondary); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + padding: 14px 16px +} + +.pp-head .fas { + color: var(--primary); + font-size: 1.2rem +} + +.pp-title { + font-weight: 600 +} + +.pp-head .btn { + margin-left: auto +} + +.pp-detail { + margin-top: 12px; + background: var(--surface-1); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + padding: 14px 16px +} + +.od-info-block { + background: var(--secondary); + border-radius: var(--radius-sm); + padding: 8px 12px; + white-space: pre-wrap +} + +.ship-form { + margin-top: 18px; + max-width: 720px +} + +.ship-existing { + background: var(--surface-1); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + padding: 12px 14px; + margin-bottom: 4px +} + +.ship-existing .od-line { + font-size: .9rem +} + +.renew-box, +.ticket-order { + margin: 12px 0; + padding: 10px 14px; + border-left: 4px solid var(--primary); + background: var(--surface-1); + border-radius: var(--radius-sm); + color: var(--muted); + font-size: .9rem +} + +.renew-box i, +.ticket-order i { + color: var(--primary) +} + +.ann-strip-wrap { + margin-bottom: 6px +} + +.ann-strip { + display: flex; + align-items: center; + gap: 14px; + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-md); + padding: 12px 16px +} + +.ann-strip-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 700; + color: var(--primary); + flex: none +} + +.ann-strip-items { + display: flex; + gap: 18px; + overflow-x: auto; + flex: 1; + scrollbar-width: none; + -ms-overflow-style: none +} + +.ann-strip-items::-webkit-scrollbar { + display: none +} + +.ann-strip-item { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + white-space: nowrap; + font-size: .9rem +} + +.ann-strip-item:hover { + color: var(--primary) +} + +.ann-strip-date { + font-size: .78rem; + opacity: .8 +} + +.ann-strip-more { + flex: none; + color: var(--primary); + font-weight: 600; + font-size: .88rem; + white-space: nowrap +} + +.ann-pin { + color: var(--primary) +} + +.ann-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 18px +} + +.ann-card { + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + transition: box-shadow .15s, transform .12s +} + +.ann-card:hover { + box-shadow: var(--shadow-2); + transform: translateY(-2px) +} + +.ann-card-link { + display: block; + padding: 18px; + color: var(--text); + text-decoration: none +} + +.ann-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px +} + +.ann-title { + font-size: 1.05rem; + font-weight: 700; + color: var(--text) +} + +.ann-card-link:hover .ann-title { + color: var(--primary) +} + +.ann-pin { + font-size: .72rem; + padding: 1px 8px; + border-radius: var(--radius-full); + background: var(--primary-container); + color: var(--on-primary-container); + font-weight: 600 +} + +.ann-time { + color: var(--muted); + font-size: .8rem; + margin-bottom: 10px +} + +.ann-excerpt { + color: var(--muted); + font-size: .92rem; + line-height: 1.6; + margin: 0 0 12px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden +} + +.ann-read { + font-size: .88rem; + font-weight: 600; + color: var(--primary); + display: inline-flex; + align-items: center; + gap: 4px +} + +.ann-read i { + font-size: .72rem +} + +.ann-detail { + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + padding: 28px; + max-width: 800px; + margin: 0 auto +} + +.ann-detail-head { + border-bottom: 1px solid var(--outline-variant); + padding-bottom: 16px; + margin-bottom: 20px +} + +.ann-detail-title { + font-size: 1.45rem; + font-weight: 700; + line-height: 1.35; + margin: 0 0 10px +} + +.ann-detail-title .ann-pin { + margin-right: 8px +} + +.ann-detail-time { + color: var(--muted); + font-size: .88rem +} + +.ann-detail-body { + color: var(--text); + line-height: 1.9; + font-size: 1rem; + white-space: pre-wrap +} + +.ann-detail-foot { + margin-top: 28px; + padding-top: 18px; + border-top: 1px solid var(--outline-variant) +} + +.ann-preview-wrap { + margin-top: 8px +} + +.ann-preview-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin: 6px 0 +} + +.ann-preview-bar .btn-sm { + padding: 4px 12px; + font-size: 0.82rem +} + +.ann-preview { + min-height: 80px; + padding: 14px 18px; + border: 1px solid var(--outline-variant); + border-radius: 10px; + background: var(--surface-2, #f6f8fb) +} + +@media (max-width:780px) { + .checkout { + grid-template-columns: 1fr + } + + .nav-toggle { + display: inline-flex + } + + .brand { + flex: 1 + } + + .nav-links { + position: absolute; + top: 100%; + left: 0; + right: 0; + flex-direction: column; + gap: 0; + flex: none; + background: var(--surface); + border-bottom: 1px solid var(--outline); + box-shadow: var(--shadow-2); + padding: 6px 16px + } + + .nav-links.open { + display: flex + } + + .nav-links a { + padding: 13px 4px; + border-bottom: 1px solid var(--outline-variant) + } + + .nav-links a:last-child { + border-bottom: none + } + + .product-detail { + grid-template-columns: 1fr + } + + .pd-media { + height: 200px + } + + .cart-item { + grid-template-columns: 60px 1fr; + grid-auto-rows: auto + } + + .ci-qty, + .ci-sub, + .ci-del { + grid-column: 2; + justify-self: start + } + + .grid-form { + grid-template-columns: 1fr + } + + .od-info { + grid-template-columns: 1fr + } + + .od-th, + .od-row { + grid-template-columns: 1fr 70px 48px 84px; + font-size: .86rem + } + + .oc-foot { + flex-direction: column; + align-items: flex-start + } + + .ann-strip { + flex-wrap: wrap + } + + .od-renew { + flex-direction: column; + align-items: flex-start + } +} + +@media (max-width:780px) { + #navLinks { + display: none !important; + position: absolute !important; + top: 100% !important; + left: 0 !important; + right: 0 !important; + flex-direction: column !important; + gap: 0 !important; + background: var(--surface) !important; + border-bottom: 1px solid var(--outline) !important; + box-shadow: var(--shadow-2) !important; + padding: 6px 16px !important; + border-radius: 0 0 8px 8px !important; + z-index: 100 !important + } + + #navLinks.open { + display: flex !important; + animation: uc-pop .16s ease + } + + #navLinks a { + padding: 13px 4px !important; + border-bottom: 1px solid var(--outline-variant) !important; + color: var(--text) !important; + background: transparent !important; + border-radius: 0 !important + } + + #navLinks a:last-child { + border-bottom: none !important + } + + #navLinks a:hover, + #navLinks a.active { + color: var(--primary) !important; + background: var(--secondary) !important + } +} + +@media (max-width:780px) { + .admin-bar .nav-links { + display: none !important; + position: absolute !important; + top: 100% !important; + left: 0 !important; + right: 0 !important; + flex-direction: column !important; + gap: 0 !important; + background: var(--surface) !important; + border-bottom: 1px solid var(--outline) !important; + box-shadow: var(--shadow-2) !important; + padding: 6px 16px !important; + border-radius: 0 0 8px 8px !important; + z-index: 100 !important + } + + .admin-bar .nav-links.open { + display: flex !important; + animation: uc-pop .16s ease + } + + .admin-bar .nav-links a { + padding: 13px 4px !important; + border-bottom: 1px solid var(--outline-variant) !important; + color: var(--text) !important; + background: transparent !important; + border-radius: 0 !important + } + + .admin-bar .nav-links a:last-child { + border-bottom: none !important + } + + .admin-bar .nav-links a:hover, + .admin-bar .nav-links a.active { + color: var(--primary) !important; + background: var(--secondary) !important + } +} + +@media (min-width:781px) { + .nav-toggle { + display: none !important + } +} + +@media (max-width:780px) { + .nav-toggle { + display: inline-flex !important + } + + .site-header .nav, + .admin-bar .nav { + position: relative !important + } +} + +@media (max-width:520px) { + .hero-content h1 { + font-size: 1.8rem + } + + .footer-inner { + flex-direction: column + } + + .auth-card { + padding: 22px 18px + } + + .user-card { + position: fixed; + left: 12px; + right: 12px; + width: auto; + top: 64px; + max-width: none + } + + .uc-head { + flex-wrap: nowrap + } + + .uc-id { + min-width: 0 + } + + .uc-name, + .uc-handle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap + } + + .uc-points { + white-space: nowrap + } + + .uc-item { + white-space: nowrap; + display: flex; + align-items: center; + gap: 10px + } + + .uc-item .fas { + width: 20px + } + + .ann-list { + grid-template-columns: 1fr + } + + .ann-detail { + padding: 18px + } + + .ann-detail-title { + font-size: 1.2rem + } +} + +.ann-detail-body, +.pd-desc { + line-height: 1.8; + color: var(--text) +} + +.ann-detail-body h1, +.pd-desc h1, +.ann-detail-body h2, +.pd-desc h2, +.ann-detail-body h3, +.pd-desc h3, +.ann-detail-body h4, +.pd-desc h4, +.ann-detail-body h5, +.pd-desc h5, +.ann-detail-body h6, +.pd-desc h6 { + margin: 1.4rem 0 0.6rem; + font-weight: 700; + line-height: 1.4 +} + +.ann-detail-body h1, +.pd-desc h1 { + font-size: 1.8rem +} + +.ann-detail-body h2, +.pd-desc h2 { + font-size: 1.5rem +} + +.ann-detail-body h3, +.pd-desc h3 { + font-size: 1.25rem +} + +.ann-detail-body h4, +.pd-desc h4 { + font-size: 1.1rem +} + +.ann-detail-body h5, +.pd-desc h5 { + font-size: 1rem +} + +.ann-detail-body h6, +.pd-desc h6 { + font-size: 0.9rem; + color: var(--muted) +} + +.ann-detail-body p, +.pd-desc p { + margin: 0.6rem 0 +} + +.ann-detail-body strong, +.pd-desc strong { + font-weight: 700 +} + +.ann-detail-body em, +.pd-desc em { + font-style: italic +} + +.ann-detail-body a, +.pd-desc a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px +} + +.ann-detail-body a:hover, +.pd-desc a:hover { + color: var(--primary-hover) +} + +.ann-detail-body ul, +.ann-detail-body ol, +.pd-desc ul, +.pd-desc ol { + padding-left: 1.8rem; + margin: 0.5rem 0 +} + +.ann-detail-body li, +.pd-desc li { + margin: 0.2rem 0 +} + +.ann-detail-body ul ul, +.pd-desc ul ul, +.ann-detail-body ol ol, +.pd-desc ol ol, +.ann-detail-body ul ol, +.pd-desc ul ol, +.ann-detail-body ol ul, +.pd-desc ol ul { + margin: 0.2rem 0 +} + +.ann-detail-body blockquote, +.pd-desc blockquote { + border-left: 4px solid var(--primary); + background: var(--surface-1); + padding: 0.6rem 1.2rem; + margin: 0.8rem 0; + border-radius: var(--radius-sm); + color: var(--muted) +} + +.ann-detail-body code, +.pd-desc code { + background: var(--secondary); + padding: 0.1rem 0.4rem; + border-radius: 4px; + font-size: 0.9em; + color: var(--primary); + font-family: 'Consolas', 'Monaco', 'Courier New', monospace +} + +.ann-detail-body pre, +.pd-desc pre { + background: var(--surface-2); + padding: 1rem 1.2rem; + border-radius: var(--radius-md); + overflow-x: auto; + margin: 0.8rem 0; + border: 1px solid var(--outline-variant) +} + +.ann-detail-body pre code, +.pd-desc pre code { + background: transparent; + padding: 0; + color: var(--text); + font-size: 0.9rem; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace +} + +.ann-detail-body table, +.pd-desc table { + width: 100%; + border-collapse: collapse; + margin: 0.8rem 0; + font-size: 0.95rem +} + +.ann-detail-body th, +.pd-desc th, +.ann-detail-body td, +.pd-desc td { + border: 1px solid var(--outline); + padding: 8px 12px; + text-align: left +} + +.ann-detail-body th, +.pd-desc th { + background: var(--secondary); + font-weight: 700 +} + +.ann-detail-body tr:nth-child(even), +.pd-desc tr:nth-child(even) { + background: var(--surface-1) +} + +table { + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + white-space: nowrap; + max-width: 100%; + border-radius: var(--radius-sm) +} + +.ann-detail-body img, +.pd-desc img { + max-width: 100%; + border-radius: var(--radius-sm); + margin: 0.5rem 0 +} + +.ann-detail-body hr, +.pd-desc hr { + border: none; + border-top: 2px solid var(--outline-variant); + margin: 1.5rem 0 +} + +.ann-detail-body input[type="checkbox"], +.pd-desc input[type="checkbox"] { + margin-right: 6px; + vertical-align: middle; + accent-color: var(--primary) +} + +.pagination { + display: flex; + gap: 6px; + justify-content: center; + margin: 24px 0; + flex-wrap: wrap +} + +.pagination a, +.pagination span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + height: 36px; + padding: 0 12px; + border: 1px solid var(--outline); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-size: .9rem; + transition: .15s +} + +.pagination a:hover { + background: var(--secondary); + border-color: var(--primary); + color: var(--primary) +} + +.pagination .active { + background: var(--primary); + color: var(--on-primary); + border-color: var(--primary) +} + +.pagination .disabled { + opacity: .5; + pointer-events: none +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid var(--outline); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin .7s linear infinite; + margin: 30px auto +} + +@keyframes spin { + to { + transform: rotate(360deg) + } +} + +.loading-mask { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 0; + color: var(--muted); + gap: 10px +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .5); + backdrop-filter: blur(4px); + z-index: 999; + display: flex; + align-items: center; + justify-content: center; + padding: 20px +} + +.modal-overlay[hidden] { + display: none !important +} + +.modal-box { + background: var(--surface); + border: 1px solid var(--outline); + border-radius: var(--radius-lg); + max-width: 560px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + padding: 28px; + box-shadow: var(--shadow-3); + animation: modal-in .2s ease +} + +@keyframes modal-in { + from { + opacity: 0; + transform: scale(.95) translateY(10px) + } + + to { + opacity: 1; + transform: none + } +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px +} + +.modal-head h3 { + font-size: 1.2rem; + color: var(--text); + margin: 0 +} + +.modal-close { + width: 36px; + height: 36px; + border: none; + background: var(--secondary); + border-radius: 50%; + font-size: 1.2rem; + cursor: pointer; + color: var(--muted); + display: flex; + align-items: center; + justify-content: center +} + +.modal-close:hover { + background: var(--outline-variant); + color: var(--text) +} + +.modal-body { + color: var(--text); + line-height: 1.7 +} + +.modal-foot { + margin-top: 20px; + display: flex; + gap: 10px; + justify-content: flex-end +} + +/* 桌面端导航链接:干净的内联样式 */ +#navLinks { + display: flex; + gap: 18px; + margin-left: 10px; + flex: 1; + align-items: center +} + +/* ===== 到期时间徽标 ===== */ +.exp-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + border-radius: var(--radius-full); + font-size: .82rem; + font-weight: 600; + line-height: 1.6; + border: 1px solid transparent; + white-space: nowrap; + vertical-align: middle; +} +.exp-badge i { font-size: .8rem; } +.exp-ok { color: var(--ok); background: rgba(22,163,74,.12); border-color: rgba(22,163,74,.35); } +.exp-warn { color: #b7791f; background: #fff7e6; border-color: #f3d28a; } +.exp-danger { color: var(--danger);background: rgba(229,72,77,.12); border-color: rgba(229,72,77,.35); } +.exp-expired { color: var(--muted); background: var(--surface-2); border-color: var(--border); } +.exp-none { color: var(--muted); background: var(--surface-2); border-color: var(--border); } +[data-theme="dark"] .exp-warn { color: #f0c050; background: rgba(240,192,80,.14); border-color: rgba(240,192,80,.40); } + +/* ===== 后台:到期时间修改 ===== */ +.expire-edit { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed var(--outline-variant); +} +.expire-edit .expire-field { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: .9rem; + color: var(--muted); +} +.expire-edit input[type="datetime-local"] { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-size: .9rem; +} +.expire-edit .expire-clear { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: .9rem; + color: var(--muted); +} \ No newline at end of file diff --git a/assets/vendor/fontawesome/all.min.css b/assets/vendor/fontawesome/all.min.css new file mode 100644 index 0000000..ae8103a --- /dev/null +++ b/assets/vendor/fontawesome/all.min.css @@ -0,0 +1,9 @@ +/*! + * Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2024 Fonticons, Inc. + */ +.fa{font-family:var(--fa-style-family,"Font Awesome 6 Free");font-weight:var(--fa-style,900)}.fa,.fa-brands,.fa-classic,.fa-regular,.fa-sharp,.fa-solid,.fab,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:var(--fa-display,inline-block);font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}.fa-classic,.fa-regular,.fa-solid,.far,.fas{font-family:"Font Awesome 6 Free"}.fa-brands,.fab{font-family:"Font Awesome 6 Brands"}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{left:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);padding:var(--fa-border-padding,.2em .25em .15em)}.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.fa-beat{-webkit-animation-name:fa-beat;animation-name:fa-beat;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{-webkit-animation-name:fa-bounce;animation-name:fa-bounce;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{-webkit-animation-name:fa-fade;animation-name:fa-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{-webkit-animation-name:fa-beat-fade;animation-name:fa-beat-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{-webkit-animation-name:fa-flip;animation-name:fa-flip;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{-webkit-animation-name:fa-shake;animation-name:fa-shake;-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-duration:var(--fa-animation-duration,2s);animation-duration:var(--fa-animation-duration,2s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,steps(8));animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{-webkit-animation-delay:-1ms;animation-delay:-1ms;-webkit-animation-duration:1ms;animation-duration:1ms;-webkit-animation-iteration-count:1;animation-iteration-count:1;-webkit-transition-delay:0s;transition-delay:0s;-webkit-transition-duration:0s;transition-duration:0s}}@-webkit-keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@-webkit-keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@-webkit-keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@-webkit-keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@-webkit-keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@-webkit-keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}.fa-rotate-by{-webkit-transform:rotate(var(--fa-rotate-angle,0));transform:rotate(var(--fa-rotate-angle,0))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%;z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)} + +.fa-0:before{content:"\30"}.fa-1:before{content:"\31"}.fa-2:before{content:"\32"}.fa-3:before{content:"\33"}.fa-4:before{content:"\34"}.fa-5:before{content:"\35"}.fa-6:before{content:"\36"}.fa-7:before{content:"\37"}.fa-8:before{content:"\38"}.fa-9:before{content:"\39"}.fa-fill-drip:before{content:"\f576"}.fa-arrows-to-circle:before{content:"\e4bd"}.fa-chevron-circle-right:before,.fa-circle-chevron-right:before{content:"\f138"}.fa-at:before{content:"\40"}.fa-trash-alt:before,.fa-trash-can:before{content:"\f2ed"}.fa-text-height:before{content:"\f034"}.fa-user-times:before,.fa-user-xmark:before{content:"\f235"}.fa-stethoscope:before{content:"\f0f1"}.fa-comment-alt:before,.fa-message:before{content:"\f27a"}.fa-info:before{content:"\f129"}.fa-compress-alt:before,.fa-down-left-and-up-right-to-center:before{content:"\f422"}.fa-explosion:before{content:"\e4e9"}.fa-file-alt:before,.fa-file-lines:before,.fa-file-text:before{content:"\f15c"}.fa-wave-square:before{content:"\f83e"}.fa-ring:before{content:"\f70b"}.fa-building-un:before{content:"\e4d9"}.fa-dice-three:before{content:"\f527"}.fa-calendar-alt:before,.fa-calendar-days:before{content:"\f073"}.fa-anchor-circle-check:before{content:"\e4aa"}.fa-building-circle-arrow-right:before{content:"\e4d1"}.fa-volleyball-ball:before,.fa-volleyball:before{content:"\f45f"}.fa-arrows-up-to-line:before{content:"\e4c2"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-circle-minus:before,.fa-minus-circle:before{content:"\f056"}.fa-door-open:before{content:"\f52b"}.fa-right-from-bracket:before,.fa-sign-out-alt:before{content:"\f2f5"}.fa-atom:before{content:"\f5d2"}.fa-soap:before{content:"\e06e"}.fa-heart-music-camera-bolt:before,.fa-icons:before{content:"\f86d"}.fa-microphone-alt-slash:before,.fa-microphone-lines-slash:before{content:"\f539"}.fa-bridge-circle-check:before{content:"\e4c9"}.fa-pump-medical:before{content:"\e06a"}.fa-fingerprint:before{content:"\f577"}.fa-hand-point-right:before{content:"\f0a4"}.fa-magnifying-glass-location:before,.fa-search-location:before{content:"\f689"}.fa-forward-step:before,.fa-step-forward:before{content:"\f051"}.fa-face-smile-beam:before,.fa-smile-beam:before{content:"\f5b8"}.fa-flag-checkered:before{content:"\f11e"}.fa-football-ball:before,.fa-football:before{content:"\f44e"}.fa-school-circle-exclamation:before{content:"\e56c"}.fa-crop:before{content:"\f125"}.fa-angle-double-down:before,.fa-angles-down:before{content:"\f103"}.fa-users-rectangle:before{content:"\e594"}.fa-people-roof:before{content:"\e537"}.fa-people-line:before{content:"\e534"}.fa-beer-mug-empty:before,.fa-beer:before{content:"\f0fc"}.fa-diagram-predecessor:before{content:"\e477"}.fa-arrow-up-long:before,.fa-long-arrow-up:before{content:"\f176"}.fa-burn:before,.fa-fire-flame-simple:before{content:"\f46a"}.fa-male:before,.fa-person:before{content:"\f183"}.fa-laptop:before{content:"\f109"}.fa-file-csv:before{content:"\f6dd"}.fa-menorah:before{content:"\f676"}.fa-truck-plane:before{content:"\e58f"}.fa-record-vinyl:before{content:"\f8d9"}.fa-face-grin-stars:before,.fa-grin-stars:before{content:"\f587"}.fa-bong:before{content:"\f55c"}.fa-pastafarianism:before,.fa-spaghetti-monster-flying:before{content:"\f67b"}.fa-arrow-down-up-across-line:before{content:"\e4af"}.fa-spoon:before,.fa-utensil-spoon:before{content:"\f2e5"}.fa-jar-wheat:before{content:"\e517"}.fa-envelopes-bulk:before,.fa-mail-bulk:before{content:"\f674"}.fa-file-circle-exclamation:before{content:"\e4eb"}.fa-circle-h:before,.fa-hospital-symbol:before{content:"\f47e"}.fa-pager:before{content:"\f815"}.fa-address-book:before,.fa-contact-book:before{content:"\f2b9"}.fa-strikethrough:before{content:"\f0cc"}.fa-k:before{content:"\4b"}.fa-landmark-flag:before{content:"\e51c"}.fa-pencil-alt:before,.fa-pencil:before{content:"\f303"}.fa-backward:before{content:"\f04a"}.fa-caret-right:before{content:"\f0da"}.fa-comments:before{content:"\f086"}.fa-file-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-code-pull-request:before{content:"\e13c"}.fa-clipboard-list:before{content:"\f46d"}.fa-truck-loading:before,.fa-truck-ramp-box:before{content:"\f4de"}.fa-user-check:before{content:"\f4fc"}.fa-vial-virus:before{content:"\e597"}.fa-sheet-plastic:before{content:"\e571"}.fa-blog:before{content:"\f781"}.fa-user-ninja:before{content:"\f504"}.fa-person-arrow-up-from-line:before{content:"\e539"}.fa-scroll-torah:before,.fa-torah:before{content:"\f6a0"}.fa-broom-ball:before,.fa-quidditch-broom-ball:before,.fa-quidditch:before{content:"\f458"}.fa-toggle-off:before{content:"\f204"}.fa-archive:before,.fa-box-archive:before{content:"\f187"}.fa-person-drowning:before{content:"\e545"}.fa-arrow-down-9-1:before,.fa-sort-numeric-desc:before,.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-face-grin-tongue-squint:before,.fa-grin-tongue-squint:before{content:"\f58a"}.fa-spray-can:before{content:"\f5bd"}.fa-truck-monster:before{content:"\f63b"}.fa-w:before{content:"\57"}.fa-earth-africa:before,.fa-globe-africa:before{content:"\f57c"}.fa-rainbow:before{content:"\f75b"}.fa-circle-notch:before{content:"\f1ce"}.fa-tablet-alt:before,.fa-tablet-screen-button:before{content:"\f3fa"}.fa-paw:before{content:"\f1b0"}.fa-cloud:before{content:"\f0c2"}.fa-trowel-bricks:before{content:"\e58a"}.fa-face-flushed:before,.fa-flushed:before{content:"\f579"}.fa-hospital-user:before{content:"\f80d"}.fa-tent-arrow-left-right:before{content:"\e57f"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-binoculars:before{content:"\f1e5"}.fa-microphone-slash:before{content:"\f131"}.fa-box-tissue:before{content:"\e05b"}.fa-motorcycle:before{content:"\f21c"}.fa-bell-concierge:before,.fa-concierge-bell:before{content:"\f562"}.fa-pen-ruler:before,.fa-pencil-ruler:before{content:"\f5ae"}.fa-people-arrows-left-right:before,.fa-people-arrows:before{content:"\e068"}.fa-mars-and-venus-burst:before{content:"\e523"}.fa-caret-square-right:before,.fa-square-caret-right:before{content:"\f152"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-sun-plant-wilt:before{content:"\e57a"}.fa-toilets-portable:before{content:"\e584"}.fa-hockey-puck:before{content:"\f453"}.fa-table:before{content:"\f0ce"}.fa-magnifying-glass-arrow-right:before{content:"\e521"}.fa-digital-tachograph:before,.fa-tachograph-digital:before{content:"\f566"}.fa-users-slash:before{content:"\e073"}.fa-clover:before{content:"\e139"}.fa-mail-reply:before,.fa-reply:before{content:"\f3e5"}.fa-star-and-crescent:before{content:"\f699"}.fa-house-fire:before{content:"\e50c"}.fa-minus-square:before,.fa-square-minus:before{content:"\f146"}.fa-helicopter:before{content:"\f533"}.fa-compass:before{content:"\f14e"}.fa-caret-square-down:before,.fa-square-caret-down:before{content:"\f150"}.fa-file-circle-question:before{content:"\e4ef"}.fa-laptop-code:before{content:"\f5fc"}.fa-swatchbook:before{content:"\f5c3"}.fa-prescription-bottle:before{content:"\f485"}.fa-bars:before,.fa-navicon:before{content:"\f0c9"}.fa-people-group:before{content:"\e533"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-heart-broken:before,.fa-heart-crack:before{content:"\f7a9"}.fa-external-link-square-alt:before,.fa-square-up-right:before{content:"\f360"}.fa-face-kiss-beam:before,.fa-kiss-beam:before{content:"\f597"}.fa-film:before{content:"\f008"}.fa-ruler-horizontal:before{content:"\f547"}.fa-people-robbery:before{content:"\e536"}.fa-lightbulb:before{content:"\f0eb"}.fa-caret-left:before{content:"\f0d9"}.fa-circle-exclamation:before,.fa-exclamation-circle:before{content:"\f06a"}.fa-school-circle-xmark:before{content:"\e56d"}.fa-arrow-right-from-bracket:before,.fa-sign-out:before{content:"\f08b"}.fa-chevron-circle-down:before,.fa-circle-chevron-down:before{content:"\f13a"}.fa-unlock-alt:before,.fa-unlock-keyhole:before{content:"\f13e"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-headphones-alt:before,.fa-headphones-simple:before{content:"\f58f"}.fa-sitemap:before{content:"\f0e8"}.fa-circle-dollar-to-slot:before,.fa-donate:before{content:"\f4b9"}.fa-memory:before{content:"\f538"}.fa-road-spikes:before{content:"\e568"}.fa-fire-burner:before{content:"\e4f1"}.fa-flag:before{content:"\f024"}.fa-hanukiah:before{content:"\f6e6"}.fa-feather:before{content:"\f52d"}.fa-volume-down:before,.fa-volume-low:before{content:"\f027"}.fa-comment-slash:before{content:"\f4b3"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-compress:before{content:"\f066"}.fa-wheat-alt:before,.fa-wheat-awn:before{content:"\e2cd"}.fa-ankh:before{content:"\f644"}.fa-hands-holding-child:before{content:"\e4fa"}.fa-asterisk:before{content:"\2a"}.fa-check-square:before,.fa-square-check:before{content:"\f14a"}.fa-peseta-sign:before{content:"\e221"}.fa-header:before,.fa-heading:before{content:"\f1dc"}.fa-ghost:before{content:"\f6e2"}.fa-list-squares:before,.fa-list:before{content:"\f03a"}.fa-phone-square-alt:before,.fa-square-phone-flip:before{content:"\f87b"}.fa-cart-plus:before{content:"\f217"}.fa-gamepad:before{content:"\f11b"}.fa-circle-dot:before,.fa-dot-circle:before{content:"\f192"}.fa-dizzy:before,.fa-face-dizzy:before{content:"\f567"}.fa-egg:before{content:"\f7fb"}.fa-house-medical-circle-xmark:before{content:"\e513"}.fa-campground:before{content:"\f6bb"}.fa-folder-plus:before{content:"\f65e"}.fa-futbol-ball:before,.fa-futbol:before,.fa-soccer-ball:before{content:"\f1e3"}.fa-paint-brush:before,.fa-paintbrush:before{content:"\f1fc"}.fa-lock:before{content:"\f023"}.fa-gas-pump:before{content:"\f52f"}.fa-hot-tub-person:before,.fa-hot-tub:before{content:"\f593"}.fa-map-location:before,.fa-map-marked:before{content:"\f59f"}.fa-house-flood-water:before{content:"\e50e"}.fa-tree:before{content:"\f1bb"}.fa-bridge-lock:before{content:"\e4cc"}.fa-sack-dollar:before{content:"\f81d"}.fa-edit:before,.fa-pen-to-square:before{content:"\f044"}.fa-car-side:before{content:"\f5e4"}.fa-share-alt:before,.fa-share-nodes:before{content:"\f1e0"}.fa-heart-circle-minus:before{content:"\e4ff"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-microscope:before{content:"\f610"}.fa-sink:before{content:"\e06d"}.fa-bag-shopping:before,.fa-shopping-bag:before{content:"\f290"}.fa-arrow-down-z-a:before,.fa-sort-alpha-desc:before,.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-mitten:before{content:"\f7b5"}.fa-person-rays:before{content:"\e54d"}.fa-users:before{content:"\f0c0"}.fa-eye-slash:before{content:"\f070"}.fa-flask-vial:before{content:"\e4f3"}.fa-hand-paper:before,.fa-hand:before{content:"\f256"}.fa-om:before{content:"\f679"}.fa-worm:before{content:"\e599"}.fa-house-circle-xmark:before{content:"\e50b"}.fa-plug:before{content:"\f1e6"}.fa-chevron-up:before{content:"\f077"}.fa-hand-spock:before{content:"\f259"}.fa-stopwatch:before{content:"\f2f2"}.fa-face-kiss:before,.fa-kiss:before{content:"\f596"}.fa-bridge-circle-xmark:before{content:"\e4cb"}.fa-face-grin-tongue:before,.fa-grin-tongue:before{content:"\f589"}.fa-chess-bishop:before{content:"\f43a"}.fa-face-grin-wink:before,.fa-grin-wink:before{content:"\f58c"}.fa-deaf:before,.fa-deafness:before,.fa-ear-deaf:before,.fa-hard-of-hearing:before{content:"\f2a4"}.fa-road-circle-check:before{content:"\e564"}.fa-dice-five:before{content:"\f523"}.fa-rss-square:before,.fa-square-rss:before{content:"\f143"}.fa-land-mine-on:before{content:"\e51b"}.fa-i-cursor:before{content:"\f246"}.fa-stamp:before{content:"\f5bf"}.fa-stairs:before{content:"\e289"}.fa-i:before{content:"\49"}.fa-hryvnia-sign:before,.fa-hryvnia:before{content:"\f6f2"}.fa-pills:before{content:"\f484"}.fa-face-grin-wide:before,.fa-grin-alt:before{content:"\f581"}.fa-tooth:before{content:"\f5c9"}.fa-v:before{content:"\56"}.fa-bangladeshi-taka-sign:before{content:"\e2e6"}.fa-bicycle:before{content:"\f206"}.fa-rod-asclepius:before,.fa-rod-snake:before,.fa-staff-aesculapius:before,.fa-staff-snake:before{content:"\e579"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-ambulance:before,.fa-truck-medical:before{content:"\f0f9"}.fa-wheat-awn-circle-exclamation:before{content:"\e598"}.fa-snowman:before{content:"\f7d0"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-road-barrier:before{content:"\e562"}.fa-school:before{content:"\f549"}.fa-igloo:before{content:"\f7ae"}.fa-joint:before{content:"\f595"}.fa-angle-right:before{content:"\f105"}.fa-horse:before{content:"\f6f0"}.fa-q:before{content:"\51"}.fa-g:before{content:"\47"}.fa-notes-medical:before{content:"\f481"}.fa-temperature-2:before,.fa-temperature-half:before,.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-dong-sign:before{content:"\e169"}.fa-capsules:before{content:"\f46b"}.fa-poo-bolt:before,.fa-poo-storm:before{content:"\f75a"}.fa-face-frown-open:before,.fa-frown-open:before{content:"\f57a"}.fa-hand-point-up:before{content:"\f0a6"}.fa-money-bill:before{content:"\f0d6"}.fa-bookmark:before{content:"\f02e"}.fa-align-justify:before{content:"\f039"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-helmet-un:before{content:"\e503"}.fa-bullseye:before{content:"\f140"}.fa-bacon:before{content:"\f7e5"}.fa-hand-point-down:before{content:"\f0a7"}.fa-arrow-up-from-bracket:before{content:"\e09a"}.fa-folder-blank:before,.fa-folder:before{content:"\f07b"}.fa-file-medical-alt:before,.fa-file-waveform:before{content:"\f478"}.fa-radiation:before{content:"\f7b9"}.fa-chart-simple:before{content:"\e473"}.fa-mars-stroke:before{content:"\f229"}.fa-vial:before{content:"\f492"}.fa-dashboard:before,.fa-gauge-med:before,.fa-gauge:before,.fa-tachometer-alt-average:before{content:"\f624"}.fa-magic-wand-sparkles:before,.fa-wand-magic-sparkles:before{content:"\e2ca"}.fa-e:before{content:"\45"}.fa-pen-alt:before,.fa-pen-clip:before{content:"\f305"}.fa-bridge-circle-exclamation:before{content:"\e4ca"}.fa-user:before{content:"\f007"}.fa-school-circle-check:before{content:"\e56b"}.fa-dumpster:before{content:"\f793"}.fa-shuttle-van:before,.fa-van-shuttle:before{content:"\f5b6"}.fa-building-user:before{content:"\e4da"}.fa-caret-square-left:before,.fa-square-caret-left:before{content:"\f191"}.fa-highlighter:before{content:"\f591"}.fa-key:before{content:"\f084"}.fa-bullhorn:before{content:"\f0a1"}.fa-globe:before{content:"\f0ac"}.fa-synagogue:before{content:"\f69b"}.fa-person-half-dress:before{content:"\e548"}.fa-road-bridge:before{content:"\e563"}.fa-location-arrow:before{content:"\f124"}.fa-c:before{content:"\43"}.fa-tablet-button:before{content:"\f10a"}.fa-building-lock:before{content:"\e4d6"}.fa-pizza-slice:before{content:"\f818"}.fa-money-bill-wave:before{content:"\f53a"}.fa-area-chart:before,.fa-chart-area:before{content:"\f1fe"}.fa-house-flag:before{content:"\e50d"}.fa-person-circle-minus:before{content:"\e540"}.fa-ban:before,.fa-cancel:before{content:"\f05e"}.fa-camera-rotate:before{content:"\e0d8"}.fa-air-freshener:before,.fa-spray-can-sparkles:before{content:"\f5d0"}.fa-star:before{content:"\f005"}.fa-repeat:before{content:"\f363"}.fa-cross:before{content:"\f654"}.fa-box:before{content:"\f466"}.fa-venus-mars:before{content:"\f228"}.fa-arrow-pointer:before,.fa-mouse-pointer:before{content:"\f245"}.fa-expand-arrows-alt:before,.fa-maximize:before{content:"\f31e"}.fa-charging-station:before{content:"\f5e7"}.fa-shapes:before,.fa-triangle-circle-square:before{content:"\f61f"}.fa-random:before,.fa-shuffle:before{content:"\f074"}.fa-person-running:before,.fa-running:before{content:"\f70c"}.fa-mobile-retro:before{content:"\e527"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-spider:before{content:"\f717"}.fa-hands-bound:before{content:"\e4f9"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-plane-circle-exclamation:before{content:"\e556"}.fa-x-ray:before{content:"\f497"}.fa-spell-check:before{content:"\f891"}.fa-slash:before{content:"\f715"}.fa-computer-mouse:before,.fa-mouse:before{content:"\f8cc"}.fa-arrow-right-to-bracket:before,.fa-sign-in:before{content:"\f090"}.fa-shop-slash:before,.fa-store-alt-slash:before{content:"\e070"}.fa-server:before{content:"\f233"}.fa-virus-covid-slash:before{content:"\e4a9"}.fa-shop-lock:before{content:"\e4a5"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-blender-phone:before{content:"\f6b6"}.fa-building-wheat:before{content:"\e4db"}.fa-person-breastfeeding:before{content:"\e53a"}.fa-right-to-bracket:before,.fa-sign-in-alt:before{content:"\f2f6"}.fa-venus:before{content:"\f221"}.fa-passport:before{content:"\f5ab"}.fa-heart-pulse:before,.fa-heartbeat:before{content:"\f21e"}.fa-people-carry-box:before,.fa-people-carry:before{content:"\f4ce"}.fa-temperature-high:before{content:"\f769"}.fa-microchip:before{content:"\f2db"}.fa-crown:before{content:"\f521"}.fa-weight-hanging:before{content:"\f5cd"}.fa-xmarks-lines:before{content:"\e59a"}.fa-file-prescription:before{content:"\f572"}.fa-weight-scale:before,.fa-weight:before{content:"\f496"}.fa-user-friends:before,.fa-user-group:before{content:"\f500"}.fa-arrow-up-a-z:before,.fa-sort-alpha-up:before{content:"\f15e"}.fa-chess-knight:before{content:"\f441"}.fa-face-laugh-squint:before,.fa-laugh-squint:before{content:"\f59b"}.fa-wheelchair:before{content:"\f193"}.fa-arrow-circle-up:before,.fa-circle-arrow-up:before{content:"\f0aa"}.fa-toggle-on:before{content:"\f205"}.fa-person-walking:before,.fa-walking:before{content:"\f554"}.fa-l:before{content:"\4c"}.fa-fire:before{content:"\f06d"}.fa-bed-pulse:before,.fa-procedures:before{content:"\f487"}.fa-shuttle-space:before,.fa-space-shuttle:before{content:"\f197"}.fa-face-laugh:before,.fa-laugh:before{content:"\f599"}.fa-folder-open:before{content:"\f07c"}.fa-heart-circle-plus:before{content:"\e500"}.fa-code-fork:before{content:"\e13b"}.fa-city:before{content:"\f64f"}.fa-microphone-alt:before,.fa-microphone-lines:before{content:"\f3c9"}.fa-pepper-hot:before{content:"\f816"}.fa-unlock:before{content:"\f09c"}.fa-colon-sign:before{content:"\e140"}.fa-headset:before{content:"\f590"}.fa-store-slash:before{content:"\e071"}.fa-road-circle-xmark:before{content:"\e566"}.fa-user-minus:before{content:"\f503"}.fa-mars-stroke-up:before,.fa-mars-stroke-v:before{content:"\f22a"}.fa-champagne-glasses:before,.fa-glass-cheers:before{content:"\f79f"}.fa-clipboard:before{content:"\f328"}.fa-house-circle-exclamation:before{content:"\e50a"}.fa-file-arrow-up:before,.fa-file-upload:before{content:"\f574"}.fa-wifi-3:before,.fa-wifi-strong:before,.fa-wifi:before{content:"\f1eb"}.fa-bath:before,.fa-bathtub:before{content:"\f2cd"}.fa-underline:before{content:"\f0cd"}.fa-user-edit:before,.fa-user-pen:before{content:"\f4ff"}.fa-signature:before{content:"\f5b7"}.fa-stroopwafel:before{content:"\f551"}.fa-bold:before{content:"\f032"}.fa-anchor-lock:before{content:"\e4ad"}.fa-building-ngo:before{content:"\e4d7"}.fa-manat-sign:before{content:"\e1d5"}.fa-not-equal:before{content:"\f53e"}.fa-border-style:before,.fa-border-top-left:before{content:"\f853"}.fa-map-location-dot:before,.fa-map-marked-alt:before{content:"\f5a0"}.fa-jedi:before{content:"\f669"}.fa-poll:before,.fa-square-poll-vertical:before{content:"\f681"}.fa-mug-hot:before{content:"\f7b6"}.fa-battery-car:before,.fa-car-battery:before{content:"\f5df"}.fa-gift:before{content:"\f06b"}.fa-dice-two:before{content:"\f528"}.fa-chess-queen:before{content:"\f445"}.fa-glasses:before{content:"\f530"}.fa-chess-board:before{content:"\f43c"}.fa-building-circle-check:before{content:"\e4d2"}.fa-person-chalkboard:before{content:"\e53d"}.fa-mars-stroke-h:before,.fa-mars-stroke-right:before{content:"\f22b"}.fa-hand-back-fist:before,.fa-hand-rock:before{content:"\f255"}.fa-caret-square-up:before,.fa-square-caret-up:before{content:"\f151"}.fa-cloud-showers-water:before{content:"\e4e4"}.fa-bar-chart:before,.fa-chart-bar:before{content:"\f080"}.fa-hands-bubbles:before,.fa-hands-wash:before{content:"\e05e"}.fa-less-than-equal:before{content:"\f537"}.fa-train:before{content:"\f238"}.fa-eye-low-vision:before,.fa-low-vision:before{content:"\f2a8"}.fa-crow:before{content:"\f520"}.fa-sailboat:before{content:"\e445"}.fa-window-restore:before{content:"\f2d2"}.fa-plus-square:before,.fa-square-plus:before{content:"\f0fe"}.fa-torii-gate:before{content:"\f6a1"}.fa-frog:before{content:"\f52e"}.fa-bucket:before{content:"\e4cf"}.fa-image:before{content:"\f03e"}.fa-microphone:before{content:"\f130"}.fa-cow:before{content:"\f6c8"}.fa-caret-up:before{content:"\f0d8"}.fa-screwdriver:before{content:"\f54a"}.fa-folder-closed:before{content:"\e185"}.fa-house-tsunami:before{content:"\e515"}.fa-square-nfi:before{content:"\e576"}.fa-arrow-up-from-ground-water:before{content:"\e4b5"}.fa-glass-martini-alt:before,.fa-martini-glass:before{content:"\f57b"}.fa-rotate-back:before,.fa-rotate-backward:before,.fa-rotate-left:before,.fa-undo-alt:before{content:"\f2ea"}.fa-columns:before,.fa-table-columns:before{content:"\f0db"}.fa-lemon:before{content:"\f094"}.fa-head-side-mask:before{content:"\e063"}.fa-handshake:before{content:"\f2b5"}.fa-gem:before{content:"\f3a5"}.fa-dolly-box:before,.fa-dolly:before{content:"\f472"}.fa-smoking:before{content:"\f48d"}.fa-compress-arrows-alt:before,.fa-minimize:before{content:"\f78c"}.fa-monument:before{content:"\f5a6"}.fa-snowplow:before{content:"\f7d2"}.fa-angle-double-right:before,.fa-angles-right:before{content:"\f101"}.fa-cannabis:before{content:"\f55f"}.fa-circle-play:before,.fa-play-circle:before{content:"\f144"}.fa-tablets:before{content:"\f490"}.fa-ethernet:before{content:"\f796"}.fa-eur:before,.fa-euro-sign:before,.fa-euro:before{content:"\f153"}.fa-chair:before{content:"\f6c0"}.fa-check-circle:before,.fa-circle-check:before{content:"\f058"}.fa-circle-stop:before,.fa-stop-circle:before{content:"\f28d"}.fa-compass-drafting:before,.fa-drafting-compass:before{content:"\f568"}.fa-plate-wheat:before{content:"\e55a"}.fa-icicles:before{content:"\f7ad"}.fa-person-shelter:before{content:"\e54f"}.fa-neuter:before{content:"\f22c"}.fa-id-badge:before{content:"\f2c1"}.fa-marker:before{content:"\f5a1"}.fa-face-laugh-beam:before,.fa-laugh-beam:before{content:"\f59a"}.fa-helicopter-symbol:before{content:"\e502"}.fa-universal-access:before{content:"\f29a"}.fa-chevron-circle-up:before,.fa-circle-chevron-up:before{content:"\f139"}.fa-lari-sign:before{content:"\e1c8"}.fa-volcano:before{content:"\f770"}.fa-person-walking-dashed-line-arrow-right:before{content:"\e553"}.fa-gbp:before,.fa-pound-sign:before,.fa-sterling-sign:before{content:"\f154"}.fa-viruses:before{content:"\e076"}.fa-square-person-confined:before{content:"\e577"}.fa-user-tie:before{content:"\f508"}.fa-arrow-down-long:before,.fa-long-arrow-down:before{content:"\f175"}.fa-tent-arrow-down-to-line:before{content:"\e57e"}.fa-certificate:before{content:"\f0a3"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-suitcase:before{content:"\f0f2"}.fa-person-skating:before,.fa-skating:before{content:"\f7c5"}.fa-filter-circle-dollar:before,.fa-funnel-dollar:before{content:"\f662"}.fa-camera-retro:before{content:"\f083"}.fa-arrow-circle-down:before,.fa-circle-arrow-down:before{content:"\f0ab"}.fa-arrow-right-to-file:before,.fa-file-import:before{content:"\f56f"}.fa-external-link-square:before,.fa-square-arrow-up-right:before{content:"\f14c"}.fa-box-open:before{content:"\f49e"}.fa-scroll:before{content:"\f70e"}.fa-spa:before{content:"\f5bb"}.fa-location-pin-lock:before{content:"\e51f"}.fa-pause:before{content:"\f04c"}.fa-hill-avalanche:before{content:"\e507"}.fa-temperature-0:before,.fa-temperature-empty:before,.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-bomb:before{content:"\f1e2"}.fa-registered:before{content:"\f25d"}.fa-address-card:before,.fa-contact-card:before,.fa-vcard:before{content:"\f2bb"}.fa-balance-scale-right:before,.fa-scale-unbalanced-flip:before{content:"\f516"}.fa-subscript:before{content:"\f12c"}.fa-diamond-turn-right:before,.fa-directions:before{content:"\f5eb"}.fa-burst:before{content:"\e4dc"}.fa-house-laptop:before,.fa-laptop-house:before{content:"\e066"}.fa-face-tired:before,.fa-tired:before{content:"\f5c8"}.fa-money-bills:before{content:"\e1f3"}.fa-smog:before{content:"\f75f"}.fa-crutch:before{content:"\f7f7"}.fa-cloud-arrow-up:before,.fa-cloud-upload-alt:before,.fa-cloud-upload:before{content:"\f0ee"}.fa-palette:before{content:"\f53f"}.fa-arrows-turn-right:before{content:"\e4c0"}.fa-vest:before{content:"\e085"}.fa-ferry:before{content:"\e4ea"}.fa-arrows-down-to-people:before{content:"\e4b9"}.fa-seedling:before,.fa-sprout:before{content:"\f4d8"}.fa-arrows-alt-h:before,.fa-left-right:before{content:"\f337"}.fa-boxes-packing:before{content:"\e4c7"}.fa-arrow-circle-left:before,.fa-circle-arrow-left:before{content:"\f0a8"}.fa-group-arrows-rotate:before{content:"\e4f6"}.fa-bowl-food:before{content:"\e4c6"}.fa-candy-cane:before{content:"\f786"}.fa-arrow-down-wide-short:before,.fa-sort-amount-asc:before,.fa-sort-amount-down:before{content:"\f160"}.fa-cloud-bolt:before,.fa-thunderstorm:before{content:"\f76c"}.fa-remove-format:before,.fa-text-slash:before{content:"\f87d"}.fa-face-smile-wink:before,.fa-smile-wink:before{content:"\f4da"}.fa-file-word:before{content:"\f1c2"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-arrows-h:before,.fa-arrows-left-right:before{content:"\f07e"}.fa-house-lock:before{content:"\e510"}.fa-cloud-arrow-down:before,.fa-cloud-download-alt:before,.fa-cloud-download:before{content:"\f0ed"}.fa-children:before{content:"\e4e1"}.fa-blackboard:before,.fa-chalkboard:before{content:"\f51b"}.fa-user-alt-slash:before,.fa-user-large-slash:before{content:"\f4fa"}.fa-envelope-open:before{content:"\f2b6"}.fa-handshake-alt-slash:before,.fa-handshake-simple-slash:before{content:"\e05f"}.fa-mattress-pillow:before{content:"\e525"}.fa-guarani-sign:before{content:"\e19a"}.fa-arrows-rotate:before,.fa-refresh:before,.fa-sync:before{content:"\f021"}.fa-fire-extinguisher:before{content:"\f134"}.fa-cruzeiro-sign:before{content:"\e152"}.fa-greater-than-equal:before{content:"\f532"}.fa-shield-alt:before,.fa-shield-halved:before{content:"\f3ed"}.fa-atlas:before,.fa-book-atlas:before{content:"\f558"}.fa-virus:before{content:"\e074"}.fa-envelope-circle-check:before{content:"\e4e8"}.fa-layer-group:before{content:"\f5fd"}.fa-arrows-to-dot:before{content:"\e4be"}.fa-archway:before{content:"\f557"}.fa-heart-circle-check:before{content:"\e4fd"}.fa-house-chimney-crack:before,.fa-house-damage:before{content:"\f6f1"}.fa-file-archive:before,.fa-file-zipper:before{content:"\f1c6"}.fa-square:before{content:"\f0c8"}.fa-glass-martini:before,.fa-martini-glass-empty:before{content:"\f000"}.fa-couch:before{content:"\f4b8"}.fa-cedi-sign:before{content:"\e0df"}.fa-italic:before{content:"\f033"}.fa-table-cells-column-lock:before{content:"\e678"}.fa-church:before{content:"\f51d"}.fa-comments-dollar:before{content:"\f653"}.fa-democrat:before{content:"\f747"}.fa-z:before{content:"\5a"}.fa-person-skiing:before,.fa-skiing:before{content:"\f7c9"}.fa-road-lock:before{content:"\e567"}.fa-a:before{content:"\41"}.fa-temperature-arrow-down:before,.fa-temperature-down:before{content:"\e03f"}.fa-feather-alt:before,.fa-feather-pointed:before{content:"\f56b"}.fa-p:before{content:"\50"}.fa-snowflake:before{content:"\f2dc"}.fa-newspaper:before{content:"\f1ea"}.fa-ad:before,.fa-rectangle-ad:before{content:"\f641"}.fa-arrow-circle-right:before,.fa-circle-arrow-right:before{content:"\f0a9"}.fa-filter-circle-xmark:before{content:"\e17b"}.fa-locust:before{content:"\e520"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-list-1-2:before,.fa-list-numeric:before,.fa-list-ol:before{content:"\f0cb"}.fa-person-dress-burst:before{content:"\e544"}.fa-money-check-alt:before,.fa-money-check-dollar:before{content:"\f53d"}.fa-vector-square:before{content:"\f5cb"}.fa-bread-slice:before{content:"\f7ec"}.fa-language:before{content:"\f1ab"}.fa-face-kiss-wink-heart:before,.fa-kiss-wink-heart:before{content:"\f598"}.fa-filter:before{content:"\f0b0"}.fa-question:before{content:"\3f"}.fa-file-signature:before{content:"\f573"}.fa-arrows-alt:before,.fa-up-down-left-right:before{content:"\f0b2"}.fa-house-chimney-user:before{content:"\e065"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-puzzle-piece:before{content:"\f12e"}.fa-money-check:before{content:"\f53c"}.fa-star-half-alt:before,.fa-star-half-stroke:before{content:"\f5c0"}.fa-code:before{content:"\f121"}.fa-glass-whiskey:before,.fa-whiskey-glass:before{content:"\f7a0"}.fa-building-circle-exclamation:before{content:"\e4d3"}.fa-magnifying-glass-chart:before{content:"\e522"}.fa-arrow-up-right-from-square:before,.fa-external-link:before{content:"\f08e"}.fa-cubes-stacked:before{content:"\e4e6"}.fa-krw:before,.fa-won-sign:before,.fa-won:before{content:"\f159"}.fa-virus-covid:before{content:"\e4a8"}.fa-austral-sign:before{content:"\e0a9"}.fa-f:before{content:"\46"}.fa-leaf:before{content:"\f06c"}.fa-road:before{content:"\f018"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-person-circle-plus:before{content:"\e541"}.fa-chart-pie:before,.fa-pie-chart:before{content:"\f200"}.fa-bolt-lightning:before{content:"\e0b7"}.fa-sack-xmark:before{content:"\e56a"}.fa-file-excel:before{content:"\f1c3"}.fa-file-contract:before{content:"\f56c"}.fa-fish-fins:before{content:"\e4f2"}.fa-building-flag:before{content:"\e4d5"}.fa-face-grin-beam:before,.fa-grin-beam:before{content:"\f582"}.fa-object-ungroup:before{content:"\f248"}.fa-poop:before{content:"\f619"}.fa-location-pin:before,.fa-map-marker:before{content:"\f041"}.fa-kaaba:before{content:"\f66b"}.fa-toilet-paper:before{content:"\f71e"}.fa-hard-hat:before,.fa-hat-hard:before,.fa-helmet-safety:before{content:"\f807"}.fa-eject:before{content:"\f052"}.fa-arrow-alt-circle-right:before,.fa-circle-right:before{content:"\f35a"}.fa-plane-circle-check:before{content:"\e555"}.fa-face-rolling-eyes:before,.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-object-group:before{content:"\f247"}.fa-chart-line:before,.fa-line-chart:before{content:"\f201"}.fa-mask-ventilator:before{content:"\e524"}.fa-arrow-right:before{content:"\f061"}.fa-map-signs:before,.fa-signs-post:before{content:"\f277"}.fa-cash-register:before{content:"\f788"}.fa-person-circle-question:before{content:"\e542"}.fa-h:before{content:"\48"}.fa-tarp:before{content:"\e57b"}.fa-screwdriver-wrench:before,.fa-tools:before{content:"\f7d9"}.fa-arrows-to-eye:before{content:"\e4bf"}.fa-plug-circle-bolt:before{content:"\e55b"}.fa-heart:before{content:"\f004"}.fa-mars-and-venus:before{content:"\f224"}.fa-home-user:before,.fa-house-user:before{content:"\e1b0"}.fa-dumpster-fire:before{content:"\f794"}.fa-house-crack:before{content:"\e3b1"}.fa-cocktail:before,.fa-martini-glass-citrus:before{content:"\f561"}.fa-face-surprise:before,.fa-surprise:before{content:"\f5c2"}.fa-bottle-water:before{content:"\e4c5"}.fa-circle-pause:before,.fa-pause-circle:before{content:"\f28b"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-apple-alt:before,.fa-apple-whole:before{content:"\f5d1"}.fa-kitchen-set:before{content:"\e51a"}.fa-r:before{content:"\52"}.fa-temperature-1:before,.fa-temperature-quarter:before,.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-cube:before{content:"\f1b2"}.fa-bitcoin-sign:before{content:"\e0b4"}.fa-shield-dog:before{content:"\e573"}.fa-solar-panel:before{content:"\f5ba"}.fa-lock-open:before{content:"\f3c1"}.fa-elevator:before{content:"\e16d"}.fa-money-bill-transfer:before{content:"\e528"}.fa-money-bill-trend-up:before{content:"\e529"}.fa-house-flood-water-circle-arrow-right:before{content:"\e50f"}.fa-poll-h:before,.fa-square-poll-horizontal:before{content:"\f682"}.fa-circle:before{content:"\f111"}.fa-backward-fast:before,.fa-fast-backward:before{content:"\f049"}.fa-recycle:before{content:"\f1b8"}.fa-user-astronaut:before{content:"\f4fb"}.fa-plane-slash:before{content:"\e069"}.fa-trademark:before{content:"\f25c"}.fa-basketball-ball:before,.fa-basketball:before{content:"\f434"}.fa-satellite-dish:before{content:"\f7c0"}.fa-arrow-alt-circle-up:before,.fa-circle-up:before{content:"\f35b"}.fa-mobile-alt:before,.fa-mobile-screen-button:before{content:"\f3cd"}.fa-volume-high:before,.fa-volume-up:before{content:"\f028"}.fa-users-rays:before{content:"\e593"}.fa-wallet:before{content:"\f555"}.fa-clipboard-check:before{content:"\f46c"}.fa-file-audio:before{content:"\f1c7"}.fa-burger:before,.fa-hamburger:before{content:"\f805"}.fa-wrench:before{content:"\f0ad"}.fa-bugs:before{content:"\e4d0"}.fa-rupee-sign:before,.fa-rupee:before{content:"\f156"}.fa-file-image:before{content:"\f1c5"}.fa-circle-question:before,.fa-question-circle:before{content:"\f059"}.fa-plane-departure:before{content:"\f5b0"}.fa-handshake-slash:before{content:"\e060"}.fa-book-bookmark:before{content:"\e0bb"}.fa-code-branch:before{content:"\f126"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-bridge:before{content:"\e4c8"}.fa-phone-alt:before,.fa-phone-flip:before{content:"\f879"}.fa-truck-front:before{content:"\e2b7"}.fa-cat:before{content:"\f6be"}.fa-anchor-circle-exclamation:before{content:"\e4ab"}.fa-truck-field:before{content:"\e58d"}.fa-route:before{content:"\f4d7"}.fa-clipboard-question:before{content:"\e4e3"}.fa-panorama:before{content:"\e209"}.fa-comment-medical:before{content:"\f7f5"}.fa-teeth-open:before{content:"\f62f"}.fa-file-circle-minus:before{content:"\e4ed"}.fa-tags:before{content:"\f02c"}.fa-wine-glass:before{content:"\f4e3"}.fa-fast-forward:before,.fa-forward-fast:before{content:"\f050"}.fa-face-meh-blank:before,.fa-meh-blank:before{content:"\f5a4"}.fa-parking:before,.fa-square-parking:before{content:"\f540"}.fa-house-signal:before{content:"\e012"}.fa-bars-progress:before,.fa-tasks-alt:before{content:"\f828"}.fa-faucet-drip:before{content:"\e006"}.fa-cart-flatbed:before,.fa-dolly-flatbed:before{content:"\f474"}.fa-ban-smoking:before,.fa-smoking-ban:before{content:"\f54d"}.fa-terminal:before{content:"\f120"}.fa-mobile-button:before{content:"\f10b"}.fa-house-medical-flag:before{content:"\e514"}.fa-basket-shopping:before,.fa-shopping-basket:before{content:"\f291"}.fa-tape:before{content:"\f4db"}.fa-bus-alt:before,.fa-bus-simple:before{content:"\f55e"}.fa-eye:before{content:"\f06e"}.fa-face-sad-cry:before,.fa-sad-cry:before{content:"\f5b3"}.fa-audio-description:before{content:"\f29e"}.fa-person-military-to-person:before{content:"\e54c"}.fa-file-shield:before{content:"\e4f0"}.fa-user-slash:before{content:"\f506"}.fa-pen:before{content:"\f304"}.fa-tower-observation:before{content:"\e586"}.fa-file-code:before{content:"\f1c9"}.fa-signal-5:before,.fa-signal-perfect:before,.fa-signal:before{content:"\f012"}.fa-bus:before{content:"\f207"}.fa-heart-circle-xmark:before{content:"\e501"}.fa-home-lg:before,.fa-house-chimney:before{content:"\e3af"}.fa-window-maximize:before{content:"\f2d0"}.fa-face-frown:before,.fa-frown:before{content:"\f119"}.fa-prescription:before{content:"\f5b1"}.fa-shop:before,.fa-store-alt:before{content:"\f54f"}.fa-floppy-disk:before,.fa-save:before{content:"\f0c7"}.fa-vihara:before{content:"\f6a7"}.fa-balance-scale-left:before,.fa-scale-unbalanced:before{content:"\f515"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-comment-dots:before,.fa-commenting:before{content:"\f4ad"}.fa-plant-wilt:before{content:"\e5aa"}.fa-diamond:before{content:"\f219"}.fa-face-grin-squint:before,.fa-grin-squint:before{content:"\f585"}.fa-hand-holding-dollar:before,.fa-hand-holding-usd:before{content:"\f4c0"}.fa-bacterium:before{content:"\e05a"}.fa-hand-pointer:before{content:"\f25a"}.fa-drum-steelpan:before{content:"\f56a"}.fa-hand-scissors:before{content:"\f257"}.fa-hands-praying:before,.fa-praying-hands:before{content:"\f684"}.fa-arrow-right-rotate:before,.fa-arrow-rotate-forward:before,.fa-arrow-rotate-right:before,.fa-redo:before{content:"\f01e"}.fa-biohazard:before{content:"\f780"}.fa-location-crosshairs:before,.fa-location:before{content:"\f601"}.fa-mars-double:before{content:"\f227"}.fa-child-dress:before{content:"\e59c"}.fa-users-between-lines:before{content:"\e591"}.fa-lungs-virus:before{content:"\e067"}.fa-face-grin-tears:before,.fa-grin-tears:before{content:"\f588"}.fa-phone:before{content:"\f095"}.fa-calendar-times:before,.fa-calendar-xmark:before{content:"\f273"}.fa-child-reaching:before{content:"\e59d"}.fa-head-side-virus:before{content:"\e064"}.fa-user-cog:before,.fa-user-gear:before{content:"\f4fe"}.fa-arrow-up-1-9:before,.fa-sort-numeric-up:before{content:"\f163"}.fa-door-closed:before{content:"\f52a"}.fa-shield-virus:before{content:"\e06c"}.fa-dice-six:before{content:"\f526"}.fa-mosquito-net:before{content:"\e52c"}.fa-bridge-water:before{content:"\e4ce"}.fa-person-booth:before{content:"\f756"}.fa-text-width:before{content:"\f035"}.fa-hat-wizard:before{content:"\f6e8"}.fa-pen-fancy:before{content:"\f5ac"}.fa-digging:before,.fa-person-digging:before{content:"\f85e"}.fa-trash:before{content:"\f1f8"}.fa-gauge-simple-med:before,.fa-gauge-simple:before,.fa-tachometer-average:before{content:"\f629"}.fa-book-medical:before{content:"\f7e6"}.fa-poo:before{content:"\f2fe"}.fa-quote-right-alt:before,.fa-quote-right:before{content:"\f10e"}.fa-shirt:before,.fa-t-shirt:before,.fa-tshirt:before{content:"\f553"}.fa-cubes:before{content:"\f1b3"}.fa-divide:before{content:"\f529"}.fa-tenge-sign:before,.fa-tenge:before{content:"\f7d7"}.fa-headphones:before{content:"\f025"}.fa-hands-holding:before{content:"\f4c2"}.fa-hands-clapping:before{content:"\e1a8"}.fa-republican:before{content:"\f75e"}.fa-arrow-left:before{content:"\f060"}.fa-person-circle-xmark:before{content:"\e543"}.fa-ruler:before{content:"\f545"}.fa-align-left:before{content:"\f036"}.fa-dice-d6:before{content:"\f6d1"}.fa-restroom:before{content:"\f7bd"}.fa-j:before{content:"\4a"}.fa-users-viewfinder:before{content:"\e595"}.fa-file-video:before{content:"\f1c8"}.fa-external-link-alt:before,.fa-up-right-from-square:before{content:"\f35d"}.fa-table-cells:before,.fa-th:before{content:"\f00a"}.fa-file-pdf:before{content:"\f1c1"}.fa-bible:before,.fa-book-bible:before{content:"\f647"}.fa-o:before{content:"\4f"}.fa-medkit:before,.fa-suitcase-medical:before{content:"\f0fa"}.fa-user-secret:before{content:"\f21b"}.fa-otter:before{content:"\f700"}.fa-female:before,.fa-person-dress:before{content:"\f182"}.fa-comment-dollar:before{content:"\f651"}.fa-briefcase-clock:before,.fa-business-time:before{content:"\f64a"}.fa-table-cells-large:before,.fa-th-large:before{content:"\f009"}.fa-book-tanakh:before,.fa-tanakh:before{content:"\f827"}.fa-phone-volume:before,.fa-volume-control-phone:before{content:"\f2a0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-clipboard-user:before{content:"\f7f3"}.fa-child:before{content:"\f1ae"}.fa-lira-sign:before{content:"\f195"}.fa-satellite:before{content:"\f7bf"}.fa-plane-lock:before{content:"\e558"}.fa-tag:before{content:"\f02b"}.fa-comment:before{content:"\f075"}.fa-birthday-cake:before,.fa-cake-candles:before,.fa-cake:before{content:"\f1fd"}.fa-envelope:before{content:"\f0e0"}.fa-angle-double-up:before,.fa-angles-up:before{content:"\f102"}.fa-paperclip:before{content:"\f0c6"}.fa-arrow-right-to-city:before{content:"\e4b3"}.fa-ribbon:before{content:"\f4d6"}.fa-lungs:before{content:"\f604"}.fa-arrow-up-9-1:before,.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-litecoin-sign:before{content:"\e1d3"}.fa-border-none:before{content:"\f850"}.fa-circle-nodes:before{content:"\e4e2"}.fa-parachute-box:before{content:"\f4cd"}.fa-indent:before{content:"\f03c"}.fa-truck-field-un:before{content:"\e58e"}.fa-hourglass-empty:before,.fa-hourglass:before{content:"\f254"}.fa-mountain:before{content:"\f6fc"}.fa-user-doctor:before,.fa-user-md:before{content:"\f0f0"}.fa-circle-info:before,.fa-info-circle:before{content:"\f05a"}.fa-cloud-meatball:before{content:"\f73b"}.fa-camera-alt:before,.fa-camera:before{content:"\f030"}.fa-square-virus:before{content:"\e578"}.fa-meteor:before{content:"\f753"}.fa-car-on:before{content:"\e4dd"}.fa-sleigh:before{content:"\f7cc"}.fa-arrow-down-1-9:before,.fa-sort-numeric-asc:before,.fa-sort-numeric-down:before{content:"\f162"}.fa-hand-holding-droplet:before,.fa-hand-holding-water:before{content:"\f4c1"}.fa-water:before{content:"\f773"}.fa-calendar-check:before{content:"\f274"}.fa-braille:before{content:"\f2a1"}.fa-prescription-bottle-alt:before,.fa-prescription-bottle-medical:before{content:"\f486"}.fa-landmark:before{content:"\f66f"}.fa-truck:before{content:"\f0d1"}.fa-crosshairs:before{content:"\f05b"}.fa-person-cane:before{content:"\e53c"}.fa-tent:before{content:"\e57d"}.fa-vest-patches:before{content:"\e086"}.fa-check-double:before{content:"\f560"}.fa-arrow-down-a-z:before,.fa-sort-alpha-asc:before,.fa-sort-alpha-down:before{content:"\f15d"}.fa-money-bill-wheat:before{content:"\e52a"}.fa-cookie:before{content:"\f563"}.fa-arrow-left-rotate:before,.fa-arrow-rotate-back:before,.fa-arrow-rotate-backward:before,.fa-arrow-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-hard-drive:before,.fa-hdd:before{content:"\f0a0"}.fa-face-grin-squint-tears:before,.fa-grin-squint-tears:before{content:"\f586"}.fa-dumbbell:before{content:"\f44b"}.fa-list-alt:before,.fa-rectangle-list:before{content:"\f022"}.fa-tarp-droplet:before{content:"\e57c"}.fa-house-medical-circle-check:before{content:"\e511"}.fa-person-skiing-nordic:before,.fa-skiing-nordic:before{content:"\f7ca"}.fa-calendar-plus:before{content:"\f271"}.fa-plane-arrival:before{content:"\f5af"}.fa-arrow-alt-circle-left:before,.fa-circle-left:before{content:"\f359"}.fa-subway:before,.fa-train-subway:before{content:"\f239"}.fa-chart-gantt:before{content:"\e0e4"}.fa-indian-rupee-sign:before,.fa-indian-rupee:before,.fa-inr:before{content:"\e1bc"}.fa-crop-alt:before,.fa-crop-simple:before{content:"\f565"}.fa-money-bill-1:before,.fa-money-bill-alt:before{content:"\f3d1"}.fa-left-long:before,.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-dna:before{content:"\f471"}.fa-virus-slash:before{content:"\e075"}.fa-minus:before,.fa-subtract:before{content:"\f068"}.fa-chess:before{content:"\f439"}.fa-arrow-left-long:before,.fa-long-arrow-left:before{content:"\f177"}.fa-plug-circle-check:before{content:"\e55c"}.fa-street-view:before{content:"\f21d"}.fa-franc-sign:before{content:"\e18f"}.fa-volume-off:before{content:"\f026"}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before,.fa-hands-american-sign-language-interpreting:before,.fa-hands-asl-interpreting:before{content:"\f2a3"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-droplet-slash:before,.fa-tint-slash:before{content:"\f5c7"}.fa-mosque:before{content:"\f678"}.fa-mosquito:before{content:"\e52b"}.fa-star-of-david:before{content:"\f69a"}.fa-person-military-rifle:before{content:"\e54b"}.fa-cart-shopping:before,.fa-shopping-cart:before{content:"\f07a"}.fa-vials:before{content:"\f493"}.fa-plug-circle-plus:before{content:"\e55f"}.fa-place-of-worship:before{content:"\f67f"}.fa-grip-vertical:before{content:"\f58e"}.fa-arrow-turn-up:before,.fa-level-up:before{content:"\f148"}.fa-u:before{content:"\55"}.fa-square-root-alt:before,.fa-square-root-variable:before{content:"\f698"}.fa-clock-four:before,.fa-clock:before{content:"\f017"}.fa-backward-step:before,.fa-step-backward:before{content:"\f048"}.fa-pallet:before{content:"\f482"}.fa-faucet:before{content:"\e005"}.fa-baseball-bat-ball:before{content:"\f432"}.fa-s:before{content:"\53"}.fa-timeline:before{content:"\e29c"}.fa-keyboard:before{content:"\f11c"}.fa-caret-down:before{content:"\f0d7"}.fa-clinic-medical:before,.fa-house-chimney-medical:before{content:"\f7f2"}.fa-temperature-3:before,.fa-temperature-three-quarters:before,.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-mobile-android-alt:before,.fa-mobile-screen:before{content:"\f3cf"}.fa-plane-up:before{content:"\e22d"}.fa-piggy-bank:before{content:"\f4d3"}.fa-battery-3:before,.fa-battery-half:before{content:"\f242"}.fa-mountain-city:before{content:"\e52e"}.fa-coins:before{content:"\f51e"}.fa-khanda:before{content:"\f66d"}.fa-sliders-h:before,.fa-sliders:before{content:"\f1de"}.fa-folder-tree:before{content:"\f802"}.fa-network-wired:before{content:"\f6ff"}.fa-map-pin:before{content:"\f276"}.fa-hamsa:before{content:"\f665"}.fa-cent-sign:before{content:"\e3f5"}.fa-flask:before{content:"\f0c3"}.fa-person-pregnant:before{content:"\e31e"}.fa-wand-sparkles:before{content:"\f72b"}.fa-ellipsis-v:before,.fa-ellipsis-vertical:before{content:"\f142"}.fa-ticket:before{content:"\f145"}.fa-power-off:before{content:"\f011"}.fa-long-arrow-alt-right:before,.fa-right-long:before{content:"\f30b"}.fa-flag-usa:before{content:"\f74d"}.fa-laptop-file:before{content:"\e51d"}.fa-teletype:before,.fa-tty:before{content:"\f1e4"}.fa-diagram-next:before{content:"\e476"}.fa-person-rifle:before{content:"\e54e"}.fa-house-medical-circle-exclamation:before{content:"\e512"}.fa-closed-captioning:before{content:"\f20a"}.fa-hiking:before,.fa-person-hiking:before{content:"\f6ec"}.fa-venus-double:before{content:"\f226"}.fa-images:before{content:"\f302"}.fa-calculator:before{content:"\f1ec"}.fa-people-pulling:before{content:"\e535"}.fa-n:before{content:"\4e"}.fa-cable-car:before,.fa-tram:before{content:"\f7da"}.fa-cloud-rain:before{content:"\f73d"}.fa-building-circle-xmark:before{content:"\e4d4"}.fa-ship:before{content:"\f21a"}.fa-arrows-down-to-line:before{content:"\e4b8"}.fa-download:before{content:"\f019"}.fa-face-grin:before,.fa-grin:before{content:"\f580"}.fa-backspace:before,.fa-delete-left:before{content:"\f55a"}.fa-eye-dropper-empty:before,.fa-eye-dropper:before,.fa-eyedropper:before{content:"\f1fb"}.fa-file-circle-check:before{content:"\e5a0"}.fa-forward:before{content:"\f04e"}.fa-mobile-android:before,.fa-mobile-phone:before,.fa-mobile:before{content:"\f3ce"}.fa-face-meh:before,.fa-meh:before{content:"\f11a"}.fa-align-center:before{content:"\f037"}.fa-book-dead:before,.fa-book-skull:before{content:"\f6b7"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-heart-circle-exclamation:before{content:"\e4fe"}.fa-home-alt:before,.fa-home-lg-alt:before,.fa-home:before,.fa-house:before{content:"\f015"}.fa-calendar-week:before{content:"\f784"}.fa-laptop-medical:before{content:"\f812"}.fa-b:before{content:"\42"}.fa-file-medical:before{content:"\f477"}.fa-dice-one:before{content:"\f525"}.fa-kiwi-bird:before{content:"\f535"}.fa-arrow-right-arrow-left:before,.fa-exchange:before{content:"\f0ec"}.fa-redo-alt:before,.fa-rotate-forward:before,.fa-rotate-right:before{content:"\f2f9"}.fa-cutlery:before,.fa-utensils:before{content:"\f2e7"}.fa-arrow-up-wide-short:before,.fa-sort-amount-up:before{content:"\f161"}.fa-mill-sign:before{content:"\e1ed"}.fa-bowl-rice:before{content:"\e2eb"}.fa-skull:before{content:"\f54c"}.fa-broadcast-tower:before,.fa-tower-broadcast:before{content:"\f519"}.fa-truck-pickup:before{content:"\f63c"}.fa-long-arrow-alt-up:before,.fa-up-long:before{content:"\f30c"}.fa-stop:before{content:"\f04d"}.fa-code-merge:before{content:"\f387"}.fa-upload:before{content:"\f093"}.fa-hurricane:before{content:"\f751"}.fa-mound:before{content:"\e52d"}.fa-toilet-portable:before{content:"\e583"}.fa-compact-disc:before{content:"\f51f"}.fa-file-arrow-down:before,.fa-file-download:before{content:"\f56d"}.fa-caravan:before{content:"\f8ff"}.fa-shield-cat:before{content:"\e572"}.fa-bolt:before,.fa-zap:before{content:"\f0e7"}.fa-glass-water:before{content:"\e4f4"}.fa-oil-well:before{content:"\e532"}.fa-vault:before{content:"\e2c5"}.fa-mars:before{content:"\f222"}.fa-toilet:before{content:"\f7d8"}.fa-plane-circle-xmark:before{content:"\e557"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen-sign:before,.fa-yen:before{content:"\f157"}.fa-rouble:before,.fa-rub:before,.fa-ruble-sign:before,.fa-ruble:before{content:"\f158"}.fa-sun:before{content:"\f185"}.fa-guitar:before{content:"\f7a6"}.fa-face-laugh-wink:before,.fa-laugh-wink:before{content:"\f59c"}.fa-horse-head:before{content:"\f7ab"}.fa-bore-hole:before{content:"\e4c3"}.fa-industry:before{content:"\f275"}.fa-arrow-alt-circle-down:before,.fa-circle-down:before{content:"\f358"}.fa-arrows-turn-to-dots:before{content:"\e4c1"}.fa-florin-sign:before{content:"\e184"}.fa-arrow-down-short-wide:before,.fa-sort-amount-desc:before,.fa-sort-amount-down-alt:before{content:"\f884"}.fa-less-than:before{content:"\3c"}.fa-angle-down:before{content:"\f107"}.fa-car-tunnel:before{content:"\e4de"}.fa-head-side-cough:before{content:"\e061"}.fa-grip-lines:before{content:"\f7a4"}.fa-thumbs-down:before{content:"\f165"}.fa-user-lock:before{content:"\f502"}.fa-arrow-right-long:before,.fa-long-arrow-right:before{content:"\f178"}.fa-anchor-circle-xmark:before{content:"\e4ac"}.fa-ellipsis-h:before,.fa-ellipsis:before{content:"\f141"}.fa-chess-pawn:before{content:"\f443"}.fa-first-aid:before,.fa-kit-medical:before{content:"\f479"}.fa-person-through-window:before{content:"\e5a9"}.fa-toolbox:before{content:"\f552"}.fa-hands-holding-circle:before{content:"\e4fb"}.fa-bug:before{content:"\f188"}.fa-credit-card-alt:before,.fa-credit-card:before{content:"\f09d"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-hand-holding-hand:before{content:"\e4f7"}.fa-book-open-reader:before,.fa-book-reader:before{content:"\f5da"}.fa-mountain-sun:before{content:"\e52f"}.fa-arrows-left-right-to-line:before{content:"\e4ba"}.fa-dice-d20:before{content:"\f6cf"}.fa-truck-droplet:before{content:"\e58c"}.fa-file-circle-xmark:before{content:"\e5a1"}.fa-temperature-arrow-up:before,.fa-temperature-up:before{content:"\e040"}.fa-medal:before{content:"\f5a2"}.fa-bed:before{content:"\f236"}.fa-h-square:before,.fa-square-h:before{content:"\f0fd"}.fa-podcast:before{content:"\f2ce"}.fa-temperature-4:before,.fa-temperature-full:before,.fa-thermometer-4:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-bell:before{content:"\f0f3"}.fa-superscript:before{content:"\f12b"}.fa-plug-circle-xmark:before{content:"\e560"}.fa-star-of-life:before{content:"\f621"}.fa-phone-slash:before{content:"\f3dd"}.fa-paint-roller:before{content:"\f5aa"}.fa-hands-helping:before,.fa-handshake-angle:before{content:"\f4c4"}.fa-location-dot:before,.fa-map-marker-alt:before{content:"\f3c5"}.fa-file:before{content:"\f15b"}.fa-greater-than:before{content:"\3e"}.fa-person-swimming:before,.fa-swimmer:before{content:"\f5c4"}.fa-arrow-down:before{content:"\f063"}.fa-droplet:before,.fa-tint:before{content:"\f043"}.fa-eraser:before{content:"\f12d"}.fa-earth-america:before,.fa-earth-americas:before,.fa-earth:before,.fa-globe-americas:before{content:"\f57d"}.fa-person-burst:before{content:"\e53b"}.fa-dove:before{content:"\f4ba"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-socks:before{content:"\f696"}.fa-inbox:before{content:"\f01c"}.fa-section:before{content:"\e447"}.fa-gauge-high:before,.fa-tachometer-alt-fast:before,.fa-tachometer-alt:before{content:"\f625"}.fa-envelope-open-text:before{content:"\f658"}.fa-hospital-alt:before,.fa-hospital-wide:before,.fa-hospital:before{content:"\f0f8"}.fa-wine-bottle:before{content:"\f72f"}.fa-chess-rook:before{content:"\f447"}.fa-bars-staggered:before,.fa-reorder:before,.fa-stream:before{content:"\f550"}.fa-dharmachakra:before{content:"\f655"}.fa-hotdog:before{content:"\f80f"}.fa-blind:before,.fa-person-walking-with-cane:before{content:"\f29d"}.fa-drum:before{content:"\f569"}.fa-ice-cream:before{content:"\f810"}.fa-heart-circle-bolt:before{content:"\e4fc"}.fa-fax:before{content:"\f1ac"}.fa-paragraph:before{content:"\f1dd"}.fa-check-to-slot:before,.fa-vote-yea:before{content:"\f772"}.fa-star-half:before{content:"\f089"}.fa-boxes-alt:before,.fa-boxes-stacked:before,.fa-boxes:before{content:"\f468"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-assistive-listening-systems:before,.fa-ear-listen:before{content:"\f2a2"}.fa-tree-city:before{content:"\e587"}.fa-play:before{content:"\f04b"}.fa-font:before{content:"\f031"}.fa-table-cells-row-lock:before{content:"\e67a"}.fa-rupiah-sign:before{content:"\e23d"}.fa-magnifying-glass:before,.fa-search:before{content:"\f002"}.fa-ping-pong-paddle-ball:before,.fa-table-tennis-paddle-ball:before,.fa-table-tennis:before{content:"\f45d"}.fa-diagnoses:before,.fa-person-dots-from-line:before{content:"\f470"}.fa-trash-can-arrow-up:before,.fa-trash-restore-alt:before{content:"\f82a"}.fa-naira-sign:before{content:"\e1f6"}.fa-cart-arrow-down:before{content:"\f218"}.fa-walkie-talkie:before{content:"\f8ef"}.fa-file-edit:before,.fa-file-pen:before{content:"\f31c"}.fa-receipt:before{content:"\f543"}.fa-pen-square:before,.fa-pencil-square:before,.fa-square-pen:before{content:"\f14b"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-person-circle-exclamation:before{content:"\e53f"}.fa-chevron-down:before{content:"\f078"}.fa-battery-5:before,.fa-battery-full:before,.fa-battery:before{content:"\f240"}.fa-skull-crossbones:before{content:"\f714"}.fa-code-compare:before{content:"\e13a"}.fa-list-dots:before,.fa-list-ul:before{content:"\f0ca"}.fa-school-lock:before{content:"\e56f"}.fa-tower-cell:before{content:"\e585"}.fa-down-long:before,.fa-long-arrow-alt-down:before{content:"\f309"}.fa-ranking-star:before{content:"\e561"}.fa-chess-king:before{content:"\f43f"}.fa-person-harassing:before{content:"\e549"}.fa-brazilian-real-sign:before{content:"\e46c"}.fa-landmark-alt:before,.fa-landmark-dome:before{content:"\f752"}.fa-arrow-up:before{content:"\f062"}.fa-television:before,.fa-tv-alt:before,.fa-tv:before{content:"\f26c"}.fa-shrimp:before{content:"\e448"}.fa-list-check:before,.fa-tasks:before{content:"\f0ae"}.fa-jug-detergent:before{content:"\e519"}.fa-circle-user:before,.fa-user-circle:before{content:"\f2bd"}.fa-user-shield:before{content:"\f505"}.fa-wind:before{content:"\f72e"}.fa-car-burst:before,.fa-car-crash:before{content:"\f5e1"}.fa-y:before{content:"\59"}.fa-person-snowboarding:before,.fa-snowboarding:before{content:"\f7ce"}.fa-shipping-fast:before,.fa-truck-fast:before{content:"\f48b"}.fa-fish:before{content:"\f578"}.fa-user-graduate:before{content:"\f501"}.fa-adjust:before,.fa-circle-half-stroke:before{content:"\f042"}.fa-clapperboard:before{content:"\e131"}.fa-circle-radiation:before,.fa-radiation-alt:before{content:"\f7ba"}.fa-baseball-ball:before,.fa-baseball:before{content:"\f433"}.fa-jet-fighter-up:before{content:"\e518"}.fa-diagram-project:before,.fa-project-diagram:before{content:"\f542"}.fa-copy:before{content:"\f0c5"}.fa-volume-mute:before,.fa-volume-times:before,.fa-volume-xmark:before{content:"\f6a9"}.fa-hand-sparkles:before{content:"\e05d"}.fa-grip-horizontal:before,.fa-grip:before{content:"\f58d"}.fa-share-from-square:before,.fa-share-square:before{content:"\f14d"}.fa-child-combatant:before,.fa-child-rifle:before{content:"\e4e0"}.fa-gun:before{content:"\e19b"}.fa-phone-square:before,.fa-square-phone:before{content:"\f098"}.fa-add:before,.fa-plus:before{content:"\2b"}.fa-expand:before{content:"\f065"}.fa-computer:before{content:"\e4e5"}.fa-close:before,.fa-multiply:before,.fa-remove:before,.fa-times:before,.fa-xmark:before{content:"\f00d"}.fa-arrows-up-down-left-right:before,.fa-arrows:before{content:"\f047"}.fa-chalkboard-teacher:before,.fa-chalkboard-user:before{content:"\f51c"}.fa-peso-sign:before{content:"\e222"}.fa-building-shield:before{content:"\e4d8"}.fa-baby:before{content:"\f77c"}.fa-users-line:before{content:"\e592"}.fa-quote-left-alt:before,.fa-quote-left:before{content:"\f10d"}.fa-tractor:before{content:"\f722"}.fa-trash-arrow-up:before,.fa-trash-restore:before{content:"\f829"}.fa-arrow-down-up-lock:before{content:"\e4b0"}.fa-lines-leaning:before{content:"\e51e"}.fa-ruler-combined:before{content:"\f546"}.fa-copyright:before{content:"\f1f9"}.fa-equals:before{content:"\3d"}.fa-blender:before{content:"\f517"}.fa-teeth:before{content:"\f62e"}.fa-ils:before,.fa-shekel-sign:before,.fa-shekel:before,.fa-sheqel-sign:before,.fa-sheqel:before{content:"\f20b"}.fa-map:before{content:"\f279"}.fa-rocket:before{content:"\f135"}.fa-photo-film:before,.fa-photo-video:before{content:"\f87c"}.fa-folder-minus:before{content:"\f65d"}.fa-store:before{content:"\f54e"}.fa-arrow-trend-up:before{content:"\e098"}.fa-plug-circle-minus:before{content:"\e55e"}.fa-sign-hanging:before,.fa-sign:before{content:"\f4d9"}.fa-bezier-curve:before{content:"\f55b"}.fa-bell-slash:before{content:"\f1f6"}.fa-tablet-android:before,.fa-tablet:before{content:"\f3fb"}.fa-school-flag:before{content:"\e56e"}.fa-fill:before{content:"\f575"}.fa-angle-up:before{content:"\f106"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-holly-berry:before{content:"\f7aa"}.fa-chevron-left:before{content:"\f053"}.fa-bacteria:before{content:"\e059"}.fa-hand-lizard:before{content:"\f258"}.fa-notdef:before{content:"\e1fe"}.fa-disease:before{content:"\f7fa"}.fa-briefcase-medical:before{content:"\f469"}.fa-genderless:before{content:"\f22d"}.fa-chevron-right:before{content:"\f054"}.fa-retweet:before{content:"\f079"}.fa-car-alt:before,.fa-car-rear:before{content:"\f5de"}.fa-pump-soap:before{content:"\e06b"}.fa-video-slash:before{content:"\f4e2"}.fa-battery-2:before,.fa-battery-quarter:before{content:"\f243"}.fa-radio:before{content:"\f8d7"}.fa-baby-carriage:before,.fa-carriage-baby:before{content:"\f77d"}.fa-traffic-light:before{content:"\f637"}.fa-thermometer:before{content:"\f491"}.fa-vr-cardboard:before{content:"\f729"}.fa-hand-middle-finger:before{content:"\f806"}.fa-percent:before,.fa-percentage:before{content:"\25"}.fa-truck-moving:before{content:"\f4df"}.fa-glass-water-droplet:before{content:"\e4f5"}.fa-display:before{content:"\e163"}.fa-face-smile:before,.fa-smile:before{content:"\f118"}.fa-thumb-tack:before,.fa-thumbtack:before{content:"\f08d"}.fa-trophy:before{content:"\f091"}.fa-person-praying:before,.fa-pray:before{content:"\f683"}.fa-hammer:before{content:"\f6e3"}.fa-hand-peace:before{content:"\f25b"}.fa-rotate:before,.fa-sync-alt:before{content:"\f2f1"}.fa-spinner:before{content:"\f110"}.fa-robot:before{content:"\f544"}.fa-peace:before{content:"\f67c"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-warehouse:before{content:"\f494"}.fa-arrow-up-right-dots:before{content:"\e4b7"}.fa-splotch:before{content:"\f5bc"}.fa-face-grin-hearts:before,.fa-grin-hearts:before{content:"\f584"}.fa-dice-four:before{content:"\f524"}.fa-sim-card:before{content:"\f7c4"}.fa-transgender-alt:before,.fa-transgender:before{content:"\f225"}.fa-mercury:before{content:"\f223"}.fa-arrow-turn-down:before,.fa-level-down:before{content:"\f149"}.fa-person-falling-burst:before{content:"\e547"}.fa-award:before{content:"\f559"}.fa-ticket-alt:before,.fa-ticket-simple:before{content:"\f3ff"}.fa-building:before{content:"\f1ad"}.fa-angle-double-left:before,.fa-angles-left:before{content:"\f100"}.fa-qrcode:before{content:"\f029"}.fa-clock-rotate-left:before,.fa-history:before{content:"\f1da"}.fa-face-grin-beam-sweat:before,.fa-grin-beam-sweat:before{content:"\f583"}.fa-arrow-right-from-file:before,.fa-file-export:before{content:"\f56e"}.fa-shield-blank:before,.fa-shield:before{content:"\f132"}.fa-arrow-up-short-wide:before,.fa-sort-amount-up-alt:before{content:"\f885"}.fa-house-medical:before{content:"\e3b2"}.fa-golf-ball-tee:before,.fa-golf-ball:before{content:"\f450"}.fa-chevron-circle-left:before,.fa-circle-chevron-left:before{content:"\f137"}.fa-house-chimney-window:before{content:"\e00d"}.fa-pen-nib:before{content:"\f5ad"}.fa-tent-arrow-turn-left:before{content:"\e580"}.fa-tents:before{content:"\e582"}.fa-magic:before,.fa-wand-magic:before{content:"\f0d0"}.fa-dog:before{content:"\f6d3"}.fa-carrot:before{content:"\f787"}.fa-moon:before{content:"\f186"}.fa-wine-glass-alt:before,.fa-wine-glass-empty:before{content:"\f5ce"}.fa-cheese:before{content:"\f7ef"}.fa-yin-yang:before{content:"\f6ad"}.fa-music:before{content:"\f001"}.fa-code-commit:before{content:"\f386"}.fa-temperature-low:before{content:"\f76b"}.fa-biking:before,.fa-person-biking:before{content:"\f84a"}.fa-broom:before{content:"\f51a"}.fa-shield-heart:before{content:"\e574"}.fa-gopuram:before{content:"\f664"}.fa-earth-oceania:before,.fa-globe-oceania:before{content:"\e47b"}.fa-square-xmark:before,.fa-times-square:before,.fa-xmark-square:before{content:"\f2d3"}.fa-hashtag:before{content:"\23"}.fa-expand-alt:before,.fa-up-right-and-down-left-from-center:before{content:"\f424"}.fa-oil-can:before{content:"\f613"}.fa-t:before{content:"\54"}.fa-hippo:before{content:"\f6ed"}.fa-chart-column:before{content:"\e0e3"}.fa-infinity:before{content:"\f534"}.fa-vial-circle-check:before{content:"\e596"}.fa-person-arrow-down-to-line:before{content:"\e538"}.fa-voicemail:before{content:"\f897"}.fa-fan:before{content:"\f863"}.fa-person-walking-luggage:before{content:"\e554"}.fa-arrows-alt-v:before,.fa-up-down:before{content:"\f338"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-calendar:before{content:"\f133"}.fa-trailer:before{content:"\e041"}.fa-bahai:before,.fa-haykal:before{content:"\f666"}.fa-sd-card:before{content:"\f7c2"}.fa-dragon:before{content:"\f6d5"}.fa-shoe-prints:before{content:"\f54b"}.fa-circle-plus:before,.fa-plus-circle:before{content:"\f055"}.fa-face-grin-tongue-wink:before,.fa-grin-tongue-wink:before{content:"\f58b"}.fa-hand-holding:before{content:"\f4bd"}.fa-plug-circle-exclamation:before{content:"\e55d"}.fa-chain-broken:before,.fa-chain-slash:before,.fa-link-slash:before,.fa-unlink:before{content:"\f127"}.fa-clone:before{content:"\f24d"}.fa-person-walking-arrow-loop-left:before{content:"\e551"}.fa-arrow-up-z-a:before,.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-fire-alt:before,.fa-fire-flame-curved:before{content:"\f7e4"}.fa-tornado:before{content:"\f76f"}.fa-file-circle-plus:before{content:"\e494"}.fa-book-quran:before,.fa-quran:before{content:"\f687"}.fa-anchor:before{content:"\f13d"}.fa-border-all:before{content:"\f84c"}.fa-angry:before,.fa-face-angry:before{content:"\f556"}.fa-cookie-bite:before{content:"\f564"}.fa-arrow-trend-down:before{content:"\e097"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-draw-polygon:before{content:"\f5ee"}.fa-balance-scale:before,.fa-scale-balanced:before{content:"\f24e"}.fa-gauge-simple-high:before,.fa-tachometer-fast:before,.fa-tachometer:before{content:"\f62a"}.fa-shower:before{content:"\f2cc"}.fa-desktop-alt:before,.fa-desktop:before{content:"\f390"}.fa-m:before{content:"\4d"}.fa-table-list:before,.fa-th-list:before{content:"\f00b"}.fa-comment-sms:before,.fa-sms:before{content:"\f7cd"}.fa-book:before{content:"\f02d"}.fa-user-plus:before{content:"\f234"}.fa-check:before{content:"\f00c"}.fa-battery-4:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-house-circle-check:before{content:"\e509"}.fa-angle-left:before{content:"\f104"}.fa-diagram-successor:before{content:"\e47a"}.fa-truck-arrow-right:before{content:"\e58b"}.fa-arrows-split-up-and-left:before{content:"\e4bc"}.fa-fist-raised:before,.fa-hand-fist:before{content:"\f6de"}.fa-cloud-moon:before{content:"\f6c3"}.fa-briefcase:before{content:"\f0b1"}.fa-person-falling:before{content:"\e546"}.fa-image-portrait:before,.fa-portrait:before{content:"\f3e0"}.fa-user-tag:before{content:"\f507"}.fa-rug:before{content:"\e569"}.fa-earth-europe:before,.fa-globe-europe:before{content:"\f7a2"}.fa-cart-flatbed-suitcase:before,.fa-luggage-cart:before{content:"\f59d"}.fa-rectangle-times:before,.fa-rectangle-xmark:before,.fa-times-rectangle:before,.fa-window-close:before{content:"\f410"}.fa-baht-sign:before{content:"\e0ac"}.fa-book-open:before{content:"\f518"}.fa-book-journal-whills:before,.fa-journal-whills:before{content:"\f66a"}.fa-handcuffs:before{content:"\e4f8"}.fa-exclamation-triangle:before,.fa-triangle-exclamation:before,.fa-warning:before{content:"\f071"}.fa-database:before{content:"\f1c0"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-bottle-droplet:before{content:"\e4c4"}.fa-mask-face:before{content:"\e1d7"}.fa-hill-rockslide:before{content:"\e508"}.fa-exchange-alt:before,.fa-right-left:before{content:"\f362"}.fa-paper-plane:before{content:"\f1d8"}.fa-road-circle-exclamation:before{content:"\e565"}.fa-dungeon:before{content:"\f6d9"}.fa-align-right:before{content:"\f038"}.fa-money-bill-1-wave:before,.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-life-ring:before{content:"\f1cd"}.fa-hands:before,.fa-sign-language:before,.fa-signing:before{content:"\f2a7"}.fa-calendar-day:before{content:"\f783"}.fa-ladder-water:before,.fa-swimming-pool:before,.fa-water-ladder:before{content:"\f5c5"}.fa-arrows-up-down:before,.fa-arrows-v:before{content:"\f07d"}.fa-face-grimace:before,.fa-grimace:before{content:"\f57f"}.fa-wheelchair-alt:before,.fa-wheelchair-move:before{content:"\e2ce"}.fa-level-down-alt:before,.fa-turn-down:before{content:"\f3be"}.fa-person-walking-arrow-right:before{content:"\e552"}.fa-envelope-square:before,.fa-square-envelope:before{content:"\f199"}.fa-dice:before{content:"\f522"}.fa-bowling-ball:before{content:"\f436"}.fa-brain:before{content:"\f5dc"}.fa-band-aid:before,.fa-bandage:before{content:"\f462"}.fa-calendar-minus:before{content:"\f272"}.fa-circle-xmark:before,.fa-times-circle:before,.fa-xmark-circle:before{content:"\f057"}.fa-gifts:before{content:"\f79c"}.fa-hotel:before{content:"\f594"}.fa-earth-asia:before,.fa-globe-asia:before{content:"\f57e"}.fa-id-card-alt:before,.fa-id-card-clip:before{content:"\f47f"}.fa-magnifying-glass-plus:before,.fa-search-plus:before{content:"\f00e"}.fa-thumbs-up:before{content:"\f164"}.fa-user-clock:before{content:"\f4fd"}.fa-allergies:before,.fa-hand-dots:before{content:"\f461"}.fa-file-invoice:before{content:"\f570"}.fa-window-minimize:before{content:"\f2d1"}.fa-coffee:before,.fa-mug-saucer:before{content:"\f0f4"}.fa-brush:before{content:"\f55d"}.fa-mask:before{content:"\f6fa"}.fa-magnifying-glass-minus:before,.fa-search-minus:before{content:"\f010"}.fa-ruler-vertical:before{content:"\f548"}.fa-user-alt:before,.fa-user-large:before{content:"\f406"}.fa-train-tram:before{content:"\e5b4"}.fa-user-nurse:before{content:"\f82f"}.fa-syringe:before{content:"\f48e"}.fa-cloud-sun:before{content:"\f6c4"}.fa-stopwatch-20:before{content:"\e06f"}.fa-square-full:before{content:"\f45c"}.fa-magnet:before{content:"\f076"}.fa-jar:before{content:"\e516"}.fa-note-sticky:before,.fa-sticky-note:before{content:"\f249"}.fa-bug-slash:before{content:"\e490"}.fa-arrow-up-from-water-pump:before{content:"\e4b6"}.fa-bone:before{content:"\f5d7"}.fa-user-injured:before{content:"\f728"}.fa-face-sad-tear:before,.fa-sad-tear:before{content:"\f5b4"}.fa-plane:before{content:"\f072"}.fa-tent-arrows-down:before{content:"\e581"}.fa-exclamation:before{content:"\21"}.fa-arrows-spin:before{content:"\e4bb"}.fa-print:before{content:"\f02f"}.fa-try:before,.fa-turkish-lira-sign:before,.fa-turkish-lira:before{content:"\e2bb"}.fa-dollar-sign:before,.fa-dollar:before,.fa-usd:before{content:"\24"}.fa-x:before{content:"\58"}.fa-magnifying-glass-dollar:before,.fa-search-dollar:before{content:"\f688"}.fa-users-cog:before,.fa-users-gear:before{content:"\f509"}.fa-person-military-pointing:before{content:"\e54a"}.fa-bank:before,.fa-building-columns:before,.fa-institution:before,.fa-museum:before,.fa-university:before{content:"\f19c"}.fa-umbrella:before{content:"\f0e9"}.fa-trowel:before{content:"\e589"}.fa-d:before{content:"\44"}.fa-stapler:before{content:"\e5af"}.fa-masks-theater:before,.fa-theater-masks:before{content:"\f630"}.fa-kip-sign:before{content:"\e1c4"}.fa-hand-point-left:before{content:"\f0a5"}.fa-handshake-alt:before,.fa-handshake-simple:before{content:"\f4c6"}.fa-fighter-jet:before,.fa-jet-fighter:before{content:"\f0fb"}.fa-share-alt-square:before,.fa-square-share-nodes:before{content:"\f1e1"}.fa-barcode:before{content:"\f02a"}.fa-plus-minus:before{content:"\e43c"}.fa-video-camera:before,.fa-video:before{content:"\f03d"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-person-circle-check:before{content:"\e53e"}.fa-level-up-alt:before,.fa-turn-up:before{content:"\f3bf"} +.fa-sr-only,.fa-sr-only-focusable:not(:focus),.sr-only,.sr-only-focusable:not(:focus){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}:host,:root{--fa-style-family-brands:"Font Awesome 6 Brands";--fa-font-brands:normal 400 1em/1 "Font Awesome 6 Brands"}@font-face{font-family:"Font Awesome 6 Brands";font-style:normal;font-weight:400;font-display:block;src:url(webfonts/fa-brands-400.woff2) format("woff2"),url(webfonts/fa-brands-400.ttf) format("truetype")}.fa-brands,.fab{font-weight:400}.fa-monero:before{content:"\f3d0"}.fa-hooli:before{content:"\f427"}.fa-yelp:before{content:"\f1e9"}.fa-cc-visa:before{content:"\f1f0"}.fa-lastfm:before{content:"\f202"}.fa-shopware:before{content:"\f5b5"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-aws:before{content:"\f375"}.fa-redhat:before{content:"\f7bc"}.fa-yoast:before{content:"\f2b1"}.fa-cloudflare:before{content:"\e07d"}.fa-ups:before{content:"\f7e0"}.fa-pixiv:before{content:"\e640"}.fa-wpexplorer:before{content:"\f2de"}.fa-dyalog:before{content:"\f399"}.fa-bity:before{content:"\f37a"}.fa-stackpath:before{content:"\f842"}.fa-buysellads:before{content:"\f20d"}.fa-first-order:before{content:"\f2b0"}.fa-modx:before{content:"\f285"}.fa-guilded:before{content:"\e07e"}.fa-vnv:before{content:"\f40b"}.fa-js-square:before,.fa-square-js:before{content:"\f3b9"}.fa-microsoft:before{content:"\f3ca"}.fa-qq:before{content:"\f1d6"}.fa-orcid:before{content:"\f8d2"}.fa-java:before{content:"\f4e4"}.fa-invision:before{content:"\f7b0"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-centercode:before{content:"\f380"}.fa-glide-g:before{content:"\f2a6"}.fa-drupal:before{content:"\f1a9"}.fa-jxl:before{content:"\e67b"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-unity:before{content:"\e049"}.fa-whmcs:before{content:"\f40d"}.fa-rocketchat:before{content:"\f3e8"}.fa-vk:before{content:"\f189"}.fa-untappd:before{content:"\f405"}.fa-mailchimp:before{content:"\f59e"}.fa-css3-alt:before{content:"\f38b"}.fa-reddit-square:before,.fa-square-reddit:before{content:"\f1a2"}.fa-vimeo-v:before{content:"\f27d"}.fa-contao:before{content:"\f26d"}.fa-square-font-awesome:before{content:"\e5ad"}.fa-deskpro:before{content:"\f38f"}.fa-brave:before{content:"\e63c"}.fa-sistrix:before{content:"\f3ee"}.fa-instagram-square:before,.fa-square-instagram:before{content:"\e055"}.fa-battle-net:before{content:"\f835"}.fa-the-red-yeti:before{content:"\f69d"}.fa-hacker-news-square:before,.fa-square-hacker-news:before{content:"\f3af"}.fa-edge:before{content:"\f282"}.fa-threads:before{content:"\e618"}.fa-napster:before{content:"\f3d2"}.fa-snapchat-square:before,.fa-square-snapchat:before{content:"\f2ad"}.fa-google-plus-g:before{content:"\f0d5"}.fa-artstation:before{content:"\f77a"}.fa-markdown:before{content:"\f60f"}.fa-sourcetree:before{content:"\f7d3"}.fa-google-plus:before{content:"\f2b3"}.fa-diaspora:before{content:"\f791"}.fa-foursquare:before{content:"\f180"}.fa-stack-overflow:before{content:"\f16c"}.fa-github-alt:before{content:"\f113"}.fa-phoenix-squadron:before{content:"\f511"}.fa-pagelines:before{content:"\f18c"}.fa-algolia:before{content:"\f36c"}.fa-red-river:before{content:"\f3e3"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-safari:before{content:"\f267"}.fa-google:before{content:"\f1a0"}.fa-font-awesome-alt:before,.fa-square-font-awesome-stroke:before{content:"\f35c"}.fa-atlassian:before{content:"\f77b"}.fa-linkedin-in:before{content:"\f0e1"}.fa-digital-ocean:before{content:"\f391"}.fa-nimblr:before{content:"\f5a8"}.fa-chromecast:before{content:"\f838"}.fa-evernote:before{content:"\f839"}.fa-hacker-news:before{content:"\f1d4"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-adversal:before{content:"\f36a"}.fa-creative-commons:before{content:"\f25e"}.fa-watchman-monitoring:before{content:"\e087"}.fa-fonticons:before{content:"\f280"}.fa-weixin:before{content:"\f1d7"}.fa-shirtsinbulk:before{content:"\f214"}.fa-codepen:before{content:"\f1cb"}.fa-git-alt:before{content:"\f841"}.fa-lyft:before{content:"\f3c3"}.fa-rev:before{content:"\f5b2"}.fa-windows:before{content:"\f17a"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-square-viadeo:before,.fa-viadeo-square:before{content:"\f2aa"}.fa-meetup:before{content:"\f2e0"}.fa-centos:before{content:"\f789"}.fa-adn:before{content:"\f170"}.fa-cloudsmith:before{content:"\f384"}.fa-opensuse:before{content:"\e62b"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-dribbble-square:before,.fa-square-dribbble:before{content:"\f397"}.fa-codiepie:before{content:"\f284"}.fa-node:before{content:"\f419"}.fa-mix:before{content:"\f3cb"}.fa-steam:before{content:"\f1b6"}.fa-cc-apple-pay:before{content:"\f416"}.fa-scribd:before{content:"\f28a"}.fa-debian:before{content:"\e60b"}.fa-openid:before{content:"\f19b"}.fa-instalod:before{content:"\e081"}.fa-expeditedssl:before{content:"\f23e"}.fa-sellcast:before{content:"\f2da"}.fa-square-twitter:before,.fa-twitter-square:before{content:"\f081"}.fa-r-project:before{content:"\f4f7"}.fa-delicious:before{content:"\f1a5"}.fa-freebsd:before{content:"\f3a4"}.fa-vuejs:before{content:"\f41f"}.fa-accusoft:before{content:"\f369"}.fa-ioxhost:before{content:"\f208"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-app-store:before{content:"\f36f"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-itunes-note:before{content:"\f3b5"}.fa-golang:before{content:"\e40f"}.fa-kickstarter:before,.fa-square-kickstarter:before{content:"\f3bb"}.fa-grav:before{content:"\f2d6"}.fa-weibo:before{content:"\f18a"}.fa-uncharted:before{content:"\e084"}.fa-firstdraft:before{content:"\f3a1"}.fa-square-youtube:before,.fa-youtube-square:before{content:"\f431"}.fa-wikipedia-w:before{content:"\f266"}.fa-rendact:before,.fa-wpressr:before{content:"\f3e4"}.fa-angellist:before{content:"\f209"}.fa-galactic-republic:before{content:"\f50c"}.fa-nfc-directional:before{content:"\e530"}.fa-skype:before{content:"\f17e"}.fa-joget:before{content:"\f3b7"}.fa-fedora:before{content:"\f798"}.fa-stripe-s:before{content:"\f42a"}.fa-meta:before{content:"\e49b"}.fa-laravel:before{content:"\f3bd"}.fa-hotjar:before{content:"\f3b1"}.fa-bluetooth-b:before{content:"\f294"}.fa-square-letterboxd:before{content:"\e62e"}.fa-sticker-mule:before{content:"\f3f7"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-hips:before{content:"\f452"}.fa-behance:before{content:"\f1b4"}.fa-reddit:before{content:"\f1a1"}.fa-discord:before{content:"\f392"}.fa-chrome:before{content:"\f268"}.fa-app-store-ios:before{content:"\f370"}.fa-cc-discover:before{content:"\f1f2"}.fa-wpbeginner:before{content:"\f297"}.fa-confluence:before{content:"\f78d"}.fa-shoelace:before{content:"\e60c"}.fa-mdb:before{content:"\f8ca"}.fa-dochub:before{content:"\f394"}.fa-accessible-icon:before{content:"\f368"}.fa-ebay:before{content:"\f4f4"}.fa-amazon:before{content:"\f270"}.fa-unsplash:before{content:"\e07c"}.fa-yarn:before{content:"\f7e3"}.fa-square-steam:before,.fa-steam-square:before{content:"\f1b7"}.fa-500px:before{content:"\f26e"}.fa-square-vimeo:before,.fa-vimeo-square:before{content:"\f194"}.fa-asymmetrik:before{content:"\f372"}.fa-font-awesome-flag:before,.fa-font-awesome-logo-full:before,.fa-font-awesome:before{content:"\f2b4"}.fa-gratipay:before{content:"\f184"}.fa-apple:before{content:"\f179"}.fa-hive:before{content:"\e07f"}.fa-gitkraken:before{content:"\f3a6"}.fa-keybase:before{content:"\f4f5"}.fa-apple-pay:before{content:"\f415"}.fa-padlet:before{content:"\e4a0"}.fa-amazon-pay:before{content:"\f42c"}.fa-github-square:before,.fa-square-github:before{content:"\f092"}.fa-stumbleupon:before{content:"\f1a4"}.fa-fedex:before{content:"\f797"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-shopify:before{content:"\e057"}.fa-neos:before{content:"\f612"}.fa-square-threads:before{content:"\e619"}.fa-hackerrank:before{content:"\f5f7"}.fa-researchgate:before{content:"\f4f8"}.fa-swift:before{content:"\f8e1"}.fa-angular:before{content:"\f420"}.fa-speakap:before{content:"\f3f3"}.fa-angrycreative:before{content:"\f36e"}.fa-y-combinator:before{content:"\f23b"}.fa-empire:before{content:"\f1d1"}.fa-envira:before{content:"\f299"}.fa-google-scholar:before{content:"\e63b"}.fa-gitlab-square:before,.fa-square-gitlab:before{content:"\e5ae"}.fa-studiovinari:before{content:"\f3f8"}.fa-pied-piper:before{content:"\f2ae"}.fa-wordpress:before{content:"\f19a"}.fa-product-hunt:before{content:"\f288"}.fa-firefox:before{content:"\f269"}.fa-linode:before{content:"\f2b8"}.fa-goodreads:before{content:"\f3a8"}.fa-odnoklassniki-square:before,.fa-square-odnoklassniki:before{content:"\f264"}.fa-jsfiddle:before{content:"\f1cc"}.fa-sith:before{content:"\f512"}.fa-themeisle:before{content:"\f2b2"}.fa-page4:before{content:"\f3d7"}.fa-hashnode:before{content:"\e499"}.fa-react:before{content:"\f41b"}.fa-cc-paypal:before{content:"\f1f4"}.fa-squarespace:before{content:"\f5be"}.fa-cc-stripe:before{content:"\f1f5"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-bitcoin:before{content:"\f379"}.fa-keycdn:before{content:"\f3ba"}.fa-opera:before{content:"\f26a"}.fa-itch-io:before{content:"\f83a"}.fa-umbraco:before{content:"\f8e8"}.fa-galactic-senate:before{content:"\f50d"}.fa-ubuntu:before{content:"\f7df"}.fa-draft2digital:before{content:"\f396"}.fa-stripe:before{content:"\f429"}.fa-houzz:before{content:"\f27c"}.fa-gg:before{content:"\f260"}.fa-dhl:before{content:"\f790"}.fa-pinterest-square:before,.fa-square-pinterest:before{content:"\f0d3"}.fa-xing:before{content:"\f168"}.fa-blackberry:before{content:"\f37b"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-playstation:before{content:"\f3df"}.fa-quinscape:before{content:"\f459"}.fa-less:before{content:"\f41d"}.fa-blogger-b:before{content:"\f37d"}.fa-opencart:before{content:"\f23d"}.fa-vine:before{content:"\f1ca"}.fa-signal-messenger:before{content:"\e663"}.fa-paypal:before{content:"\f1ed"}.fa-gitlab:before{content:"\f296"}.fa-typo3:before{content:"\f42b"}.fa-reddit-alien:before{content:"\f281"}.fa-yahoo:before{content:"\f19e"}.fa-dailymotion:before{content:"\e052"}.fa-affiliatetheme:before{content:"\f36b"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-bootstrap:before{content:"\f836"}.fa-odnoklassniki:before{content:"\f263"}.fa-nfc-symbol:before{content:"\e531"}.fa-mintbit:before{content:"\e62f"}.fa-ethereum:before{content:"\f42e"}.fa-speaker-deck:before{content:"\f83c"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-patreon:before{content:"\f3d9"}.fa-avianex:before{content:"\f374"}.fa-ello:before{content:"\f5f1"}.fa-gofore:before{content:"\f3a7"}.fa-bimobject:before{content:"\f378"}.fa-brave-reverse:before{content:"\e63d"}.fa-facebook-f:before{content:"\f39e"}.fa-google-plus-square:before,.fa-square-google-plus:before{content:"\f0d4"}.fa-web-awesome:before{content:"\e682"}.fa-mandalorian:before{content:"\f50f"}.fa-first-order-alt:before{content:"\f50a"}.fa-osi:before{content:"\f41a"}.fa-google-wallet:before{content:"\f1ee"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-periscope:before{content:"\f3da"}.fa-fulcrum:before{content:"\f50b"}.fa-cloudscale:before{content:"\f383"}.fa-forumbee:before{content:"\f211"}.fa-mizuni:before{content:"\f3cc"}.fa-schlix:before{content:"\f3ea"}.fa-square-xing:before,.fa-xing-square:before{content:"\f169"}.fa-bandcamp:before{content:"\f2d5"}.fa-wpforms:before{content:"\f298"}.fa-cloudversify:before{content:"\f385"}.fa-usps:before{content:"\f7e1"}.fa-megaport:before{content:"\f5a3"}.fa-magento:before{content:"\f3c4"}.fa-spotify:before{content:"\f1bc"}.fa-optin-monster:before{content:"\f23c"}.fa-fly:before{content:"\f417"}.fa-aviato:before{content:"\f421"}.fa-itunes:before{content:"\f3b4"}.fa-cuttlefish:before{content:"\f38c"}.fa-blogger:before{content:"\f37c"}.fa-flickr:before{content:"\f16e"}.fa-viber:before{content:"\f409"}.fa-soundcloud:before{content:"\f1be"}.fa-digg:before{content:"\f1a6"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-letterboxd:before{content:"\e62d"}.fa-symfony:before{content:"\f83d"}.fa-maxcdn:before{content:"\f136"}.fa-etsy:before{content:"\f2d7"}.fa-facebook-messenger:before{content:"\f39f"}.fa-audible:before{content:"\f373"}.fa-think-peaks:before{content:"\f731"}.fa-bilibili:before{content:"\e3d9"}.fa-erlang:before{content:"\f39d"}.fa-x-twitter:before{content:"\e61b"}.fa-cotton-bureau:before{content:"\f89e"}.fa-dashcube:before{content:"\f210"}.fa-42-group:before,.fa-innosoft:before{content:"\e080"}.fa-stack-exchange:before{content:"\f18d"}.fa-elementor:before{content:"\f430"}.fa-pied-piper-square:before,.fa-square-pied-piper:before{content:"\e01e"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-palfed:before{content:"\f3d8"}.fa-superpowers:before{content:"\f2dd"}.fa-resolving:before{content:"\f3e7"}.fa-xbox:before{content:"\f412"}.fa-square-web-awesome-stroke:before{content:"\e684"}.fa-searchengin:before{content:"\f3eb"}.fa-tiktok:before{content:"\e07b"}.fa-facebook-square:before,.fa-square-facebook:before{content:"\f082"}.fa-renren:before{content:"\f18b"}.fa-linux:before{content:"\f17c"}.fa-glide:before{content:"\f2a5"}.fa-linkedin:before{content:"\f08c"}.fa-hubspot:before{content:"\f3b2"}.fa-deploydog:before{content:"\f38e"}.fa-twitch:before{content:"\f1e8"}.fa-ravelry:before{content:"\f2d9"}.fa-mixer:before{content:"\e056"}.fa-lastfm-square:before,.fa-square-lastfm:before{content:"\f203"}.fa-vimeo:before{content:"\f40a"}.fa-mendeley:before{content:"\f7b3"}.fa-uniregistry:before{content:"\f404"}.fa-figma:before{content:"\f799"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-dropbox:before{content:"\f16b"}.fa-instagram:before{content:"\f16d"}.fa-cmplid:before{content:"\e360"}.fa-upwork:before{content:"\e641"}.fa-facebook:before{content:"\f09a"}.fa-gripfire:before{content:"\f3ac"}.fa-jedi-order:before{content:"\f50e"}.fa-uikit:before{content:"\f403"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-phabricator:before{content:"\f3db"}.fa-ussunnah:before{content:"\f407"}.fa-earlybirds:before{content:"\f39a"}.fa-trade-federation:before{content:"\f513"}.fa-autoprefixer:before{content:"\f41c"}.fa-whatsapp:before{content:"\f232"}.fa-square-upwork:before{content:"\e67c"}.fa-slideshare:before{content:"\f1e7"}.fa-google-play:before{content:"\f3ab"}.fa-viadeo:before{content:"\f2a9"}.fa-line:before{content:"\f3c0"}.fa-google-drive:before{content:"\f3aa"}.fa-servicestack:before{content:"\f3ec"}.fa-simplybuilt:before{content:"\f215"}.fa-bitbucket:before{content:"\f171"}.fa-imdb:before{content:"\f2d8"}.fa-deezer:before{content:"\e077"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-jira:before{content:"\f7b1"}.fa-docker:before{content:"\f395"}.fa-screenpal:before{content:"\e570"}.fa-bluetooth:before{content:"\f293"}.fa-gitter:before{content:"\f426"}.fa-d-and-d:before{content:"\f38d"}.fa-microblog:before{content:"\e01a"}.fa-cc-diners-club:before{content:"\f24c"}.fa-gg-circle:before{content:"\f261"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-yandex:before{content:"\f413"}.fa-readme:before{content:"\f4d5"}.fa-html5:before{content:"\f13b"}.fa-sellsy:before{content:"\f213"}.fa-square-web-awesome:before{content:"\e683"}.fa-sass:before{content:"\f41e"}.fa-wirsindhandwerk:before,.fa-wsh:before{content:"\e2d0"}.fa-buromobelexperte:before{content:"\f37f"}.fa-salesforce:before{content:"\f83b"}.fa-octopus-deploy:before{content:"\e082"}.fa-medapps:before{content:"\f3c6"}.fa-ns8:before{content:"\f3d5"}.fa-pinterest-p:before{content:"\f231"}.fa-apper:before{content:"\f371"}.fa-fort-awesome:before{content:"\f286"}.fa-waze:before{content:"\f83f"}.fa-bluesky:before{content:"\e671"}.fa-cc-jcb:before{content:"\f24b"}.fa-snapchat-ghost:before,.fa-snapchat:before{content:"\f2ab"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-rust:before{content:"\e07a"}.fa-wix:before{content:"\f5cf"}.fa-behance-square:before,.fa-square-behance:before{content:"\f1b5"}.fa-supple:before{content:"\f3f9"}.fa-webflow:before{content:"\e65c"}.fa-rebel:before{content:"\f1d0"}.fa-css3:before{content:"\f13c"}.fa-staylinked:before{content:"\f3f5"}.fa-kaggle:before{content:"\f5fa"}.fa-space-awesome:before{content:"\e5ac"}.fa-deviantart:before{content:"\f1bd"}.fa-cpanel:before{content:"\f388"}.fa-goodreads-g:before{content:"\f3a9"}.fa-git-square:before,.fa-square-git:before{content:"\f1d2"}.fa-square-tumblr:before,.fa-tumblr-square:before{content:"\f174"}.fa-trello:before{content:"\f181"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-get-pocket:before{content:"\f265"}.fa-perbyte:before{content:"\e083"}.fa-grunt:before{content:"\f3ad"}.fa-weebly:before{content:"\f5cc"}.fa-connectdevelop:before{content:"\f20e"}.fa-leanpub:before{content:"\f212"}.fa-black-tie:before{content:"\f27e"}.fa-themeco:before{content:"\f5c6"}.fa-python:before{content:"\f3e2"}.fa-android:before{content:"\f17b"}.fa-bots:before{content:"\e340"}.fa-free-code-camp:before{content:"\f2c5"}.fa-hornbill:before{content:"\f592"}.fa-js:before{content:"\f3b8"}.fa-ideal:before{content:"\e013"}.fa-git:before{content:"\f1d3"}.fa-dev:before{content:"\f6cc"}.fa-sketch:before{content:"\f7c6"}.fa-yandex-international:before{content:"\f414"}.fa-cc-amex:before{content:"\f1f3"}.fa-uber:before{content:"\f402"}.fa-github:before{content:"\f09b"}.fa-php:before{content:"\f457"}.fa-alipay:before{content:"\f642"}.fa-youtube:before{content:"\f167"}.fa-skyatlas:before{content:"\f216"}.fa-firefox-browser:before{content:"\e007"}.fa-replyd:before{content:"\f3e6"}.fa-suse:before{content:"\f7d6"}.fa-jenkins:before{content:"\f3b6"}.fa-twitter:before{content:"\f099"}.fa-rockrms:before{content:"\f3e9"}.fa-pinterest:before{content:"\f0d2"}.fa-buffer:before{content:"\f837"}.fa-npm:before{content:"\f3d4"}.fa-yammer:before{content:"\f840"}.fa-btc:before{content:"\f15a"}.fa-dribbble:before{content:"\f17d"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-internet-explorer:before{content:"\f26b"}.fa-stubber:before{content:"\e5c7"}.fa-telegram-plane:before,.fa-telegram:before{content:"\f2c6"}.fa-old-republic:before{content:"\f510"}.fa-odysee:before{content:"\e5c6"}.fa-square-whatsapp:before,.fa-whatsapp-square:before{content:"\f40c"}.fa-node-js:before{content:"\f3d3"}.fa-edge-legacy:before{content:"\e078"}.fa-slack-hash:before,.fa-slack:before{content:"\f198"}.fa-medrt:before{content:"\f3c8"}.fa-usb:before{content:"\f287"}.fa-tumblr:before{content:"\f173"}.fa-vaadin:before{content:"\f408"}.fa-quora:before{content:"\f2c4"}.fa-square-x-twitter:before{content:"\e61a"}.fa-reacteurope:before{content:"\f75d"}.fa-medium-m:before,.fa-medium:before{content:"\f23a"}.fa-amilia:before{content:"\f36d"}.fa-mixcloud:before{content:"\f289"}.fa-flipboard:before{content:"\f44d"}.fa-viacoin:before{content:"\f237"}.fa-critical-role:before{content:"\f6c9"}.fa-sitrox:before{content:"\e44a"}.fa-discourse:before{content:"\f393"}.fa-joomla:before{content:"\f1aa"}.fa-mastodon:before{content:"\f4f6"}.fa-airbnb:before{content:"\f834"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-buy-n-large:before{content:"\f8a6"}.fa-gulp:before{content:"\f3ae"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-strava:before{content:"\f428"}.fa-ember:before{content:"\f423"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-teamspeak:before{content:"\f4f9"}.fa-pushed:before{content:"\f3e1"}.fa-wordpress-simple:before{content:"\f411"}.fa-nutritionix:before{content:"\f3d6"}.fa-wodu:before{content:"\e088"}.fa-google-pay:before{content:"\e079"}.fa-intercom:before{content:"\f7af"}.fa-zhihu:before{content:"\f63f"}.fa-korvue:before{content:"\f42f"}.fa-pix:before{content:"\e43a"}.fa-steam-symbol:before{content:"\f3f6"}:host,:root{--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(webfonts/fa-regular-400.woff2) format("woff2"),url(webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(webfonts/fa-solid-900.woff2) format("woff2"),url(webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(webfonts/fa-brands-400.woff2) format("woff2"),url(webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(webfonts/fa-solid-900.woff2) format("woff2"),url(webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(webfonts/fa-regular-400.woff2) format("woff2"),url(webfonts/fa-regular-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(webfonts/fa-solid-900.woff2) format("woff2"),url(webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(webfonts/fa-brands-400.woff2) format("woff2"),url(webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(webfonts/fa-regular-400.woff2) format("woff2"),url(webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(webfonts/fa-v4compatibility.woff2) format("woff2"),url(webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a} \ No newline at end of file diff --git a/assets/vendor/fontawesome/webfonts/fa-brands-400.ttf b/assets/vendor/fontawesome/webfonts/fa-brands-400.ttf new file mode 100644 index 0000000..1fbb1f7 Binary files /dev/null and b/assets/vendor/fontawesome/webfonts/fa-brands-400.ttf differ diff --git a/assets/vendor/fontawesome/webfonts/fa-brands-400.woff2 b/assets/vendor/fontawesome/webfonts/fa-brands-400.woff2 new file mode 100644 index 0000000..5d28021 Binary files /dev/null and b/assets/vendor/fontawesome/webfonts/fa-brands-400.woff2 differ diff --git a/assets/vendor/fontawesome/webfonts/fa-regular-400.ttf b/assets/vendor/fontawesome/webfonts/fa-regular-400.ttf new file mode 100644 index 0000000..549d68d Binary files /dev/null and b/assets/vendor/fontawesome/webfonts/fa-regular-400.ttf differ diff --git a/assets/vendor/fontawesome/webfonts/fa-regular-400.woff2 b/assets/vendor/fontawesome/webfonts/fa-regular-400.woff2 new file mode 100644 index 0000000..18400d7 Binary files /dev/null and b/assets/vendor/fontawesome/webfonts/fa-regular-400.woff2 differ diff --git a/assets/vendor/fontawesome/webfonts/fa-solid-900.ttf b/assets/vendor/fontawesome/webfonts/fa-solid-900.ttf new file mode 100644 index 0000000..bb2a869 Binary files /dev/null and b/assets/vendor/fontawesome/webfonts/fa-solid-900.ttf differ diff --git a/assets/vendor/fontawesome/webfonts/fa-solid-900.woff2 b/assets/vendor/fontawesome/webfonts/fa-solid-900.woff2 new file mode 100644 index 0000000..758dd4f Binary files /dev/null and b/assets/vendor/fontawesome/webfonts/fa-solid-900.woff2 differ diff --git a/assets/vendor/fontawesome/webfonts/fa-v4compatibility.ttf b/assets/vendor/fontawesome/webfonts/fa-v4compatibility.ttf new file mode 100644 index 0000000..8c5864c Binary files /dev/null and b/assets/vendor/fontawesome/webfonts/fa-v4compatibility.ttf differ diff --git a/assets/vendor/fontawesome/webfonts/fa-v4compatibility.woff2 b/assets/vendor/fontawesome/webfonts/fa-v4compatibility.woff2 new file mode 100644 index 0000000..f94bec2 Binary files /dev/null and b/assets/vendor/fontawesome/webfonts/fa-v4compatibility.woff2 differ diff --git a/avatar_upload.php b/avatar_upload.php new file mode 100644 index 0000000..c260c76 --- /dev/null +++ b/avatar_upload.php @@ -0,0 +1,163 @@ + false, 'msg' => '请先登录']); + exit; +} + +$user = currentUser(); +if (!$user) { + http_response_code(401); + echo json_encode(['ok' => false, 'msg' => '登录已过期']); + exit; +} + +// 检查文件上传 +if (!isset($_FILES['avatar']) || !is_array($_FILES['avatar']) || $_FILES['avatar']['error'] !== UPLOAD_ERR_OK) { + $errMap = [ + UPLOAD_ERR_INI_SIZE => '文件超过服务器限制(php.ini 中 upload_max_filesize)', + UPLOAD_ERR_FORM_SIZE => '文件超过表单限制', + UPLOAD_ERR_PARTIAL => '文件上传不完整', + UPLOAD_ERR_NO_FILE => '未选择文件', + UPLOAD_ERR_NO_TMP_DIR => '服务器临时目录不存在', + UPLOAD_ERR_CANT_WRITE => '服务器写入失败', + UPLOAD_ERR_EXTENSION => '文件类型被禁止', + ]; + $code = $_FILES['avatar']['error'] ?? UPLOAD_ERR_NO_FILE; + echo json_encode(['ok' => false, 'msg' => $errMap[$code] ?? '上传错误,请重试']); + exit; +} + +$file = $_FILES['avatar']; + +// ─── 类型与尺寸校验(多重兜底)────────────────────────────────────── +$allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + +// 1) getimagesize:同时拿到尺寸与 MIME,几乎在所有环境可用 +$imgInfo = @getimagesize($file['tmp_name']); +$mime = ($imgInfo && !empty($imgInfo['mime'])) ? $imgInfo['mime'] : ''; + +// 2) 若 getimagesize 没拿到 MIME,再尝试 finfo / mime_content_type +if (!in_array($mime, $allowed, true)) { + if (function_exists('finfo_open')) { + $fi = @finfo_open(FILEINFO_MIME_TYPE); + if ($fi) { + $m = @finfo_file($fi, $file['tmp_name']); + finfo_close($fi); + if ($m) { + $mime = $m; + } + } + } + if (!in_array($mime, $allowed, true) && function_exists('mime_content_type')) { + $mime = @mime_content_type($file['tmp_name']); + } +} + +if (!in_array($mime, $allowed, true)) { + echo json_encode(['ok' => false, 'msg' => '仅支持 JPG / PNG / WebP / GIF 图片']); + exit; +} + +// 尺寸检查(至少 64x64) +if (!$imgInfo || $imgInfo[0] < 64 || $imgInfo[1] < 64) { + echo json_encode(['ok' => false, 'msg' => '图片尺寸不能小于 64×64 像素']); + exit; +} + +// 大小检查(2MB) +if ($file['size'] > 2 * 1024 * 1024) { + echo json_encode(['ok' => false, 'msg' => '图片大小不能超过 2MB']); + exit; +} + +// ─── 确保上传目录存在 ───────────────────────────────────────────── +$uploadDir = __DIR__ . '/storage/uploads/avatars'; +if (!is_dir($uploadDir)) { + if (!@mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) { + echo json_encode(['ok' => false, 'msg' => '服务器无法创建上传目录(请检查 storage/uploads 目录权限)']); + exit; + } +} +if (!is_writable($uploadDir)) { + echo json_encode(['ok' => false, 'msg' => '上传目录不可写(请给 storage/uploads/avatars 目录写入权限)']); + exit; +} + +// ─── 生成唯一文件名 ─────────────────────────────────────────────── +$extMap = [IMAGETYPE_JPEG => 'jpg', IMAGETYPE_PNG => 'png', IMAGETYPE_GIF => 'gif', IMAGETYPE_WEBP => 'webp']; +$ext = $extMap[$imgInfo[2]] ?? 'png'; +$fileName = $user['id'] . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext; +$filePath = $uploadDir . '/' . $fileName; + +// ─── 移动上传文件(move 失败则回退 copy)───────────────────────── +$moved = false; +if (@move_uploaded_file($file['tmp_name'], $filePath)) { + $moved = true; +} elseif (@copy($file['tmp_name'], $filePath)) { + $moved = true; +} +if (!$moved || !is_file($filePath)) { + echo json_encode(['ok' => false, 'msg' => '文件保存失败(请检查 storage/uploads/avatars 目录权限)']); + exit; +} + +// ─── 确保 users 表存在 avatar 列(未跑升级脚本时兜底)───────────── +try { + $chk = db()->query("SHOW COLUMNS FROM " . tn('users') . " LIKE 'avatar'"); + if ($chk && $chk->rowCount() === 0) { + db()->exec("ALTER TABLE " . tn('users') . " ADD COLUMN avatar VARCHAR(255) NULL DEFAULT NULL AFTER invited_by"); + } +} catch (Throwable $e) { + // 忽略:以实际 UPDATE 结果为准 +} + +// ─── 删除旧头像 ─────────────────────────────────────────────────── +$oldAvatar = $user['avatar'] ?? ''; +if ($oldAvatar && strpos($oldAvatar, 'storage/uploads/avatars') !== false) { + $oldPath = __DIR__ . '/' . $oldAvatar; + if (file_exists($oldPath) && is_file($oldPath)) { + @unlink($oldPath); + } +} + +// ─── 更新数据库 ─────────────────────────────────────────────────── +$dbAvatar = 'storage/uploads/avatars/' . $fileName; +try { + db()->prepare('UPDATE ' . tn('users') . ' SET avatar=? WHERE id=?') + ->execute([$dbAvatar, $user['id']]); +} catch (Throwable $e) { + // 列确实不存在且兜底 ALTER 也失败时才走到这里 + echo json_encode(['ok' => false, 'msg' => '数据库更新失败:' . $e->getMessage()]); + exit; +} + +// 清除会话缓存,使其它页面重新读取最新头像 +unset($_SESSION['user_id'], $_SESSION['user']); + +Logger::info('用户上传头像', ['user_id' => $user['id'], 'file' => $fileName]); + +echo json_encode([ + 'ok' => true, + 'msg' => '头像更新成功', + // 返回相对站点根的路径,前端与导航栏展示保持一致 + 'data' => ['url' => $dbAvatar] +]); +exit; diff --git a/captcha.php b/captcha.php new file mode 100644 index 0000000..645228d --- /dev/null +++ b/captcha.php @@ -0,0 +1,50 @@ + $qv) { + $pid = (int) $pid; + $qv = max(0, (int) $qv); + if ($qv <= 0) { + unset($cart[$pid]); + } else { + $cart[$pid] = $qv; + } + } + $_SESSION['cart'] = $cart; + } + redirect('cart.php'); +} + +$items = []; +if (!empty($cart)) { + $ids = array_keys($cart); + $ph = implode(',', array_fill(0, count($ids), '?')); + $stmt = db()->prepare('SELECT * FROM ' . tn('products') . ' WHERE id IN (' . $ph . ') AND status = 1'); + $stmt->execute($ids); + foreach ($stmt->fetchAll() as $p) { + $qty = (int) $cart[$p['id']]; + if ($qty > $p['stock']) $qty = $p['stock']; // 超出库存则裁剪 + $pointsSub = (!empty($p['points_price']) ? (int)$p['points_price'] * $qty : 0); + $items[] = ['p' => $p, 'qty' => $qty, 'points_sub' => $pointsSub]; + } +} + +require 'includes/header.php'; +?> + +
    +

    + + +

    ' . __('cart_page.empty_link') . '', __('cart_page.empty')) ?>

    + +
    + +
    + +
    + + + +
    + + +
    + +
    +
    + +
    +
    0 ? (int)$it['points_sub'] . ' ' . __('common.points') : __('cart_page.subtotal_free') ?>
    + +
    + +
    + +
    +
    + + + +
    +
    +
    + +
    + + diff --git a/checkout.php b/checkout.php new file mode 100644 index 0000000..db62ec9 --- /dev/null +++ b/checkout.php @@ -0,0 +1,153 @@ +prepare('SELECT * FROM ' . tn('products') . ' WHERE id IN (' . $ph . ') AND status = 1'); + $stmt->execute($ids); + foreach ($stmt->fetchAll() as $p) { + $qty = (int) $cart[$p['id']]; + if ($qty > $p['stock']) $qty = $p['stock']; + $pointsSub = (!empty($p['points_price']) ? (int)$p['points_price'] * $qty : 0); + $items[] = ['p' => $p, 'qty' => $qty, 'points_sub' => $pointsSub]; + } +} + +// 积分支付可用性:仅当订单内所有商品都设置了积分价 +$pointsElg = !empty($items); +$pointsTotal = 0; +foreach ($items as $it) { + $pp = (int) ($it['p']['points_price'] ?? 0); + if ($pp <= 0) { $pointsElg = false; break; } + $pointsTotal += $pp * $it['qty']; +} +if ($pointsTotal <= 0) { $pointsElg = false; } +$userPoints = 0; +if (isLoggedIn()) { + $userPoints = getUserPoints($user['id']); +} + +$err = ''; +if (empty($items)) { + redirect('cart.php'); +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verifyCsrf(); + $contact = trim($_POST['contact'] ?? ''); + $contactEmail = trim($_POST['contact_email'] ?? ''); + $address = trim($_POST['address'] ?? ''); + $note = trim($_POST['note'] ?? ''); + if ($contact === '') $err = '请填写联系人'; + elseif ($address === '') $err = '请填写收货地址'; + elseif ($contactEmail === '') $contactEmail = $user['email']; // 留空则使用账号邮箱 + elseif (!filter_var($contactEmail, FILTER_VALIDATE_EMAIL)) $err = '联系邮箱格式不正确'; + + if ($err === '') { + $payType = ($pointsElg && ($_POST['pay_type'] ?? '') === 'points') ? 'points' : 'direct'; + if ($payType === 'points') { + if ($userPoints < $pointsTotal) { + $err = '积分不足,当前 ' . $userPoints . ' 分,本单需 ' . $pointsTotal . ' 分'; + } + } + } + if ($err === '') { + $pdo = db(); + try { + $pdo->beginTransaction(); + // 二次校验库存 + foreach ($items as $it) { + $chk = $pdo->prepare('SELECT stock FROM ' . tn('products') . ' WHERE id = ? FOR UPDATE'); + $chk->execute([$it['p']['id']]); + $row = $chk->fetch(); + if (!$row || $row['stock'] < $it['qty']) { + throw new Exception('「' . $it['p']['name'] . '」库存不足,请返回购物车调整'); + } + } + // 计算本单周期(取商品中最大的周期天数)与到期时间 + $maxPeriod = 0; + foreach ($items as $it) { + $maxPeriod = max($maxPeriod, (int) ($it['p']['period_days'] ?? 0)); + } + $expiresAt = $maxPeriod > 0 ? date('Y-m-d H:i:s', strtotime("+$maxPeriod days")) : null; + + // 本商城不收取现金,现金合计固定记 0;积分支付时抵扣并记流水 + $orderTotal = 0; + $pointsUsed = 0; + if ($payType === 'points') { + $pointsUsed = $pointsTotal; + $pdo->prepare('UPDATE ' . tn('users') . ' SET points = points - ? WHERE id = ? AND points >= ?') + ->execute([$pointsUsed, $user['id'], $pointsUsed]); + $bal = getUserPoints($user['id']); + $pdo->prepare('INSERT INTO ' . tn('points_log') . ' (user_id,type,amount,balance,remark,created_at) VALUES (?,?,?,?,?,NOW())') + ->execute([$user['id'], 'purchase', -$pointsUsed, $bal, '积分兑换:' . $orderNo]); + } + + // 生成订单号 + $orderNo = 'FNW' . date('Ymd') . strtoupper(substr(md5(uniqid($user['id'], true)), 0, 10)); + $pdo->prepare('INSERT INTO ' . tn('orders') . ' (order_no,user_id,total,status,contact,contact_email,address,note,period_days,expires_at,pay_type,points_used,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NOW())') + ->execute([$orderNo, $user['id'], $orderTotal, 'paid', $contact, $contactEmail, $address, $note, $maxPeriod, $expiresAt, $payType, $pointsUsed]); + $orderId = $pdo->lastInsertId(); + foreach ($items as $it) { + // 商城已取消现金支付,价格字段保留为 0 供历史兼容 + $pdo->prepare('INSERT INTO ' . tn('order_items') . ' (order_id,product_id,name,price,qty,subtotal,period_days) VALUES (?,?,?,?,?,?,?)') + ->execute([$orderId, $it['p']['id'], $it['p']['name'], 0, $it['qty'], 0, (int) ($it['p']['period_days'] ?? 0)]); + $pdo->prepare('UPDATE ' . tn('products') . ' SET stock = stock - ? WHERE id = ?') + ->execute([$it['qty'], $it['p']['id']]); + } + $pdo->commit(); + $_SESSION['cart'] = []; + redirect('myorders.php?paid=' . urlencode($orderNo)); + } catch (Exception $e) { + $pdo->rollBack(); + $err = $e->getMessage(); + } + } +} + +require 'includes/header.php'; +?> +
    +

    订单结算

    +
    +
    +

    商品清单

    + +
    + × + 0 ? (int)$it['points_sub'] . ' 积分' : '直接下单' ?> +
    + +
    + +
    + +

    收货信息

    +

    + + + + + +
    +

    支付方式

    + +

    积分支付( 积分)当前积分

    +
    + + + +

    下单后如有积分价格要求,用积分支付;无积分价格,直接支付即可(发工单即代表您已同意资源领取/续期规定)。

    + +
    +
    +
    + diff --git a/cron_expire_notify.php b/cron_expire_notify.php new file mode 100644 index 0000000..e4c5654 --- /dev/null +++ b/cron_expire_notify.php @@ -0,0 +1,118 @@ + NOW() + AND o.expires_at >= ? AND o.expires_at <= ? + AND o.expire_notify_sent = 0 + AND o.status IN (\'paid\', \'shipped\', \'completed\')'; +$stmt = db()->prepare($sql); +$stmt->execute([$lo, $hi]); +$rows = $stmt->fetchAll(); + +$sent = 0; +$skipped = 0; +$errors = []; +$preview = []; + +foreach ($rows as $row) { + $to = !empty($row['contact_email']) ? $row['contact_email'] : $row['u_email']; + if (empty($to)) { + $skipped++; + $errors[] = '订单 #' . $row['id'] . ' 无收件邮箱,已跳过'; + continue; + } + $daysLeft = (int) ceil((strtotime($row['expires_at']) - time()) / 86400); + $name = $row['nickname'] ?: $row['username']; + $subject = '[' . SITE_NAME . '] 您的服务即将到期(剩余 ' . $daysLeft . ' 天),请及时续期'; + $body = buildExpireMail($name, $row['order_no'], $row['expires_at'], $daysLeft); + if ($dryRun) { + $preview[] = '预览 #' . $row['id'] . ' → ' . $to . ' | 剩余 ' . $daysLeft . ' 天'; + $sent++; + continue; + } + $res = fnwSendMail($to, $subject, $body); + if (!empty($res['ok'])) { + db()->prepare('UPDATE ' . tn('orders') . ' SET expire_notify_sent = 1 WHERE id = ?')->execute([$row['id']]); + $sent++; + } else { + $errors[] = '订单 #' . $row['id'] . ' 发送失败:' . ($res['error'] ?? '未知错误'); + } +} + +$out = []; +$out[] = '[' . date('Y-m-d H:i:s') . '] 到期前提醒任务' . ($dryRun ? '(预览模式)' : ''); +$out[] = '扫描窗口:' . $lo . ' ~ ' . $hi . '(距到期约 ' . ($notifyDays - 1) . '~' . ($notifyDays + 1) . ' 天)'; +$out[] = '命中订单:' . count($rows) . ' 笔;已发送:' . $sent . ';跳过:' . $skipped; +if ($dryRun && $preview) { + $out[] = '--- 预览列表 ---'; + $out = array_merge($out, $preview); +} +if ($errors) { + $out[] = '--- 注意事项 ---'; + $out = array_merge($out, $errors); +} +$out[] = ''; +$text = implode("\n", $out); +if ($isCli) { + fwrite(STDOUT, $text); +} else { + echo $text; +} + +/** + * 生成到期提醒邮件正文(HTML)。 + */ +function buildExpireMail($name, $orderNo, $expiresAt, $daysLeft) +{ + $exp = date('Y-m-d H:i', strtotime($expiresAt)); + $site = SITE_NAME; + return '
    ' + . '

    服务即将到期提醒

    ' + . '

    你好 ' . h($name) . ',你在 ' . h($site) . ' 的订单 ' . h($orderNo) . ' 所对应的服务即将到期。

    ' + . '
    ' + . '

    到期时间:' . h($exp) . '

    ' + . '

    剩余天数:约 ' . (int) $daysLeft . ' 天

    ' + . '
    ' + . '

    为避免服务中断,请在到期前通过「我的订单 → 对应订单 → 申请续期」提交工单,或联系客服办理续期。

    ' + . '

    若已办理续期请忽略此邮件。本邮件由系统自动发送,请勿直接回复。

    ' + . '
    '; +} diff --git a/db.sql b/db.sql new file mode 100644 index 0000000..07fc410 --- /dev/null +++ b/db.sql @@ -0,0 +1,280 @@ +-- ================================= +-- 自由云商城 数据库结构 + 初始数据 +-- 安装程序会自动把 __PREFIX__ 替换为用户填写的表前缀 +-- 编码:utf8mb4 / InnoDB +-- ================================= + +-- 用户表(普通用户与后台管理员共用,is_admin=1 为管理员;status=0 表示被封禁) +CREATE TABLE `__PREFIX__users` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `username` VARCHAR(50) NOT NULL, + `password` VARCHAR(255) NOT NULL, + `email` VARCHAR(120) NOT NULL DEFAULT '', + `is_admin` TINYINT(1) NOT NULL DEFAULT 0, + `role` VARCHAR(10) NOT NULL DEFAULT 'user' COMMENT 'admin管理员 cs客服 user普通用户(后台登录用 role IN admin/cs)', + `avatar` VARCHAR(255) DEFAULT '' COMMENT '头像文件路径', + `status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1正常 0已封禁', + `nickname` VARCHAR(60) NOT NULL DEFAULT '' COMMENT '昵称', + `verified` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '邮箱是否验证 0未验证 1已验证', + `email_token` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '邮箱验证令牌', + `reg_ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '注册 IP(限频用)', + `points` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '积分余额', + `invite_code` VARCHAR(12) NOT NULL DEFAULT '' COMMENT '我的邀请码(唯一,用于邀请奖励)', + `invited_by` INT UNSIGNED DEFAULT NULL COMMENT '邀请人 user_id(通过邀请注册时记录)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`), + UNIQUE KEY `uk_invite_code` (`invite_code`), + KEY `idx_invited_by` (`invited_by`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 商品表 +CREATE TABLE `__PREFIX__products` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(120) NOT NULL, + `price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `stock` INT UNSIGNED NOT NULL DEFAULT 0, + `category` VARCHAR(40) NOT NULL DEFAULT '其他', + `icon` VARCHAR(40) NOT NULL DEFAULT 'fa-box', + `image` VARCHAR(255) DEFAULT '', + `description` TEXT, + `period_days` INT NOT NULL DEFAULT 0 COMMENT '周期天数:0=一次性/实物,>0=按天计的周期商品(如 30/90/365)', + `points_price` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '积分价:0=不可用积分购买,>0=可用该积分数购买', + `status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1上架 0下架', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_category` (`category`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 商品分组(后台可增删,用于首页分类筛选与商品归类) +CREATE TABLE `__PREFIX__product_groups` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(40) NOT NULL, + `icon` VARCHAR(40) NOT NULL DEFAULT 'fa-tags', + `sort_order` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 订单表(status: pending待付款 paid已付款 shipped已发货 completed已完成 cancelled已取消) +CREATE TABLE `__PREFIX__orders` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_no` VARCHAR(32) NOT NULL, + `user_id` INT UNSIGNED NOT NULL, + `total` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `status` VARCHAR(20) NOT NULL DEFAULT 'pending', + `contact` VARCHAR(60) DEFAULT '', + `contact_email` VARCHAR(120) DEFAULT '' COMMENT '联系邮箱(下单人填写)', + `address` VARCHAR(255) DEFAULT '', + `note` VARCHAR(255) DEFAULT '', + `period_days` INT NOT NULL DEFAULT 0 COMMENT '本单周期天数(取商品最大值)', + `expires_at` DATETIME DEFAULT NULL COMMENT '到期时间(周期商品有值)', + `expire_notify_sent` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '到期前提醒邮件是否已发送 0未发 1已发', + `pay_type` VARCHAR(10) NOT NULL DEFAULT 'cash' COMMENT 'cash现金 points积分支付', + `points_used` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '积分支付时扣减的积分数', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_no` (`order_no`), + KEY `idx_user` (`user_id`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 订单明细表 +CREATE TABLE `__PREFIX__order_items` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_id` INT UNSIGNED NOT NULL, + `product_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(120) NOT NULL, + `price` DECIMAL(10,2) NOT NULL, + `qty` INT UNSIGNED NOT NULL, + `subtotal` DECIMAL(10,2) NOT NULL, + `period_days` INT NOT NULL DEFAULT 0 COMMENT '下单时商品的周期天数', + PRIMARY KEY (`id`), + KEY `idx_order` (`order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 发货信息表(管理员发货时填写:连接信息/登录凭据) +CREATE TABLE `__PREFIX__order_deliveries` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_id` INT UNSIGNED NOT NULL, + `server_ip` VARCHAR(60) DEFAULT '' COMMENT '服务器 IP', + `conn_addr` VARCHAR(255) DEFAULT '' COMMENT '连接地址/域名', + `login_user` VARCHAR(120) DEFAULT '' COMMENT '登录账户', + `login_pass` VARCHAR(255) DEFAULT '' COMMENT '登录密码', + `remark` TEXT, + `activated` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '激活状态:0未激活 1已激活(管理员填写,用户端展示)', + `product_info` TEXT COMMENT '商品信息(管理员填写后展示给用户)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_order` (`order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 系统设置表(SMTP 等键值对) +CREATE TABLE `__PREFIX__settings` ( + `k` VARCHAR(64) NOT NULL, + `v` TEXT, + PRIMARY KEY (`k`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 工单部门表 +CREATE TABLE `__PREFIX__ticket_departments` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(60) NOT NULL, + `description` VARCHAR(255) DEFAULT '', + `sort` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 工单表 +CREATE TABLE `__PREFIX__tickets` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `dept_id` INT UNSIGNED NOT NULL DEFAULT 0, + `order_id` INT UNSIGNED DEFAULT NULL COMMENT '关联订单(续期工单用)', + `type` VARCHAR(20) NOT NULL DEFAULT 'general' COMMENT 'general普通 renewal续期申请', + `subject` VARCHAR(160) NOT NULL, + `message` TEXT NOT NULL, + `status` VARCHAR(20) NOT NULL DEFAULT 'open' COMMENT 'open待处理 pending处理中 resolved已解决 closed已关闭', + `priority` TINYINT(1) NOT NULL DEFAULT 2 COMMENT '1低 2普通 3高', + `admin_reply` TEXT, + `admin_id` INT UNSIGNED DEFAULT NULL, + `assignee_id` INT UNSIGNED DEFAULT NULL COMMENT '负责人(客服/管理员)用户ID,NULL=未分配(仅管理员可见)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `closed_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + KEY `idx_user` (`user_id`), + KEY `idx_dept` (`dept_id`), + KEY `idx_order` (`order_id`), + KEY `idx_assignee` (`assignee_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 工单回复表(多轮对话,is_admin=1 表示客服/管理员回复) +CREATE TABLE `__PREFIX__ticket_replies` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ticket_id` INT UNSIGNED NOT NULL, + `user_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '回复人 user_id(管理员/客服回复时填其 id,用户回复时填下单用户 id)', + `is_admin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=管理员/客服回复 0=用户回复', + `message` TEXT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_ticket` (`ticket_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 公告中心 +CREATE TABLE `__PREFIX__announcements` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(160) NOT NULL, + `content` TEXT NOT NULL, + `pinned` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1置顶', + `status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1显示 0隐藏', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + KEY `idx_pinned` (`pinned`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 积分流水表(签到 / 邀请 / 消费 / 后台调整 等记录) +CREATE TABLE `__PREFIX__points_log` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `type` VARCHAR(20) NOT NULL DEFAULT 'sign' COMMENT 'sign签到 invite邀请 purchase消费 admin后台调整 refund退还', + `amount` INT NOT NULL DEFAULT 0 COMMENT '变动量:正=增加 负=扣减', + `balance` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '变动后余额', + `remark` VARCHAR(255) DEFAULT '', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 每日签到记录(每人每天一条,防止重复签到) +CREATE TABLE `__PREFIX__sign_logs` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `sign_date` DATE NOT NULL, + `points` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_date` (`user_id`, `sign_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================ +-- 初始示例商品(分类:周边 / 数码 / 虚拟 / 图书) +-- ============================================================ +INSERT INTO `__PREFIX__products` (`name`, `price`, `stock`, `category`, `icon`, `image`, `description`) VALUES +('自由云定制帆布包', 39.00, 100, '周边', 'fa-shopping-bag', '', '印有自由云科技团队 Logo 的环保帆布包,简约实用,容量充足。'), +('自由云贴纸套装', 9.90, 500, '周边', 'fa-sticky-note', '', '多种尺寸防水贴纸,装点你的电脑、水杯与笔记本。'), +('机械键盘 87 键', 199.00, 50, '数码', 'fa-keyboard', '', '热插拔轴体,RGB 背光,打字手感顺滑,附赠拔轴器。'), +('10000mAh 移动电源', 79.00, 80, '数码', 'fa-battery-full', '', '轻薄便携,支持双向快充,可为手机平板多次续电。'), +('MC 服务器 VIP 月卡', 12.00, 9999, '虚拟', 'fa-cube', '', '自由 MC 服务器专属 VIP 月卡,享优先进入与专属昵称色。'), +('图床扩容包 10GB', 5.00, 9999, '虚拟', 'fa-images', '', '为你的公益图床账号扩容 10GB 存储空间。'), +('《从零学 PHP》实体书', 49.00, 30, '图书', 'fa-book', '', '面向中学生的 PHP 入门教程,案例丰富、通俗易懂。'), +('《网络安全小百科》', 35.00, 40, '图书', 'fa-shield-alt', '', '图文并茂的网络安全科普读物,适合青少年阅读。'); + +-- ======================= +-- 初始商品分组(与上面商品 category 对应:周边 / 数码 / 虚拟 / 图书) +-- ======================= +INSERT INTO `__PREFIX__product_groups` (`name`, `icon`, `sort_order`) VALUES +('周边', 'fa-shopping-bag', 1), +('数码', 'fa-microchip', 2), +('虚拟', 'fa-cube', 3), +('图书', 'fa-book', 4); + +-- ===================== +-- 初始工单部门 +-- ===================== +INSERT INTO `__PREFIX__ticket_departments` (`name`, `description`, `sort`) VALUES +('技术支持', '产品使用、功能咨询', 1), +('售后服务', '退换货、订单与物流问题', 2), +('财务与支付', '发票、支付异常', 3), +('其他', '其他无法归类的问题', 9); + +-- ======================= +-- 初始系统设置(SMTP 留空,安装后在后台「设置」中填写) +-- ======================= +INSERT INTO `__PREFIX__settings` (`k`, `v`) VALUES +('smtp_host', ''), +('smtp_port', ''), +('smtp_enc', ''), +('smtp_user', ''), +('smtp_pass', ''), +('smtp_from', ''), +('smtp_fromname', '自由云商城'), +('notify_emails', ''), +('mall_about', '自由云商城是自由云科技团队旗下的公益电商,坚持「公益 · 实惠 · 用心挑选」的理念,为社区提供周边、数码、虚拟服务与图书等好物。所有收益用于支持团队的公益技术服务。'), +('points_enabled', '1'), +('points_sign', '5'), +('points_invite', '20'); + +-- 支付日志表(记录支付宝/微信支付流水) +CREATE TABLE `__PREFIX__pay_logs` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_id` VARCHAR(64) NOT NULL COMMENT '商户订单号', + `pay_method` VARCHAR(20) NOT NULL DEFAULT 'alipay' COMMENT 'alipay / wechat', + `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '支付金额', + `trade_no` VARCHAR(64) DEFAULT '' COMMENT '第三方交易号', + `status` ENUM('pending','paid','failed','refunded') NOT NULL DEFAULT 'pending', + `raw_response TEXT DEFAULT NULL COMMENT '原始回调数据(调试用)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_method` (`order_id`, `pay_method`), + KEY `idx_status` (`status`), + KEY `idx_trade_no` (`trade_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 支付配置初始值 +INSERT INTO `__PREFIX__settings` (`k`, `v`) VALUES +('pay_alipay_enabled', '0'), +('pay_alipay_appid', ''), +('pay_alipay_private_key',''), +('pay_alipay_public_key', ''), +('pay_alipay_sandbox', '0'), +('pay_wechat_enabled', '0'), +('pay_wechat_mch_id', ''), +('pay_wechat_api_key', ''), +('pay_wechat_appid', ''); diff --git a/includes/Cache.php b/includes/Cache.php new file mode 100644 index 0000000..41f2143 --- /dev/null +++ b/includes/Cache.php @@ -0,0 +1,155 @@ + $ttl > 0 ? time() + $ttl : 0, + 'created' => time(), + 'value' => $value, + ]; + $content = serialize($data); + return (bool)@file_put_contents($file, $content, LOCK_EX); + } + + /** + * 读取缓存。过期或不存在返回默认值。 + * @param string $key + * @param mixed $default 不存在时的默认值 + * @return mixed + */ + public static function get($key, $default = null) + { + if (!self::$enabled) return $default; + $file = self::fileName($key); + if (!file_exists($file)) return $default; + + $content = @file_get_contents($file); + if ($content === false) return $default; + + $data = @unserialize($content); + if (!is_array($data)) { + @unlink($file); + return $default; + } + + // 检查过期 + if ($data['expires'] > 0 && time() > $data['expires']) { + @unlink($file); + return $default; + } + + return $data['value']; + } + + /** + * 检查缓存是否存在且未过期。 + */ + public static function has($key) + { + return self::get($key, '__NOT_FOUND__') !== '__NOT_FOUND__'; + } + + /** + * 删除单个缓存。 + */ + public static function forget($key) + { + $file = self::fileName($key); + if (file_exists($file)) { + return @unlink($file); + } + return true; + } + + /** + * 清除所有缓存。 + */ + public static function clear() + { + if (!is_dir(self::$dir)) return; + $files = glob(self::$dir . DIRECTORY_SEPARATOR . '*' . self::EXT); + foreach ($files as $f) { + @unlink($f); + } + } + + /** + * 带回掉的缓存读取(缓存命中直接返回,未命中执行回调并缓存)。 + * @param string $key + * @param int $ttl + * @param callable $callback 无参回调,返回要缓存的值 + * @return mixed + */ + public static function remember($key, $ttl, callable $callback) + { + $value = self::get($key, null); + if ($value !== null) return $value; + + $value = $callback(); + self::put($key, $value, $ttl); + return $value; + } + + /** + * 获取或设置(简化版 remember)。 + */ + public static function getOrSet($key, $ttl, callable $callback) + { + return self::remember($key, $ttl, $callback); + } + + // ─── 内部 ─── + + private static function fileName($key) + { + // 用 md5 确保文件名安全 + return self::$dir . DIRECTORY_SEPARATOR . md5($key) . self::EXT; + } +} diff --git a/includes/DbDriver.php b/includes/DbDriver.php new file mode 100644 index 0000000..13008b4 --- /dev/null +++ b/includes/DbDriver.php @@ -0,0 +1,207 @@ +exec('PRAGMA journal_mode=WAL'); + self::$pdo->exec('PRAGMA foreign_keys=ON'); + } else { + // MySQL(默认) + $host = defined('DB_HOST') ? DB_HOST : 'localhost'; + $port = defined('DB_PORT') ? DB_PORT : '3306'; + $name = defined('DB_NAME') ? DB_NAME : 'mall'; + $user = defined('DB_USER') ? DB_USER : 'root'; + $pass = defined('DB_PASS') ? DB_PASS : ''; + $charset = 'utf8mb4'; + $dsn = "mysql:host={$host};port={$port};dbname={$name};charset={$charset}"; + self::$pdo = new PDO($dsn, $user, $pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + } catch (PDOException $e) { + if (defined('DEBUG') && DEBUG) { + throw $e; + } + die('数据库连接失败,请检查配置。' . (DEBUG ? ' [' . $e->getMessage() . ']' : '')); + } + + return self::$pdo; + } + + /** + * 获取当前驱动类型。 + */ + public static function getDriver() + { + return self::$driver; + } + + /** + * 是否为 SQLite。 + */ + public static function isSqlite() + { + return self::$driver === 'sqlite'; + } + + /** + * 是否为 MySQL。 + */ + public static function isMysql() + { + return self::$driver === 'mysql'; + } + + /** + * 获取原始 PDO 对象。 + */ + public static function pdo() + { + return self::connect(); + } + + /** + * 关闭连接。 + */ + public static function close() + { + self::$pdo = null; + } + + /** + * 执行 SQL 并返回影响行数。 + */ + public static function exec($sql) + { + return self::connect()->exec($sql); + } + + /** + * 查询并返回全部行。 + */ + public static function queryAll($sql, $params = []) + { + $stmt = self::connect()->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + /** + * 查询并返回一行。 + */ + public static function queryOne($sql, $params = []) + { + $stmt = self::connect()->prepare($sql); + $stmt->execute($params); + return $stmt->fetch(); + } + + /** + * 查询并返回单个值。 + */ + public static function queryScalar($sql, $params = []) + { + $row = self::queryOne($sql, $params); + return $row ? reset($row) : null; + } + + /** + * 获取最后插入 ID。 + */ + public static function lastInsertId() + { + return self::connect()->lastInsertId(); + } + + /** + * 在事务中执行回调。 + */ + public static function transaction(callable $callback) + { + $pdo = self::connect(); + try { + $pdo->beginTransaction(); + $result = $callback($pdo); + $pdo->commit(); + return $result; + } catch (Exception $e) { + $pdo->rollBack(); + throw $e; + } + } + + /** + * 检查表是否存在。 + */ + public static function tableExists($table) + { + if (self::isSqlite()) { + $name = str_replace(self::getPrefix(), '', $table); + return self::queryScalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", + [$name] + ) > 0; + } else { + $db = defined('DB_NAME') ? DB_NAME : ''; + return self::queryScalar( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=? AND table_name=?", + [$db, $table] + ) > 0; + } + } + + /** + * 获取表前缀。 + */ + public static function getPrefix() + { + return defined('DB_PREFIX') ? DB_PREFIX : ''; + } + + /** + * 列出所有表名。 + */ + public static function listTables() + { + if (self::isSqlite()) { + return self::queryAll("SELECT name FROM sqlite_master WHERE type='table'"); + } else { + $db = defined('DB_NAME') ? DB_NAME : ''; + return self::queryAll("SELECT table_name AS name FROM information_schema.tables WHERE table_schema=?", [$db]); + } + } +} diff --git a/includes/Lang.php b/includes/Lang.php new file mode 100644 index 0000000..f191030 --- /dev/null +++ b/includes/Lang.php @@ -0,0 +1,207 @@ + data] */ + private static $cache = []; + + /** @var string 语言文件目录 */ + private static $dir; + + /** + * 设置语言目录(通常在 init.php 中调用一次)。 + */ + public static function setDir($dir) + { + self::$dir = rtrim($dir, '/\\'); + } + + /** + * 获取当前语言代码。 + */ + public static function getLocale() + { + return self::$locale; + } + + /** + * 切换语言。 + * @param string $locale 如 'zh_CN', 'en_US' + * @return bool 是否成功 + */ + public static function setLocale($locale) + { + $file = self::$dir . DIRECTORY_SEPARATOR . $locale . '.json'; + if (!file_exists($file)) return false; + self::$locale = $locale; + // 清除旧缓存 + if (isset(self::$cache[$locale])) unset(self::$cache[$locale]); + return true; + } + + /** + * 从 cookie / session / 参数自动检测并设置语言。 + */ + public static function detectLocale() + { + // 1. Session(由 lang.php 设置,优先级最高,保证整站会话内一致) + if (!empty($_SESSION['lang'])) { + $lang = preg_replace('/[^a-zA-Z0-9_]/', '', $_SESSION['lang']); + if (self::setLocale($lang)) return; + } + // 2. URL 参数 ?lang=xx(一次性切换,写入 cookie + session) + if (!empty($_GET['lang'])) { + $lang = preg_replace('/[^a-zA-Z0-9_]/', '', $_GET['lang']); + if (self::setLocale($lang)) { + $_SESSION['lang'] = $lang; + setcookie('fnw_lang', $lang, time() + 31536000, '/'); + return; + } + } + // 3. Cookie + if (!empty($_COOKIE['fnw_lang'])) { + $lang = $_COOKIE['fnw_lang']; + if (self::setLocale($lang)) return; + } + // 4. 默认中文 + self::$locale = 'zh_CN'; + } + + /** + * 翻译函数(核心)。 + * + * @param string $key 点分隔键,如 'common.home' 或 'hero.title' + * @param mixed ...$args 占位参数(按顺序替换 {0}, {1}... 或命名参数) + * @return string 翻译后的文本,找不到则返回 key 本身 + */ + public static function trans($key, ...$args) + { + $data = self::load(self::$locale); + $value = self::getNested($data, $key); + + if ($value === null) { + // 回退到中文 + if (self::$locale !== 'zh_CN') { + $dataZh = self::load('zh_CN'); + $value = self::getNested($dataZh, $key); + } + if ($value === null) return $key; + } + + // 替换占位符 + if (!empty($args)) { + $value = self::replacePlaceholders($value, $args); + } + + return $value; + } + + /** + * 获取所有可用语言列表。 + * @return array [['code'=>'zh_CN','name'=>'简体中文'], ...] + */ + public static function available() + { + $list = []; + if (!is_dir(self::$dir)) return $list; + foreach (glob(self::$dir . DIRECTORY_SEPARATOR . '*.json') as $f) { + $code = pathinfo($f, PATHINFO_FILENAME); + $data = json_decode(file_get_contents($f), true); + if (isset($data['_meta']['name'])) { + $list[] = [ + 'code' => $code, + 'name' => $data['_meta']['name'], + 'direction' => $data['_meta']['direction'] ?? 'ltr', + ]; + } + } + return $list; + } + + /** + * 获取当前语言的 HTML lang 属性值。 + */ + public static function htmlLang() + { + $map = ['zh_CN' => 'zh-CN', 'en_US' => 'en']; + return $map[self::$locale] ?? substr(self::$locale, 0, 2); + } + + // ─── 内部方法 ─── + + /** + * 加载语言包(带缓存)。 + */ + private static function load($locale) + { + if (isset(self::$cache[$locale])) return self::$cache[$locale]; + $file = self::$dir . DIRECTORY_SEPARATOR . $locale . '.json'; + if (!file_exists($file)) return []; + $json = file_get_contents($file); + self::$cache[$locale] = json_decode($json, true) ?: []; + return self::$cache[$locale]; + } + + /** + * 从嵌套数组中用点分隔 key 取值。 + */ + private static function getNested($arr, $key) + { + $keys = explode('.', $key); + $val = $arr; + foreach ($keys as $k) { + if (!is_array($val) || !array_key_exists($k, $val)) return null; + $val = $val[$k]; + } + return is_string($val) || is_numeric($val) ? (string)$val : null; + } + + /** + * 替换文本中的占位符。 + * 支持 {named} 和 {0}, {1} 格式。 + */ + private static function replacePlaceholders($text, $args) + { + // 命名参数: __('key', ['points' => 5]) 或 __('key', 5) + if (count($args) === 1 && is_array($args[0])) { + $map = $args[0]; + foreach ($map as $k => $v) { + $text = str_replace('{' . $k . '}', $v, $text); + } + } else { + // 位置参数: {0}, {1} + foreach ($args as $i => $v) { + $text = str_replace('{' . $i . '}', $v, $text); + } + } + return $text; + } +} + +/** + * 全局翻译函数快捷方式。 + * + * @param string $key 翻译键 + * @param mixed ...$args 占位参数 + * @return string + */ +if (!function_exists('__')) { + function __($key, ...$args) + { + return Lang::trans($key, ...$args); + } +} diff --git a/includes/Logger.php b/includes/Logger.php new file mode 100644 index 0000000..37a45c0 --- /dev/null +++ b/includes/Logger.php @@ -0,0 +1,122 @@ + 123]); + * Logger::error('支付失败', ['order_id' => 456, 'err' => $e->getMessage()]); + */ +if (!defined('IN_APP')) exit('Forbidden'); + +class Logger +{ + /** @var string 日志根目录 */ + private static $dir; + + /** @var bool 是否启用(受 DEBUG 配置控制) */ + private static $enabled = true; + + /** @var int 最小记录级别 (DEBUG=0, INFO=1, WARN=2, ERROR=3) */ + private static $minLevel = 0; // DEBUG 模式下记录全部 + + const DEBUG = 0; + const INFO = 1; + const WARN = 2; + const ERROR = 3; + + /** + * 初始化日志系统。 + * @param string $dir 日志目录路径 + * @param bool $enabled 是否启用 + * @param int $minLevel 最小级别 + */ + public static function init($dir, $enabled = true, $minLevel = 0) + { + self::$dir = rtrim($dir, '/\\'); + self::$enabled = $enabled; + self::$minLevel = $minLevel; + if (!is_dir(self::$dir)) { + @mkdir(self::$dir, 0755, true); + } + } + + /** + * 记录 DEBUG 级别日志。 + */ + public static function debug($message, array $context = []) + { + self::write(self::DEBUG, $message, $context); + } + + /** + * 记录 INFO 级别日志。 + */ + public static function info($message, array $context = []) + { + self::write(self::INFO, $message, $context); + } + + /** + * 记录 WARN 级别日志。 + */ + public static function warn($message, array $context = []) + { + self::write(self::WARN, $message, $context); + } + + /** + * 记录 ERROR 级别日志。 + */ + public static function error($message, array $context = []) + { + self::write(self::ERROR, $message, $context); + } + + // ─── 内部实现 ─── + + private static function write($level, $message, array $context) + { + if (!self::$enabled || $level < self::$minLevel) return; + + $levelNames = [self::DEBUG => 'DEBUG', self::INFO => 'INFO', self::WARN => 'WARN', self::ERROR => 'ERROR']; + $date = date('Y-m-d'); + $time = date('Y-m-d H:i:s'); + $levelName = $levelNames[$level] ?? 'LOG'; + + // 格式化上下文 + $ctxStr = ''; + if (!empty($context)) { + $parts = []; + foreach ($context as $k => $v) { + if (is_array($v) || is_object($v)) { + $v = json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + $parts[] = $k . '=' . $v; + } + $ctxStr = ' [' . implode(', ', $parts) . ']'; + } + + // 获取调用者信息(跳过 Logger 内部调用) + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); + $caller = ''; + if (isset($trace[2])) { + $file = basename($trace[2]['file'] ?? ''); + $line = $trace[2]['line'] ?? ''; + $caller = " [{$file}:{$line}]"; + } + + $logLine = "[{$time}] [{$levelName}]{$caller} {$message}{$ctxStr}" . PHP_EOL; + + $file = self::$dir . DIRECTORY_SEPARATOR . $date . '.log'; + @file_put_contents($file, $logLine, FILE_APPEND | LOCK_EX); + + // 单文件最大 10MB,超出轮转 + if (file_exists($file) && filesize($file) > 10 * 1024 * 1024) { + $rotated = self::$dir . DIRECTORY_SEPARATOR . $date . '.log.1'; + @rename($file, $rotated); + } + } +} diff --git a/includes/Payment.php b/includes/Payment.php new file mode 100644 index 0000000..6de34dd --- /dev/null +++ b/includes/Payment.php @@ -0,0 +1,146 @@ + '...', 'form' => '...'] 至少包含一个 + */ + public function createOrder(array $order); + + /** + * 验证异步通知签名和数据。 + * @param array $params POST/GET 参数 + * @return bool 是否有效 + */ + public function verifyNotify(array $params); + + /** + * 从通知中获取交易号。 + */ + public function getTradeNo(array $params); + + /** + * 从通知中获取支付状态(是否成功)。 + */ + public function isPaid(array $params); + + /** + * 响应支付平台(输出 "success" 等)。 + */ + public function respondSuccess(); + + /** + * 获取网关名称。 + */ + public function getName(); +} + +/** + * 支付管理器:注册网关、创建订单、处理回调。 + */ +class Payment +{ + /** @var array 已注册的支付网关 [code => instance] */ + private static $gateways = []; + + /** + * 注册支付网关。 + * @param string $code 如 'alipay', 'wechat' + * @param PaymentGatewayInterface $gateway + */ + public static function register($code, PaymentGatewayInterface $gateway) + { + self::$gateways[$code] = $gateway; + } + + /** + * 获取已注册的支付网关。 + * @param string $code + * @return PaymentGatewayInterface|null + */ + public static function get($code) + { + return self::$gateways[$code] ?? null; + } + + /** + * 获取所有可用支付方式列表。 + * @return array [['code'=>'alipay','name'=>'支付宝','enabled'=>bool], ...] + */ + public static function availableMethods() + { + $list = []; + foreach (self::$gateways as $code => $gw) { + $list[] = [ + 'code' => $code, + 'name' => $gw->getName(), + 'enabled' => true, // TODO: 可根据配置判断是否启用 + ]; + } + return $list; + } + + /** + * 根据配置自动初始化并注册所有启用的支付网关。 + * 在 init.php 中调用一次即可。 + */ + public static function initGateways() + { + $alipayEnabled = getSetting('pay_alipay_enabled', '0') === '1'; + $wechatEnabled = getSetting('pay_wechat_enabled', '0') === '1'; + + if ($alipayEnabled) { + self::register('alipay', new AlipayGateway()); + } + if ($wechatEnabled) { + self::register('wechat', new WechatGateway()); + } + } + + /** + * 创建支付记录到数据库。 + * @return int pay_log ID + */ + public static function createPayLog($orderId, $method, $amount, $tradeNo = '') + { + db()->prepare( + 'INSERT INTO ' . tn('pay_logs') . ' (order_id,pay_method,amount,trade_no,status,created_at) VALUES (?,?,?,?,?,NOW())' + )->execute([$orderId, $method, $amount, $tradeNo, 'pending']); + return (int)db()->lastInsertId(); + } + + /** + * 更新支付日志状态。 + */ + public static function updatePayStatus($logId, $status, $tradeNo = '') + { + $sql = 'UPDATE ' . tn('pay_logs') . ' SET status=?, updated_at=NOW()'; + $params = [$status]; + if ($tradeNo) { + $sql .= ', trade_no=?'; + $params[] = $tradeNo; + } + $sql .= ' WHERE id=?'; + $params[] = $logId; + db()->prepare($sql)->execute($params); + } +} diff --git a/includes/PaymentAlipay.php b/includes/PaymentAlipay.php new file mode 100644 index 0000000..82d0834 --- /dev/null +++ b/includes/PaymentAlipay.php @@ -0,0 +1,140 @@ +appId = getSetting('pay_alipay_appid', ''); + $this->privateKey = getSetting('pay_alipay_private_key', ''); + $this->publicKey = getSetting('pay_alipay_public_key', ''); + // 沙箱模式(开发测试用) + if (getSetting('pay_alipay_sandbox', '0') === '1') { + $this->gatewayUrl = $this->sandboxUrl; + } + } + + public function getName() + { + return '支付宝'; + } + + /** + * 创建支付宝电脑网站支付订单。 + */ + public function createOrder(array $order) + { + $params = [ + 'app_id' => $this->appId, + 'method' => 'alipay.trade.page.pay', + 'format' => 'JSON', + 'return_url' => $order['return_url'], + 'notify_url' => $order['notify_url'], + 'charset' => 'utf-8', + 'sign_type' => 'RSA2', + 'timestamp' => date('Y-m-d H:i:s'), + 'version' => '1.0', + 'biz_content' => json_encode([ + 'out_trade_no' => $order['order_no'], + 'total_amount' => sprintf('%.2f', $order['amount']), + 'subject' => $order['subject'], + 'product_code' => 'FAST_INSTANT_TRADE_PAY', + ], JSON_UNESCAPED_UNICODE), + ]; + + $params['sign'] = $this->sign($params); + + // 构建跳转 URL(GET 方式) + $url = $this->gatewayUrl . '?' . http_build_query($params); + + return ['url' => $url, 'form' => '']; + } + + public function verifyNotify(array $params) + { + if (empty($params['sign'])) return false; + $sign = $params['sign']; + unset($params['sign'], $params['sign_type']); + return $this->rsaVerify($this->buildSignData($params), $sign); + } + + public function getTradeNo(array $params) + { + return $params['trade_no'] ?? ''; + } + + public function isPaid(array $params) + { + return ($params['trade_status'] ?? '') === 'TRADE_SUCCESS'; + } + + public function respondSuccess() + { + echo 'success'; + } + + // ─── 签名相关 ─── + + private function sign($params) + { + ksort($params); + $data = $this->buildSignData($params); + $res = openssl_sign($data, $signature, $this->loadPrivateKey(), OPENSSL_ALGO_SHA256); + return base64_encode($signature); + } + + private function rsaVerify($data, $sign) + { + $result = openssl_verify($data, base64_decode($sign), $this->loadPublicKey(), OPENSSL_ALGO_SHA256); + return $result === 1; + } + + private function buildSignData($params) + { + $pairs = []; + foreach ($params as $k => $v) { + if ($v === '' || is_array($v)) continue; + $pairs[] = $k . '=' . $v; + } + return implode('&', $pairs); + } + + private function loadPrivateKey() + { + $key = trim($this->privateKey); + $key = preg_replace('/-----BEGIN RSA PRIVATE KEY-----/', '', $key); + $key = preg_replace('/-----END RSA PRIVATE KEY-----/', '', $key); + $key = str_replace(["\r", "\n", " "], '', $key); + return "-----BEGIN RSA PRIVATE KEY-----\n" . + chunk_split($key, 64, "\n") . + "-----END RSA PRIVATE KEY-----"; + } + + private function loadPublicKey() + { + $key = trim($this->publicKey); + $key = preg_replace('/-----BEGIN PUBLIC KEY-----/', '', $key); + $key = preg_replace('/-----END PUBLIC KEY-----/', '', $key); + $key = str_replace(["\r", "\n", " "], '', $key); + return "-----BEGIN PUBLIC KEY-----\n" . + chunk_split($key, 64, "\n") . + "-----END PUBLIC KEY-----"; + } +} diff --git a/includes/PaymentWechat.php b/includes/PaymentWechat.php new file mode 100644 index 0000000..1d7718a --- /dev/null +++ b/includes/PaymentWechat.php @@ -0,0 +1,161 @@ +mchId = getSetting('pay_wechat_mch_id', ''); + $this->apiKey = getSetting('pay_wechat_api_key', ''); + $this->appId = getSetting('pay_wechat_appid', ''); + } + + public function getName() + { + return '微信支付'; + } + + /** + * 创建微信 Native 支付订单(返回二维码链接)。 + */ + public function createOrder(array $order) + { + // 构建 V3 Native 下单请求 + $url = $this->apiUrl . '/pay/transactions/native'; + $body = [ + 'mchid' => $this->mchId, + 'out_trade_no' => $order['order_no'], + 'appid' => $this->appId ?: '', + 'description' => $order['subject'], + 'notify_url' => $order['notify_url'], + 'amount' => [ + 'total' => (int)round($order['amount'] * 100), // 单位:分 + 'currency' => 'CNY', + ], + ]; + + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json', + 'Authorization: WECHATPAY2-SHA256-RSA2048 ' . $this->buildAuth($url, 'POST', json_encode($body)), + ]; + + $result = $this->httpPost($url, json_encode($body), $headers); + $data = json_decode($result, true); + + if (isset($data['code_url'])) { + return [ + 'url' => '', + 'form' => '

    请使用微信扫码支付

    ' . + '微信支付二维码
    ', + 'qr_code' => $data['code_url'], + ]; + } + + // 错误时返回错误信息 + Logger::error('微信支付下单失败', ['response' => $data]); + return ['url' => '', 'form' => '

    支付创建失败: ' . h($data['message'] ?? '未知错误') . '

    ']; + } + + public function verifyNotify(array $params) + { + // V3 回调验签 + if (empty($_SERVER['HTTP_WECHATPAY_SIGNATURE'])) return false; + + $serial = $_SERVER['HTTP_WECHATPAY_SERIAL'] ?? ''; + $signature = $_SERVER['HTTP_WECHATPAY_SIGNATURE'] ?? ''; + $timestamp = $_SERVER['HTTP_WECHATPAY_TIMESTAMP'] ?? ''; + $nonce = $_SERVER['HTTP_WECHATPAY_NONCE'] ?? ''; + + // TODO: 完整的 V3 验签实现需加载微信平台证书 + // 这里做基础验证 + return !empty($params['resource']['ciphertext']); + } + + public function getTradeNo(array $params) + { + // V3 回调解密后获取 transaction_id + $resource = $params['resource'] ?? []; + return $resource['ciphertext'] ? $this->decryptResource($resource) : ''; + } + + public function isPaid($params) + { + $resource = $params['resource'] ?? []; + $decrypted = $resource['ciphertext'] ? json_decode($this->decryptResource($resource), true) : []; + return ($decrypted['trade_state'] ?? '') === 'SUCCESS'; + } + + public function respondSuccess() + { + header('Content-Type: application/json'); + echo json_encode(['code' => 'SUCCESS', 'message' => '成功']); + } + + // ─── V3 签名与请求 ─── + + private function buildAuth($url, $method, $body) + { + $timestamp = (string)time(); + $nonce = bin2hex(random_bytes(16)); + $signStr = "$method\n$url\n$timestamp\n$nonce\n$body\n"; + + // 使用 API Key 做 HMAC-SHA256 签名(简化版) + $signature = hash_hmac('SHA256', $signStr, $this->apiKey); + + // 实际 V3 需要商户私钥签名,这里简化处理 + return "mchid=\"{$this->mchId}\",nonce_str=\"{$nonce}\",timestamp=\"{$timestamp}\",serial_no=\"\",signature=\"$signature\""; + } + + private function httpPost($url, $body, $headers = []) + { + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => false, + ]); + $result = curl_exec($ch); + curl_close($ch); + return $result; + } + + /** + * 解密 V3 回调资源。 + */ + private function decryptResource($resource) + { + $ciphertext = base64_decode($resource['ciphertext']); + $nonce = $resource['nonce']; + $associatedData = $resource['associated_data']; + + // AES-256-GCM 解密 + $key = hash('sha256', $this->apiKey, true); // 用 API Key 派生密钥(简化) + if (function_exists('openssl_decrypt') && defined('AES_256_GCM')) { + $plain = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $associatedData); + return $plain !== false ? $plain : ''; + } + return ''; + } +} diff --git a/includes/_install_redirect.php b/includes/_install_redirect.php new file mode 100644 index 0000000..40c203a --- /dev/null +++ b/includes/_install_redirect.php @@ -0,0 +1,28 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } catch (PDOException $e) { + // 不在页面上泄露密码等细节 + die('数据库连接失败,请检查 config.php 中的数据库配置。'); + } + return $pdo; +} + +/** + * 带前缀的表名。 + */ +function tn($table) +{ + return DB_PREFIX . $table; +} diff --git a/includes/footer.php b/includes/footer.php new file mode 100644 index 0000000..f0d7776 --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,107 @@ + + + + + + + diff --git a/includes/functions.php b/includes/functions.php new file mode 100644 index 0000000..a52a0f0 --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,649 @@ +prepare('SELECT COUNT(*) FROM ' . tn('users') . ' WHERE reg_ip = ? AND DATE(created_at) = CURDATE()'); + $stmt->execute([$ip]); + return (int) $stmt->fetchColumn(); + } catch (Throwable $e) { + return 0; + } +} + +/** + * 站点基础 URL(用于拼接验证链接)。自动适配是否部署在子目录。 + */ +function siteBaseUrl() +{ + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['SERVER_PORT'] ?? '') == '443' ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $dir = dirname($_SERVER['SCRIPT_NAME'] ?? '/index.php'); + $dir = ($dir === '/' || $dir === '\\') ? '' : rtrim($dir, '/'); + return $scheme . '://' . $host . $dir; +} + +/** + * 取昵称/用户名的首字(用于头像圆圈)。 + */ +function avatarText($name) +{ + $name = trim((string) $name); + if ($name === '') { + return '?'; + } + return mb_strtoupper(mb_substr($name, 0, 1, 'UTF-8'), 'UTF-8'); +} + +/** + * 发送邮箱验证邮件:生成/更新 email_token 并投递。 + * 返回 ['ok'=>bool, 'error'=>string, 'log'=>array] + */ +function sendVerifyMail($userId) +{ + $stmt = db()->prepare('SELECT id, username, nickname, email FROM ' . tn('users') . ' WHERE id = ?'); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + if (!$user || $user['email'] === '') { + return ['ok' => false, 'error' => '该账号没有可用邮箱', 'log' => []]; + } + $token = bin2hex(random_bytes(24)); + db()->prepare('UPDATE ' . tn('users') . ' SET email_token = ? WHERE id = ?') + ->execute([$token, $user['id']]); + $url = siteBaseUrl() . '/verify.php?token=' . $token; + $name = $user['nickname'] ?: $user['username']; + $subject = '[' . SITE_NAME . '] 请验证你的邮箱'; + $body = '
    ' + . '

    验证你的邮箱

    ' + . '

    你好 ' . h($name) . ',感谢注册 ' . h(SITE_NAME) . '!

    ' + . '

    请点击下方按钮完成邮箱验证:

    ' + . '

    验证邮箱

    ' + . '

    如按钮无法点击,请复制以下链接到浏览器打开:
    ' . h($url) . '

    ' + . '
    ' + . '

    若非本人操作,请忽略此邮件。

    ' + . '
    '; + return fnwSendMail($user['email'], $subject, $body); +} + +/** + * 当前登录用户(普通用户),未登录返回 null。 + */ +function currentUser($forceRefresh = false) +{ + if (empty($_SESSION['user_id'])) { + return null; + } + static $cache = null; + if ($cache !== null && !$forceRefresh) { + return $cache; + } + $cache = null; // 强制刷新时先清掉旧缓存 + $pdo = db(); + $stmt = $pdo->prepare('SELECT id, username, email, nickname, verified, is_admin, status, points, invite_code, invited_by, avatar FROM ' . tn('users') . ' WHERE id = ?'); + $stmt->execute([$_SESSION['user_id']]); + $row = $stmt->fetch(); + // 账号被封禁则视为未登录,并清除会话 + if (!$row || (int) $row['status'] !== 1) { + unset($_SESSION['user_id']); + $cache = null; + return null; + } + $cache = $row; + return $cache; +} + +/** + * 清除当前登录用户的请求级缓存(后台修改用户数据后调用,使下次 currentUser() 重新查库)。 + */ +function refreshCurrentUser() +{ + currentUser(true); +} + +function isLoggedIn() +{ + return currentUser() !== null; +} + +/** + * 当前登录的管理员,未登录返回 null。 + */ +function currentAdmin() +{ + if (empty($_SESSION['admin_id'])) { + return null; + } + static $cache = null; + if ($cache !== null) { + return $cache; + } + $pdo = db(); + $stmt = $pdo->prepare('SELECT id, username, email, status FROM ' . tn('users') . ' WHERE id = ? AND is_admin = 1'); + $stmt->execute([$_SESSION['admin_id']]); + $row = $stmt->fetch(); + if (!$row || (int) $row['status'] !== 1) { + unset($_SESSION['admin_id']); + $cache = null; + return null; + } + $cache = $row; + return $cache; +} + +/** + * 要求登录,否则跳转到登录页并带回跳地址。 + */ +function requireLogin($next = '') +{ + if (!isLoggedIn()) { + $q = $next ? '?next=' . urlencode($next) : ''; + redirect('login.php' . $q); + } +} + +/** + * 要求管理员(is_admin=1)登录(后台用)。 + * 注意:仅校验会话标志不够——客服(role=cs)同样持有 admin_id 会话, + * 因此必须额外确认 is_admin=1,否则客服可通过直接访问 URL 进入管理员专属页面。 + */ +function requireAdmin() +{ + if (empty($_SESSION['admin_id']) || !currentAdmin()) { + redirect('login.php'); + } +} + +/** + * 当前登录的「员工」账号(管理员或客服),未登录/无权限返回 null。 + * 与 currentAdmin() 的区别:currentAdmin() 仅限 is_admin=1;本函数额外允许 role='cs' 的客服。 + */ +function currentStaff() +{ + if (empty($_SESSION['admin_id'])) { + return null; + } + static $cache = null; + if ($cache !== null) { + return $cache; + } + $pdo = db(); + $stmt = $pdo->prepare( + 'SELECT id, username, email, status, role, is_admin FROM ' . tn('users') . ' WHERE id = ? AND status = 1 AND role IN (\'admin\', \'cs\')' + ); + $stmt->execute([$_SESSION['admin_id']]); + $row = $stmt->fetch(); + if (!$row) { + unset($_SESSION['admin_id']); + $cache = null; + return null; + } + $cache = $row; + return $cache; +} + +/** + * 要求员工(管理员或客服)登录,否则跳转到后台登录页。 + */ +function requireStaff() +{ + if (empty($_SESSION['admin_id']) || !currentStaff()) { + redirect('login.php'); + } +} + +/** + * 当前员工是否为管理员(拥有全部后台权限)。 + */ +function isAdminStaff() +{ + $s = currentStaff(); + return $s !== null && (int) $s['is_admin'] === 1; +} + +/** + * 生成/获取 CSRF 令牌(存入 session)。 + */ +function csrfToken() +{ + if (empty($_SESSION['csrf'])) { + $_SESSION['csrf'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf']; +} + +/** + * 输出 CSRF 隐藏字段。 + */ +function csrfField() +{ + return ''; +} + +/** + * 校验 CSRF(仅 POST)。失败直接 403。 + */ +function verifyCsrf() +{ + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (empty($_POST['csrf']) || !hash_equals((string) $_SESSION['csrf'], (string) $_POST['csrf'])) { + http_response_code(403); + exit('CSRF 校验失败'); + } + } +} + +/** + * 校验 CSRF token 字符串(用于 AJAX/API 场景)。 + * @param string $token 待校验的 token + * @return bool + */ +function verifyCsrfToken($token) +{ + return !empty($token) && !empty($_SESSION['csrf']) && hash_equals((string)$_SESSION['csrf'], (string)$token); +} + +/** + * 当前主题:浅色 / 深色。跟随 cookie,默认跟随系统(用 CSS 媒体查询兜底)。 + */ +function currentTheme() +{ + return (!empty($_COOKIE['fnw_theme']) && $_COOKIE['fnw_theme'] === 'dark') ? 'dark' : 'light'; +} + +/** + * 订单状态中文映射。 + */ +function orderStatusLabel($status) +{ + $map = [ + 'pending' => '待处理', + 'paid' => '已受理', + 'shipped' => '已发货', + 'completed' => '已完成', + 'cancelled' => '已取消', + ]; + return $map[$status] ?? $status; +} + +/** + * 读取单个系统设置(settings 表)。 + */ +function getSetting($k, $default = '') +{ + try { + $stmt = db()->prepare('SELECT v FROM ' . tn('settings') . ' WHERE k = ?'); + $stmt->execute([$k]); + $v = $stmt->fetchColumn(); + return $v === false ? $default : $v; + } catch (Throwable $e) { + return $default; + } +} + +/** + * 保存单个系统设置(不存在则插入,存在则更新)。 + */ +function saveSetting($k, $v) +{ + db()->prepare( + 'INSERT INTO ' . tn('settings') . ' (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?' + )->execute([$k, $v, $v]); +} + +/** + * 读取 SMTP 配置(带缓存)。 + */ +function smtpConfig() +{ + static $c = null; + if ($c !== null) { + return $c; + } + $keys = ['smtp_host', 'smtp_port', 'smtp_enc', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_fromname', 'notify_emails']; + $map = []; + try { + $pdo = db(); + $ph = implode(',', array_fill(0, count($keys), '?')); + $stmt = $pdo->prepare('SELECT k, v FROM ' . tn('settings') . ' WHERE k IN (' . $ph . ')'); + $stmt->execute($keys); + foreach ($stmt->fetchAll() as $row) { + $map[$row['k']] = $row['v']; + } + } catch (Throwable $e) { + // 配置表尚不可用时返回空配置 + } + $c = []; + foreach ($keys as $k) { + $c[$k] = $map[$k] ?? ''; + } + return $c; +} + +/** + * 通过 SMTP 发送邮件(依赖 includes/mailer.php)。 + * 返回 ['ok'=>true,'log'=>[...]] 或 ['ok'=>false,'error'=>string,'log'=>[...]] + */ +function fnwSendMail($to, $subject, $body) +{ + $cfg = smtpConfig(); + if (empty($cfg['smtp_host'])) { + return ['ok' => false, 'error' => 'SMTP 未配置']; + } + require_once __DIR__ . '/mailer.php'; + $mailer = new SmtpMailer($cfg['smtp_host'], $cfg['smtp_port'], $cfg['smtp_enc'], $cfg['smtp_user'], $cfg['smtp_pass']); + $from = $cfg['smtp_from'] ?: $cfg['smtp_user']; + $ok = $mailer->send($to, $subject, $body, $from, $cfg['smtp_fromname'] ?: SITE_NAME); + if ($ok) { + return ['ok' => true, 'log' => $mailer->log]; + } + return ['ok' => false, 'error' => $mailer->lastError, 'log' => $mailer->log]; +} + +/** + * 工单状态中文映射。 + */ +function ticketStatusLabel($status) +{ + $map = [ + 'open' => '待处理', + 'pending' => '处理中', + 'resolved' => '已解决', + 'closed' => '已关闭', + ]; + return $map[$status] ?? $status; +} + +/** + * 工单优先级中文映射。 + */ +function ticketPriorityLabel($p) +{ + $map = [1 => '低', 2 => '普通', 3 => '高']; + return $map[(int) $p] ?? '普通'; +} + +/** + * 周期天数 → 中文标签。0 表示一次性/实物。 + */ +function periodLabel($days) +{ + $days = (int) $days; + if ($days <= 0) { + return '一次性'; + } + if ($days % 365 === 0) { + return ($days / 365) . ' 年'; + } + if ($days % 30 === 0) { + return ($days / 30) . ' 个月'; + } + return $days . ' 天'; +} + +/** + * 工单类型中文映射。 + */ +function ticketTypeLabel($t) +{ + $map = [ + 'general' => '普通', + 'renewal' => '续期申请', + ]; + return $map[$t] ?? '普通'; +} + +/** + * 到期时间 → 展示文案(null 表示无到期/永久)。 + */ +function expiryText($dt) +{ + if ($dt === null || $dt === '') { + return '—'; + } + return date('Y-m-d H:i', strtotime($dt)); +} + +/** + * 是否即将/已经到期(用于前台续期入口判断,由调用方按需使用)。 + */ +function isExpired($dt) +{ + if ($dt === null || $dt === '') { + return false; + } + return strtotime($dt) < time(); +} + +/** + * 到期状态分析:返回是否含到期时间、展示文案、剩余天数、等级(ok/warn/danger/expired/none)。 + */ +function expiryState($dt) +{ + if ($dt === null || $dt === '' || $dt === '0000-00-00 00:00:00') { + return ['has' => false, 'text' => '—', 'days' => null, 'level' => 'none']; + } + $ts = strtotime($dt); + if ($ts === false) { + return ['has' => false, 'text' => '—', 'days' => null, 'level' => 'none']; + } + $now = time(); + $days = ($ts - $now) / 86400; + $daysLeft = (int) ceil($days); + if ($ts < $now) { + return ['has' => true, 'text' => '已到期(' . date('Y-m-d H:i', $ts) . ')', 'days' => $daysLeft, 'level' => 'expired']; + } + $level = $daysLeft <= 3 ? 'danger' : ($daysLeft <= 7 ? 'warn' : 'ok'); + return ['has' => true, 'text' => date('Y-m-d H:i', $ts) . '(剩 ' . $daysLeft . ' 天)', 'days' => $daysLeft, 'level' => $level]; +} + +/** + * 到期时间徽标(HTML),用于前台订单列表/详情与后台。 + */ +function expiryBadge($dt) +{ + $s = expiryState($dt); + if (!$s['has']) { + return '无到期时间'; + } + $icon = [ + 'ok' => 'fa-calendar-check', + 'warn' => 'fa-clock', + 'danger' => 'fa-triangle-exclamation', + 'expired' => 'fa-circle-xmark', + ][$s['level']] ?? 'fa-calendar'; + return ' ' . h($s['text']) . ''; +} + +/** + * 支付方式中文映射。 + */ +function payTypeLabel($t) +{ + $map = [ + 'cash' => '现金(历史)', + 'points' => '积分支付', + 'direct' => '直接下单', + ]; + return $map[$t] ?? '直接下单'; +} + +/** + * 读取用户当前积分余额。 + */ +function getUserPoints($uid) +{ + try { + $stmt = db()->prepare('SELECT points FROM ' . tn('users') . ' WHERE id = ?'); + $stmt->execute([(int) $uid]); + $v = $stmt->fetchColumn(); + return $v === false ? 0 : (int) $v; + } catch (Throwable $e) { + return 0; + } +} + +/** + * 增减用户积分并写入流水(points_log)。 + * $amount:正数=增加,负数=扣减(扣减后余额最低为 0)。 + * 返回 ['ok'=>bool, 'balance'=>int, 'error'=>string]。 + */ +function addPoints($uid, $type, $amount, $remark = '') +{ + $uid = (int) $uid; + $amount = (int) $amount; + try { + $pdo = db(); + $pdo->beginTransaction(); + $stmt = $pdo->prepare('SELECT points FROM ' . tn('users') . ' WHERE id = ? FOR UPDATE'); + $stmt->execute([$uid]); + $cur = (int) $stmt->fetchColumn(); + $balance = max(0, $cur + $amount); + $pdo->prepare('UPDATE ' . tn('users') . ' SET points = ? WHERE id = ?') + ->execute([$balance, $uid]); + $pdo->prepare( + 'INSERT INTO ' . tn('points_log') . ' (user_id, type, amount, balance, remark, created_at) VALUES (?,?,?,?,?,NOW())' + )->execute([$uid, $type, $amount, $balance, $remark]); + $pdo->commit(); + return ['ok' => true, 'balance' => $balance, 'error' => '']; + } catch (Throwable $e) { + if (isset($pdo) && $pdo->inTransaction()) { + $pdo->rollBack(); + } + return ['ok' => false, 'balance' => 0, 'error' => $e->getMessage()]; + } +} + +/** + * 生成唯一邀请码(大写字母+数字,8 位)。 + */ +function genInviteCode() +{ + $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + for ($i = 0; $i < 20; $i++) { + $code = ''; + for ($j = 0; $j < 8; $j++) { + $code .= $chars[random_int(0, strlen($chars) - 1)]; + } + $stmt = db()->prepare('SELECT 1 FROM ' . tn('users') . ' WHERE invite_code = ?'); + $stmt->execute([$code]); + if (!$stmt->fetch()) { + return $code; + } + } + return strtoupper(substr(md5(uniqid('', true)), 0, 8)); +} + +/** + * 根据邀请码查邀请人 ID(不存在返回 0)。 + */ +function getInviterIdByCode($code) +{ + $code = trim((string) $code); + if ($code === '') { + return 0; + } + $stmt = db()->prepare('SELECT id FROM ' . tn('users') . ' WHERE invite_code = ?'); + $stmt->execute([$code]); + $id = $stmt->fetchColumn(); + return $id === false ? 0 : (int) $id; +} + +/** + * 今天是否已签到(防重复)。 + */ +function hasSignedToday($uid) +{ + try { + $stmt = db()->prepare('SELECT 1 FROM ' . tn('sign_logs') . ' WHERE user_id = ? AND sign_date = CURDATE()'); + $stmt->execute([(int) $uid]); + return (bool) $stmt->fetchColumn(); + } catch (Throwable $e) { + return false; + } +} + +/** + * 获取站点名称(优先从数据库读取)。 + */ +function getSiteName() { + global $_site_name; + if ($_site_name !== null) { + return $_site_name; + } + + try { + $db = db(); + $stmt = $db->query("SELECT v FROM " . tn('settings') . " WHERE k = 'site_name'"); + $val = $stmt->fetchColumn(); + $_site_name = ($val !== false && $val !== null && $val !== '') ? $val : '自由云商城'; + } catch (Exception $e) { + $_site_name = '自由云商城'; + } + return $_site_name; +} \ No newline at end of file diff --git a/includes/header.php b/includes/header.php new file mode 100644 index 0000000..5be0816 --- /dev/null +++ b/includes/header.php @@ -0,0 +1,114 @@ + + + + + + + <?= h($pageTitle) ?> - <?= h(getSiteName()) ?> +' . "\n "; +} +?> + + + + + + + + +
    + + +
    + +
    + + 你的邮箱尚未验证,点击重新发送验证邮件 +
    + + +
    diff --git a/includes/init.php b/includes/init.php new file mode 100644 index 0000000..f279c09 --- /dev/null +++ b/includes/init.php @@ -0,0 +1,27 @@ + Logger.php) +spl_autoload_register(function ($class) { + $file = __DIR__ . '/' . $class . '.php'; + if (is_file($file)) { + require $file; + } +}); + +require_once __DIR__ . '/Lang.php'; + +// 初始化多语言系统 +Lang::setDir(__DIR__ . '/../assets/lang'); +Lang::detectLocale(); + +// 初始化日志系统 +Logger::init(__DIR__ . '/../storage/logs', true, defined('DEBUG') && DEBUG ? Logger::DEBUG : Logger::INFO); + +// 初始化缓存系统 +Cache::init(__DIR__ . '/../storage/cache', true); \ No newline at end of file diff --git a/includes/mailer.php b/includes/mailer.php new file mode 100644 index 0000000..ded74d5 --- /dev/null +++ b/includes/mailer.php @@ -0,0 +1,197 @@ +host = (string) $host; + $this->port = (int) $port; + $this->enc = (string) $enc; + $this->user = (string) $user; + $this->pass = (string) $pass; + } + + /** + * 发送邮件。 + * @param string $to 收件人,多个用逗号分隔 + * @param string $subject 主题 + * @param string $bodyHtml 正文(HTML) + * @param string $fromEmail 发件人邮箱 + * @param string $fromName 发件人名称 + * @return bool + */ + public function send($to, $subject, $bodyHtml, $fromEmail, $fromName) + { + if ($this->host === '') { + $this->lastError = 'SMTP 主机未配置'; + return false; + } + $port = $this->port ?: ($this->enc === 'ssl' ? 465 : 587); + $timeout = 20; + $scheme = $this->enc === 'ssl' ? 'ssl' : 'tcp'; + $opts = []; + if ($this->enc === 'ssl' || $this->enc === 'tls') { + $opts['ssl'] = [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ]; + } + $ctx = $opts ? stream_context_create($opts) : null; + + $errno = 0; + $errstr = ''; + $sock = @stream_socket_client( + $scheme . '://' . $this->host . ':' . $port, + $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx + ); + if (!$sock) { + $this->lastError = "无法连接 SMTP 服务器({$this->host}:{$port}):$errstr ($errno)"; + return false; + } + stream_set_timeout($sock, $timeout); + + // 读取服务商标语 220 + if ($this->talk($sock, null, [220]) === false) { + fclose($sock); + return false; + } + + $hostHeader = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost'; + + // EHLO + if ($this->talk($sock, 'EHLO ' . $hostHeader, [250]) === false) { + fclose($sock); + return false; + } + + // STARTTLS 协商 + if ($this->enc === 'tls') { + if ($this->talk($sock, 'STARTTLS', [220]) === false) { + fclose($sock); + return false; + } + if (!@stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + $this->lastError = 'STARTTLS 加密协商失败'; + fclose($sock); + return false; + } + if ($this->talk($sock, 'EHLO ' . $hostHeader, [250]) === false) { + fclose($sock); + return false; + } + } + + // AUTH LOGIN + if ($this->talk($sock, 'AUTH LOGIN', [334]) === false) { + fclose($sock); + return false; + } + if ($this->talk($sock, base64_encode($this->user), [334]) === false) { + fclose($sock); + return false; + } + if ($this->talk($sock, base64_encode($this->pass), [235]) === false) { + fclose($sock); + return false; + } + + // 信封 + if ($this->talk($sock, 'MAIL FROM:<' . $fromEmail . '>', [250]) === false) { + fclose($sock); + return false; + } + $recipients = array_filter(array_map('trim', explode(',', $to))); + foreach ($recipients as $rcpt) { + $this->talk($sock, 'RCPT TO:<' . $rcpt . '>', [250, 251]); + } + + // 信体 + if ($this->talk($sock, 'DATA', [354]) === false) { + fclose($sock); + return false; + } + $data = $this->buildData($to, $subject, $bodyHtml, $fromEmail, $fromName); + fwrite($sock, $data); + if ($this->talk($sock, '.', [250]) === false) { + fclose($sock); + return false; + } + $this->talk($sock, 'QUIT', [221]); + fclose($sock); + return true; + } + + private function buildData($to, $subject, $body, $fromEmail, $fromName) + { + $eol = "\r\n"; + $headers = []; + $headers[] = 'From: ' . $this->encodeHeader($fromName) . ' <' . $fromEmail . '>'; + $headers[] = 'To: ' . $to; + $headers[] = 'Subject: ' . $this->encodeHeader($subject); + $headers[] = 'Date: ' . date('r'); + $headers[] = 'MIME-Version: 1.0'; + $headers[] = 'Content-Type: text/html; charset=UTF-8'; + $headers[] = 'Content-Transfer-Encoding: base64'; + $msg = implode($eol, $headers) . $eol . $eol; + $msg .= chunk_split(base64_encode($body), 76, $eol); + return $msg; + } + + private function encodeHeader($s) + { + if (preg_match('/[^\x00-\x7F]/', (string) $s)) { + return '=?UTF-8?B?' . base64_encode($s) . '?='; + } + return $s; + } + + /** + * 与服务器交互:发送命令并读取响应。 + * @param resource $sock + * @param string|null $cmd 为 null 时仅读取(用于读取初始标语) + * @param array $expectCodes 期望的状态码数组 + * @return string|false + */ + private function talk($sock, $cmd, $expectCodes) + { + if ($cmd !== null) { + fwrite($sock, $cmd . "\r\n"); + $this->log[] = 'C: ' . $cmd; + } + $resp = ''; + while (true) { + $line = @fgets($sock, 515); + if ($line === false) { + break; + } + $resp .= $line; + // 末位第 4 个字符为空格表示响应结束(多行响应以 "- " 续接) + if (isset($line[3]) && $line[3] === ' ') { + break; + } + } + $this->log[] = 'S: ' . trim($resp); + $code = (int) substr($resp, 0, 3); + if (!in_array($code, (array) $expectCodes, true)) { + $this->lastError = '服务器返回错误:' . trim($resp); + return false; + } + return $resp; + } +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..a561295 --- /dev/null +++ b/index.php @@ -0,0 +1,162 @@ +prepare($sql); +$stmt->execute($params); +$products = $stmt->fetchAll(); +$anns = db()->query( + 'SELECT * FROM ' . tn('announcements') . ' WHERE status = 1 ORDER BY pinned DESC, created_at DESC LIMIT 3' +)->fetchAll(); +$groups = db()->query('SELECT name, icon FROM ' . tn('product_groups') . ' ORDER BY sort_order ASC, name ASC')->fetchAll(); +$categories = array_column($groups, 'name'); +$about = getSetting('mall_about', ''); +require 'includes/header.php'; +?> +
    +
    +

    +

    + +
    +
    + +
    +
    + +
    + + + + + + + +
    + +
    +
    + + + +
    +

    +
    +

    +
    +
    + + +
    +
    + + + + + + +
    + + +

    + + + +
    + + + diff --git a/install.php b/install.php new file mode 100644 index 0000000..b8b7664 --- /dev/null +++ b/install.php @@ -0,0 +1,248 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + $pdo = new PDO("mysql:host={$host};port={$port};charset=utf8mb4", $dbuser, $dbpass, $opts); + $pdo->exec("CREATE DATABASE IF NOT EXISTS `{$dbname}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $pdo = new PDO("mysql:host={$host};port={$port};dbname={$dbname};charset=utf8mb4", $dbuser, $dbpass, $opts); + + // 2) 导入表结构与初始数据 + $sql = file_get_contents(__DIR__ . '/db.sql'); + $sql = preg_replace('/^--.*$/m', '', $sql); // 去掉 -- 注释行 + $sql = str_replace('__PREFIX__', $prefix, $sql); + $stmts = preg_split('/;\s*\n/', $sql); + foreach ($stmts as $s) { + $s = trim($s); + if ($s === '') continue; + $pdo->exec($s); + } + + // 3) 创建管理员账号(同时生成唯一邀请码,避免 uk_invite_code 冲突) + $hash = password_hash($adminpass, PASSWORD_DEFAULT); + $adminCode = strtoupper(substr(md5(uniqid('admin', true)), 0, 8)); + $pdo->prepare("INSERT INTO `{$prefix}users` (username,password,email,invite_code,is_admin,role,created_at) VALUES (?,?,?,?,1,'admin',NOW())") + ->execute([$adminuser, $hash, $adminemail, $adminCode]); + + // 4) 生成 config.php + $cfg = "getMessage(); + } + } + // 出错则回到表单(下方会渲染步骤 2 并显示错误) + $step = '2'; +} + +// ====================== 视图渲染 ====================== +$title = '自由云商城 · 安装向导'; +$cssVer = file_exists(__DIR__ . '/assets/style.css') ? filemtime(__DIR__ . '/assets/style.css') : time(); +?> + + + + + + <?= htmlspecialchars($title) ?> + + + + + +
    +
    +

    自由云商城安装向导

    +
    只需几步,填写数据库信息即可完成安装。
    +
    +
    1. 环境检测
    +
    2. 数据库设置
    +
    3. 完成
    +
    + + + ='), '当前版本 ' . PHP_VERSION], + ['PDO 扩展', extension_loaded('pdo'), '用于数据库连接'], + ['PDO_MYSQL 扩展', extension_loaded('pdo_mysql'), '用于 MySQL 连接'], + ['config.php 可写', is_writable(__DIR__), '需对商城目录有写权限'], + ]; + $allOk = true; + foreach ($checks as $c) { if (!$c[1]) $allOk = false; } + ?> +
      + +
    • + + +
    • + +
    + + 下一步:填写数据库信息 → + +

    请先满足上述环境要求(尤其启用 pdo_mysql 扩展,并确保目录可写)后再继续。

    + + + + +
    + +
    +

    数据库信息

    +
    + + + + + + +
    +

    站点与管理员

    +
    + + + + + +
    + +
    + + +
    + +

    安装成功!

    +

    数据库已创建、数据表与初始商品已写入,管理员账号已生成。

    + +

    为安全起见,建议安装完成后删除或重命名 install.php

    +
    + +
    +

    自由云科技团队 · 公益技术团队

    +
    + + + + + + 已安装 + + +
    +

    商城已安装

    +

    检测到 config.php 已存在,无需重复安装。

    +
    + 进入商城前台 + 后台管理 + 强制重新安装 +
    +

    如需重新安装,请先删除 config.php,或点击「强制重新安装」。

    +
    + + + 手动保存配置 + + +
    +

    无法写入 config.php

    +

    数据库已初始化成功,但目录不可写。请手动创建 mall/config.php 并粘贴以下内容:

    + +

    保存后访问 index.php 即可使用。

    +
    + prepare('UPDATE ' . tn('users') . ' SET invite_code = ? WHERE id = ?') + ->execute([$myInviteCode, $user['id']]); +} +$link = siteBaseUrl() . '/login.php?mode=register&inv=' . rawurlencode($myInviteCode); + +// 邀请记录:谁通过我的邀请码注册 +$stmt = db()->prepare( + 'SELECT username, nickname, created_at FROM ' . tn('users') . ' WHERE invited_by = ? ORDER BY created_at DESC' +); +$stmt->execute([$user['id']]); +$invited = $stmt->fetchAll(); +$earned = $invPts * count($invited); +?> + +
    +

    邀请好友

    + + +

    积分系统当前已关闭,邀请功能暂不可用。

    + +
    +
    +
    每成功邀请 1 位好友注册,你可得 积分
    +
    已成功邀请 人,累计获得 积分
    +
    + +
    +
    我的邀请码
    +
    + +
    + + + +
    + 好友通过你的邀请链接注册成功,系统会自动给你的账号加上积分,无需手动操作。 +
    +
    + +
    +

    邀请记录( 人)

    + +

    还没有好友通过你的邀请注册,快分享链接试试吧~

    + + + + + + + + + + + + + +
    用户名昵称注册时间奖励
    + 积分
    + +
    + +
    + + + diff --git a/lang.php b/lang.php new file mode 100644 index 0000000..3cac0d6 --- /dev/null +++ b/lang.php @@ -0,0 +1,28 @@ +English + * 本页设置 $_SESSION['lang'] + cookie 后,重定向回来源页(或首页)。 + */ +require __DIR__ . '/includes/init.php'; + +$lang = trim($_GET['lang'] ?? ''); +$allowed = []; +foreach (Lang::available() as $l) { + $allowed[] = $l['code']; +} + +if ($lang !== '' && in_array($lang, $allowed, true)) { + $_SESSION['lang'] = $lang; + setcookie('fnw_lang', $lang, time() + 31536000, '/'); + Lang::setLocale($lang); +} + +// 重定向回来源页,避免跳转到站外 +$ref = $_SERVER['HTTP_REFERER'] ?? ''; +$host = parse_url((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? ''), PHP_URL_HOST); +if ($ref === '' || parse_url($ref, PHP_URL_HOST) !== $host) { + $ref = '/'; +} +header('Location: ' . $ref, true, 302); +exit; diff --git a/login.php b/login.php new file mode 100644 index 0000000..0d93d44 --- /dev/null +++ b/login.php @@ -0,0 +1,132 @@ +prepare('SELECT * FROM ' . tn('users') . ' WHERE username = ?'); + $stmt->execute([$u]); + $row = $stmt->fetch(); + if ($row && password_verify($pw, $row['password'])) { + $_SESSION['user_id'] = $row['id']; + unset($_SESSION['csrf']); + redirect($next ?: 'index.php'); + } + $err = __('login.err_login_failed'); + } + + if (isset($_POST['register'])) { + $u = trim($_POST['username'] ?? ''); + $pw = $_POST['password'] ?? ''; + $pw2 = $_POST['password2'] ?? ''; + $em = trim($_POST['email'] ?? ''); + $cap = strtoupper(trim($_POST['captcha'] ?? '')); + // 邀请码:优先使用表单填写,其次使用 URL 参数 + $postInvite = isset($_POST['invite_code']) ? trim($_POST['invite_code']) : null; + $invite = $postInvite !== null ? $postInvite : trim($_GET['inv'] ?? ''); + + // 自研图形验证码:一次性校验后清空 + $sessCap = isset($_SESSION['captcha']) ? strtoupper($_SESSION['captcha']) : ''; + unset($_SESSION['captcha']); + if ($cap === '' || $cap !== $sessCap) { + $err = __('login.err_captcha_wrong'); + } + elseif (strlen($u) < 3) $err = __('login.err_username_short'); + elseif (strlen($pw) < 6) $err = __('login.err_password_short'); + elseif ($pw !== $pw2) $err = __('login.err_password_mismatch'); + elseif (!filter_var($em, FILTER_VALIDATE_EMAIL)) $err = __('login.err_email_invalid'); + // 若用户在表单里手动填写了邀请码,则必须校验通过 + elseif ($postInvite !== null && $postInvite !== '' && getInviterIdByCode($postInvite) === 0) { + $err = __('login.err_invite_invalid'); + } + + // 单 IP 每日最多注册 2 个账号 + if ($err === '') { + $ip = getClientIp(); + if (regCountToday($ip) >= 2) { + $err = __('login.err_ip_limit'); + } + } + + if ($err === '') { + $chk = db()->prepare('SELECT id FROM ' . tn('users') . ' WHERE username = ?'); + $chk->execute([$u]); + if ($chk->fetch()) { + $err = __('login.err_username_exists'); + } else { + $hash = password_hash($pw, PASSWORD_DEFAULT); + $myCode = genInviteCode(); + db()->prepare('INSERT INTO ' . tn('users') . ' (username,password,email,reg_ip,invite_code,verified,created_at) VALUES (?,?,?,?,?,0,NOW())') + ->execute([$u, $hash, $em, $ip, $myCode]); + $id = db()->lastInsertId(); + // 邀请奖励:通过他人邀请码注册,给邀请人加积分 + if ($invite !== '') { + $invId = getInviterIdByCode($invite); + if ($invId > 0 && $invId != $id) { + db()->prepare('UPDATE ' . tn('users') . ' SET invited_by = ? WHERE id = ?') + ->execute([$invId, $id]); + $invPts = max(0, (int) getSetting('points_invite', '20')); + if ($invPts > 0) { + addPoints($invId, 'invite', $invPts, '邀请好友 ' . $u . ' 注册成功'); + } + } + } + $_SESSION['user_id'] = $id; + // 发送邮箱验证邮件(不阻塞注册:失败也可正常使用) + $sendRes = sendVerifyMail($id); + $_SESSION['verify_notice'] = $sendRes['ok'] ? 'sent' : 'fail'; + unset($_SESSION['csrf']); + redirect($next ?: 'index.php'); + } + } + } +} + +require 'includes/header.php'; +?> +
    +
    +
    + + +
    + +

    + + +
    + + + + +
    + +
    + + + +
    + CAPTCHA + +
    + + + + +

    +
    + +
    +
    + diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..6ba5251 --- /dev/null +++ b/logout.php @@ -0,0 +1,7 @@ +prepare('SELECT * FROM ' . tn('orders') . ' WHERE user_id = ? ORDER BY created_at DESC'); +$stmt->execute([$user['id']]); +$orders = $stmt->fetchAll(); + +// 预取每笔订单的商品 +$orderItems = []; +foreach ($orders as $o) { + $it = db()->prepare('SELECT * FROM ' . tn('order_items') . ' WHERE order_id = ?'); + $it->execute([$o['id']]); + $orderItems[$o['id']] = $it->fetchAll(); +} + +require 'includes/header.php'; +?> +
    +

    + + + + + + +

    ' . __('myorders.shop_link') . '', __('myorders.no_orders')) ?>

    + +
    + +
    +
    + + +
    +
    + +
    + + +
    + +
    +
    + + + + +
    + +
    ·
    + + +
    ·
    + + + + +
    + +
    + +
    + diff --git a/mytickets.php b/mytickets.php new file mode 100644 index 0000000..8a8287f --- /dev/null +++ b/mytickets.php @@ -0,0 +1,185 @@ + 0) { + verifyCsrf(); + $stmt = db()->prepare('SELECT id, user_id, status FROM ' . tn('tickets') . ' WHERE id = ?'); + $stmt->execute([$viewId]); + $t = $stmt->fetch(); + if (!$t || (int) $t['user_id'] !== (int) $user['id']) { + $err = __('mytickets.err_no_perm'); + } elseif ($t['status'] !== 'closed') { + $err = __('mytickets.err_not_closed'); + } else { + db()->prepare('UPDATE ' . tn('tickets') . ' SET status = ?, updated_at = NOW(), closed_at = NULL WHERE id = ?') + ->execute(['open', $viewId]); + redirect('mytickets.php?view=' . $viewId . '&ok=1'); + } +} + +// 用户追加回复 +$err = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['user_reply']) && $viewId > 0) { + verifyCsrf(); + $reply = trim($_POST['reply_message'] ?? ''); + + $stmt = db()->prepare('SELECT id, user_id, status FROM ' . tn('tickets') . ' WHERE id = ?'); + $stmt->execute([$viewId]); + $t = $stmt->fetch(); + + if (!$t || (int) $t['user_id'] !== (int) $user['id']) { + $err = __('mytickets.err_no_perm'); + } elseif ($t['status'] === 'closed') { + $err = __('mytickets.err_closed_reply'); + } elseif ($reply === '') { + $err = __('mytickets.err_reply_empty'); + } elseif (strlen($reply) > 5000) { + $err = __('mytickets.err_reply_long'); + } else { + db()->prepare('INSERT INTO ' . tn('ticket_replies') . ' (ticket_id, user_id, is_admin, message, created_at) VALUES (?,?,?,?,NOW())') + ->execute([$viewId, $user['id'], 0, $reply]); + db()->prepare('UPDATE ' . tn('tickets') . ' SET status = ?, updated_at = NOW() WHERE id = ?') + ->execute(['open', $viewId]); + redirect('mytickets.php?view=' . $viewId . '&ok=1'); + } +} + +$ticket = null; +$replies = []; +if ($viewId > 0) { + $stmt = db()->prepare( + 'SELECT t.*, d.name AS dept_name FROM ' . tn('tickets') . ' t + LEFT JOIN ' . tn('ticket_departments') . ' d ON d.id = t.dept_id + WHERE t.id = ? AND t.user_id = ?' + ); + $stmt->execute([$viewId, $user['id']]); + $ticket = $stmt->fetch(); + + if ($ticket) { + $rstmt = db()->prepare( + 'SELECT r.*, u.username, u.nickname FROM ' . tn('ticket_replies') . ' r + LEFT JOIN ' . tn('users') . ' u ON u.id = r.user_id + WHERE r.ticket_id = ? ORDER BY r.created_at ASC' + ); + $rstmt->execute([$viewId]); + $replies = $rstmt->fetchAll(); + } +} + +$list = db()->prepare( + 'SELECT t.*, d.name AS dept_name FROM ' . tn('tickets') . ' t + LEFT JOIN ' . tn('ticket_departments') . ' d ON d.id = t.dept_id + WHERE t.user_id = ? ORDER BY t.updated_at DESC' +); +$list->execute([$user['id']]); +$list = $list->fetchAll(); + +require 'includes/header.php'; +?> + + + + + +

    + + + +
    +

    +
    +
    + + + + + +
    + +

    #' . (int)$ticket['order_id'] . '', __('mytickets.renewal_note')) ?>

    + + +
    +
    +
    +

    +
    +
    + + + +
    +
    +
    +
    + +
    +
    +
    +
    + + +
    + + +
    + + + +
    + + +
    +
    + +

    +
    + + + + +
    + +
    +
    + +
    +

    +
    + +
    + +

    + +
    + + + + + + + + + + + + + + + + +
    #
    +
    + +
    + + diff --git a/order_detail.php b/order_detail.php new file mode 100644 index 0000000..1409c57 --- /dev/null +++ b/order_detail.php @@ -0,0 +1,122 @@ +prepare('SELECT o.*, u.username AS u_name, u.email AS u_email FROM ' . tn('orders') . ' o LEFT JOIN ' . tn('users') . ' u ON u.id = o.user_id WHERE o.id = ? AND o.user_id = ?'); +$stmt->execute([$id, $user['id']]); +$order = $stmt->fetch(); +if (!$order) { + http_response_code(404); + require 'includes/header.php'; + echo '
    ' + . '

    订单不存在,或不属于当前账号。

    ' + . '返回我的订单
    '; + require 'includes/footer.php'; + exit; +} +$itStmt = db()->prepare('SELECT * FROM ' . tn('order_items') . ' WHERE order_id = ?'); +$itStmt->execute([$order['id']]); +$items = $itStmt->fetchAll(); +$totalQty = array_sum(array_column($items, 'qty')); + +// 发货信息(管理员发货后才有) +$dlvStmt = db()->prepare('SELECT * FROM ' . tn('order_deliveries') . ' WHERE order_id = ? ORDER BY created_at DESC LIMIT 1'); +$dlvStmt->execute([$order['id']]); +$delivery = $dlvStmt->fetch(); + +// 是否可发起续期(周期商品且已发货/已完成) +$canRenew = ($order['period_days'] > 0) && in_array($order['status'], ['shipped', 'completed'], true); + +require 'includes/header.php'; +?> +
    + 返回我的订单 +

    订单详情

    + +
    +
    +
    +
    订单号:
    +
    +
    + +
    + +
    +
    商品单价数量小计
    + +
    + · ' . periodLabel($it['period_days']) . '' : '' ?> + + × + +
    + +
    + +
    +
    +
    订单发起人
    +
    +
    账号邮箱:
    +
    +
    +
    联系信息
    +
    联系人:
    +
    联系邮箱:
    +
    收货地址:
    +
    +
    +
    周期 / 到期
    +
    周期:
    +
    到期时间:
    +
    +
    +
    备注
    +
    +
    +
    +
    支付方式
    +
    +
    +
    + + +
    +
    发货信息(连接与登录凭据 / 商品信息)
    +
    激活状态:
    +
    服务器 IP:
    +
    连接地址:
    +
    登录账户:
    +
    + 登录密码: + + +
    + +
    商品信息:
    + + +
    备注:
    + +
    + + +
    + + 合计: +
    + + +
    + 申请续期 + 本订单为周期商品,可在到期后通过工单申请续期。 +
    + +
    +
    + diff --git a/pay_notify.php b/pay_notify.php new file mode 100644 index 0000000..55ee87a --- /dev/null +++ b/pay_notify.php @@ -0,0 +1,85 @@ + $gateway, 'raw_input' => file_get_contents('php://input')]); + +// 收集所有参数 +$params = array_merge($_GET, $_POST); +unset($params['gateway']); + +if (!$gw->verifyNotify($params)) { + Logger::warn('支付通知验签失败', ['gateway' => $gateway]); + http_response_code(403); + exit('signature mismatch'); +} + +$tradeNo = $gw->getTradeNo($params); +$isPaid = $gw->isPaid($params); + +// 查找对应的支付记录 +$outTradeNo = $params['out_trade_no'] ?? ''; +$stmt = db()->prepare('SELECT id,order_id,status FROM ' . tn('pay_logs') . ' WHERE order_id=? AND pay_method=? ORDER BY id DESC LIMIT 1'); +$stmt->execute([$outTradeNo, $gateway]); +$payLog = $stmt->fetch(); + +if (!$payLog) { + Logger::error('支付通知:未找到支付记录', ['out_trade_no' => $outTradeNo]); + // 即使找不到记录也返回成功,避免重复通知 + $gw->respondSuccess(); + exit; +} + +if ($payLog['status'] === 'paid') { + // 已处理过,直接返回成功 + $gw->respondSuccess(); + exit; +} + +if ($isPaid) { + // 支付成功 → 更新订单状态 + db()->beginTransaction(); + try { + Payment::updatePayStatus($payLog['id'], 'paid', $tradeNo); + // 更新订单状态为已付款 + db()->prepare('UPDATE ' . tn('orders') . ' SET status=\'paid\', paid_at=NOW() WHERE id=?') + ->execute([$payLog['order_id']]); + db()->commit(); + Logger::info('支付成功', ['order_id' => $payLog['order_id'], 'trade_no' => $tradeNo, 'gateway' => $gateway]); + + // TODO: 发送支付成功通知邮件 + } catch (Exception $e) { + db()->rollBack(); + Logger::error('支付成功后更新订单失败', ['error' => $e->getMessage()]); + } +} else { + Logger::warn('支付未成功', ['trade_status' => $params['trade_status'] ?? '']); +} + +$gw->respondSuccess(); +exit; diff --git a/points_debug.php b/points_debug.php new file mode 100644 index 0000000..a8ae479 --- /dev/null +++ b/points_debug.php @@ -0,0 +1,43 @@ +prepare('SELECT id, username, is_admin, points FROM ' . tn('users') . ' WHERE username = ?'); +$stmt->execute([$target]); +$rows = $stmt->fetchAll(); + +if (empty($rows)) { + echo "未找到用户「{$target}」\n"; +} else { + foreach ($rows as $r) { + echo "id=" . (int)$r['id'] + . " username=" . $r['username'] + . " is_admin=" . (int)$r['is_admin'] + . " points=" . (int)$r['points'] . "\n"; + } + // 用和前台完全相同的方式再取一次,验证 getCurrentUser 路径 + $u = currentUser(); + echo "\n=== 当前登录会话(currentUser)===\n"; + if ($u) { + echo "id=" . (int)$u['id'] . " username=" . $u['username'] . " points(对象)=" . (int)$u['points'] + . " points(getUserPoints)=" . getUserPoints($u['id']) . "\n"; + } else { + echo "(未登录)\n"; + } +} +echo "\n提示:如果这里显示的 points 就是 900,说明数据库没错,问题在页面缓存/没上传最新代码;\n"; +echo "如果这里也是 130,说明后台加的那笔积分写到了别的数据库——检查云服务器上商城自己的 config.php 的 DB_HOST。\n"; diff --git a/product.php b/product.php new file mode 100644 index 0000000..d3b2ac6 --- /dev/null +++ b/product.php @@ -0,0 +1,143 @@ +prepare('SELECT * FROM ' . tn('products') . ' WHERE id = ? AND status = 1'); +$stmt->execute([$id]); +$product = $stmt->fetch(); +if (!$product) { + header('Location: index.php'); + exit; +} + +// 当前登录用户是否已购买该商品,并取最新发货 / 商品信息 +$hasBought = false; +$purchase = null; +if ($user = currentUser()) { + $bstmt = db()->prepare( + 'SELECT 1 FROM ' . tn('orders') . ' o + INNER JOIN ' . tn('order_items') . ' oi ON oi.order_id = o.id + WHERE o.user_id = ? AND oi.product_id = ? AND o.status IN ("paid","shipped","completed") LIMIT 1' + ); + $bstmt->execute([$user['id'], $id]); + $hasBought = (bool) $bstmt->fetchColumn(); + + $pstmt = db()->prepare( + 'SELECT d.* FROM ' . tn('order_deliveries') . ' d + INNER JOIN ' . tn('orders') . ' o ON o.id = d.order_id + INNER JOIN ' . tn('order_items') . ' oi ON oi.order_id = o.id + WHERE o.user_id = ? AND oi.product_id = ? AND o.status IN ("paid","shipped","completed") + ORDER BY d.id DESC LIMIT 1' + ); + $pstmt->execute([$user['id'], $id]); + $purchase = $pstmt->fetch(); +} + +$err = $ok = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add'])) { + verifyCsrf(); + $qty = max(1, (int) ($_POST['qty'] ?? 1)); + if ($product['stock'] <= 0) { + $err = __('product_page.err_out_of_stock'); + } elseif ($qty > $product['stock']) { + $err = str_replace('{max}', $product['stock'], __('product_page.err_low_stock')); + } else { + if (!isset($_SESSION['cart'])) $_SESSION['cart'] = []; + $cur = (int) ($_SESSION['cart'][$id] ?? 0); + $_SESSION['cart'][$id] = $cur + $qty; + header('Location: cart.php'); + exit; + } +} + +require 'includes/header.php'; +?> +
    + +
    +
    + + <?= h($product['name']) ?> + + + +
    +
    + +

    + +
    + +
    + +
    + +
    + +

    + +

    + + 0): ?> +
    + + + +
    + +

    + +
    +
    +
    + + +
    +
    +
    + + + +
    + +
    +
    + + + diff --git a/settings.php b/settings.php new file mode 100644 index 0000000..716fa35 --- /dev/null +++ b/settings.php @@ -0,0 +1,166 @@ +prepare('SELECT id, username, email, nickname, verified, is_admin, status, avatar FROM ' . tn('users') . ' WHERE id = ?'); +$stmt->execute([$_SESSION['user_id']]); +$user = $stmt->fetch(); + +$msg = ''; +$msgOk = false; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verifyCsrf(); + $u = trim($_POST['username'] ?? ''); + $em = trim($_POST['email'] ?? ''); + $nk = trim($_POST['nickname'] ?? ''); + + if (mb_strlen($u) < 3) $msg = __('settings_page.err_username_short'); + elseif (!filter_var($em, FILTER_VALIDATE_EMAIL)) $msg = __('settings_page.err_email_invalid'); + elseif ($nk !== '' && mb_strlen($nk) > 30) $msg = __('settings_page.err_nickname_long'); + + if ($msg === '') { + $chk = db()->prepare('SELECT id FROM ' . tn('users') . ' WHERE username = ? AND id <> ?'); + $chk->execute([$u, $user['id']]); + if ($chk->fetch()) { + $msg = __('settings_page.err_username_taken'); + } else { + $emailChanged = ($em !== $user['email']); + db()->prepare('UPDATE ' . tn('users') . ' SET username = ?, email = ?, nickname = ? WHERE id = ?') + ->execute([$u, $em, $nk, $user['id']]); + // 更换邮箱则取消验证状态,要求重新验证 + if ($emailChanged && !empty($user['verified'])) { + db()->prepare('UPDATE ' . tn('users') . ' SET verified = 0 WHERE id = ?') + ->execute([$user['id']]); + } + // 重新读取最新资料 + $stmt->execute([$user['id']]); + $user = $stmt->fetch(); + $msg = $emailChanged ? __('settings_page.saved_email_change') : __('settings_page.saved'); + $msgOk = true; + } + } +} + +require 'includes/header.php'; +?> +
    +

    + + +

    + + +
    + + + +
    +

    +
    +
    + + + + + +
    +
    + + +

    +
    +
    +

    +
    + + + + +
    + + +
    +
    + +
    +

    + +

    + +

    ' . __('settings_page.verify_send_link') . '', __('settings_page.verify_not_done')) ?>

    + +
    +
    + + + diff --git a/sign.php b/sign.php new file mode 100644 index 0000000..8095fa2 --- /dev/null +++ b/sign.php @@ -0,0 +1,78 @@ +beginTransaction(); + // 写入签到记录(uk_user_date 唯一,重复签到会抛异常) + $pdo->prepare('INSERT INTO ' . tn('sign_logs') . ' (user_id, sign_date, points, created_at) VALUES (?, CURDATE(), ?, NOW())') + ->execute([$user['id'], $signPoints]); + // 同事务内加积分并写流水(避免嵌套事务) + $pdo->prepare('UPDATE ' . tn('users') . ' SET points = points + ? WHERE id = ?') + ->execute([$signPoints, $user['id']]); + $bal = getUserPoints($user['id']); + $pdo->prepare('INSERT INTO ' . tn('points_log') . ' (user_id, type, amount, balance, remark, created_at) VALUES (?, ?, ?, ?, ?, NOW())') + ->execute([$user['id'], 'sign', $signPoints, $bal, '每日签到']); + $pdo->commit(); + $points = getUserPoints($user['id']); + $signed = true; + $msg = '签到成功,获得 ' . $signPoints . ' 积分!'; + } catch (Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + $err = '签到失败:' . $e->getMessage(); + } + } + } + +require 'includes/header.php'; +?> +
    +

    每日签到

    + +
    +
    +
    +
    我的积分
    +
    +
    + +

    积分系统当前已关闭,暂不可签到。

    + + + +

    每日签到可领取 积分,积分可用于兑换设置了「积分价」的商品。

    + +
    +

    + + +
    + + +
    + +
    + +

    + 想赚更多积分?邀请好友 注册成功可得 积分。 +

    +
    + diff --git a/ticket.php b/ticket.php new file mode 100644 index 0000000..1ccc55b --- /dev/null +++ b/ticket.php @@ -0,0 +1,147 @@ +query('SELECT * FROM ' . tn('ticket_departments') . ' ORDER BY sort, id')->fetchAll(); +if (empty($depts)) { + $depts = [['id' => 0, 'name' => __('ticket.meta_unspecified'), 'description' => '']]; +} + +// 当前用户可续期的订单(周期商品 + 已发货/已完成) +$renewOrders = db()->prepare( + 'SELECT o.id, o.order_no, o.period_days, o.expires_at FROM ' . tn('orders') . ' o + WHERE o.user_id = ? AND o.period_days > 0 AND o.status IN ("shipped","completed") ORDER BY o.created_at DESC' +); +$renewOrders->execute([$user['id']]); +$renewOrders = $renewOrders->fetchAll(); + +$renewOrderId = (int) ($_GET['order_id'] ?? 0); + +$msg = ''; +$err = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verifyCsrf(); + $dept = (int) ($_POST['dept_id'] ?? 0); + $subject = trim($_POST['subject'] ?? ''); + $message = trim($_POST['message'] ?? ''); + $priority = (int) ($_POST['priority'] ?? 2); + $type = in_array($_POST['type'] ?? 'general', ['general', 'renewal'], true) ? $_POST['type'] : 'general'; + $orderId = (int) ($_POST['order_id'] ?? 0); + + if ($type === 'renewal') { + // 校验关联订单确实属于本人且可续期 + $okOrder = false; + foreach ($renewOrders as $ro) { + if ((int) $ro['id'] === $orderId) { $okOrder = true; break; } + } + if (!$okOrder) { + $err = __('ticket_page.err_order_invalid'); + } elseif ($subject === '') { + $subject = __('ticket_page.subject_renewal_prefix') . $orderId; + } + } + + if ($subject === '') $err = __('ticket_page.err_subject_empty'); + elseif (strlen($subject) > 160) $err = __('ticket_page.err_subject_long'); + elseif ($message === '') $err = __('ticket_page.err_message_empty'); + elseif (!in_array($priority, [1, 2, 3], true)) $err = __('ticket_page.err_priority_bad'); + + if ($err === '') { + db()->prepare( + 'INSERT INTO ' . tn('tickets') . + ' (user_id, dept_id, order_id, type, subject, message, priority, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,NOW(),NOW())' + )->execute([$user['id'], $dept, $type === 'renewal' ? $orderId : null, $type, $subject, $message, $priority, 'open']); + $tid = db()->lastInsertId(); + + // 通过 SMTP 通知处理人员(失败不影响工单入库) + $deptNameMap = []; + foreach ($depts as $d) { $deptNameMap[$d['id']] = $d['name']; } + $deptName = $deptNameMap[$dept] ?? __('ticket.meta_unspecified'); + $cfg = smtpConfig(); + if (!empty($cfg['notify_emails'])) { + $base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') + . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . dirname($_SERVER['SCRIPT_NAME']); + $url = rtrim($base, '/') . '/admin/tickets.php?view=' . $tid; + $typeLabel = ticketTypeLabel($type); + $orderLine = $type === 'renewal' + ? '

    类型:续期申请(关联订单 #' . $orderId . ')

    ' + : '

    类型:strong>普通工单

    '; + $body = '
    ' + . '

    [' . h(SITE_NAME) . '] 收到新工单 #' . $tid . '

    ' + . '

    提交人:' . h($user['username']) . '(' . h($user['email']) . ')

    ' + . '

    部门:' . h($deptName) . '

    ' + . '

    优先级:' . ticketPriorityLabel($priority) . '

    ' + . $orderLine + . '

    标题:' . h($subject) . '

    ' + . '
    ' . h($message) . '
    ' + . '

    前往后台处理

    ' + . '
    '; + fnwSendMail($cfg['notify_emails'], '[' . SITE_NAME . '] 新工单 #' . $tid . ' ' . $subject, $body); + } + redirect('mytickets.php?ok=1'); + } +} + +require 'includes/header.php'; +?> +
    +

    +

    + +

    + +
    +
    + + + + + + + + + + + + +
    + + +
    +
    +
    +
    + diff --git a/verify.php b/verify.php new file mode 100644 index 0000000..18f7e5d --- /dev/null +++ b/verify.php @@ -0,0 +1,76 @@ +prepare('SELECT id FROM ' . tn('users') . ' WHERE email_token = ?'); + $stmt->execute([$token]); + $row = $stmt->fetch(); + if (!$row) { + // 可能是重复点击:若当前登录用户已完成验证,则提示成功而非报错 + $uid = $_SESSION['user_id'] ?? 0; + if ($uid > 0) { + $chk = db()->prepare('SELECT verified FROM ' . tn('users') . ' WHERE id = ?'); + $chk->execute([$uid]); + $verified = (int) $chk->fetchColumn(); + if ($verified === 1) { + $msg = '你的邮箱已经完成验证,无需重复操作。'; + $ok = true; + } else { + $msg = '验证链接无效或已使用,请重新获取。'; + } + } else { + $msg = '验证链接无效或已使用,请重新获取。'; + } + } else { + db()->prepare('UPDATE ' . tn('users') . ' SET verified = 1, email_token = ? WHERE id = ?') + ->execute(['', $row['id']]); + $msg = '邮箱验证成功!你也可以在「用户设置」中完善昵称与资料。'; + $ok = true; + } + } +} else { + $msg = '链接不完整,请使用邮件中的完整验证链接。'; +} + +require 'includes/header.php'; +?> +
    +
    +
    + +
    +

    +

    + +
    +
    +