commit 1b6728e5c048c88311da768e6e481907b911e8d9 Author: Xiaoxiaobai5724 Date: Sun Aug 16 16:52:43 2026 +0800 2.1 版本文件 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..050ab3d --- /dev/null +++ b/LICENSE @@ -0,0 +1,11 @@ +Copyright (c) 2026 free_net_work. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4b35a7 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +## 自由云商城 + +**✿ 开源 ✿ PHP ✿ 免费 ✿ 轻量** + +![最新版本](https://img.shields.io/badge/最新版本-2.1-pink) + +自由云商城是自由云科技团队旗下的公益电商,坚持「公益 · 实惠 · 用心挑选」的理念,为社区提供周边、数码、虚拟服务与图书等好物。 + +如今,我们把这套商城系统完整开源,不仅是献给每一位需要轻量级电商解决方案的开发者、站长与公益组织,更是让大家找出这套商城系统的漏洞,协助我们的开发。 + + +### 为什么开源? + +自由云科技团队始终秉持「技术公益、共建共享」的初心。自由云商城从诞生之初就服务于公益项目与社区用户,经过数个版本的迭代,已经足够稳定、灵活、易用。我们相信,一套干净、轻量、无冗余功能的商城系统,不应该只被一个团队使用。 + +开源,是为了让更多有需要的人: + +- **免去重复造轮子**:无需从零开发一套商城,直接基于自由云商城二次开发或功能裁剪; +- **自由部署与使用**:不受商业授权限制,可用于个人项目、公益组织、非营利社区、教育场景; +- **共同参与改进**:任何人都可以提交工单、建议或代码,让这套系统越来越好。 + + +### 系统特点 + +- **轻量纯净**:核心代码精简,无冗余依赖,适合低配置服务器(1C1G 即可流畅运行); +- **公益友好**:内置公益模式开关,一键关闭价格显示、购物车结算,仅展示商品信息,适合公益展示型站点; +- **商品体系灵活**:支持实物商品与虚拟商品混合管理; +- **订单管理简洁**:后台订单列表清晰可查,支持状态筛选与批量操作,无需复杂 ERP 即可完成日常处理; +- **国际化**:内置多语言架构,支持前台界面一键切换语言,方便不同语种用户访问。 + +### 适用场景 + +- 公益组织/非营利机构的物资展示与发放平台; +- 校园社团的小型周边售卖; +- 个人创作者的作品展示与简单交易; +- 社区/论坛配套的积分兑换商城; +- 任何想快速拥有电商功能的轻量级项目。 + + +### 技术架构 + +| 项目 | 说明 | +| --- | --- | +| 后端语言 | PHP | +| 数据库 | MySQL / MariaDB / SQLite | +| 前端框架 | 原生 HTML + CSS + JavaScript | +| 会话管理 | 原生 PHP Session + Cookie | +| 安全机制 | CSRF 防护、SQL 预处理、XSS 过滤、输入校验 | +| 支付集成 | 支持支付宝/微信支付(可配置),公益模式可完全关闭支付功能 | +| 部署方式 | 支持源码直接部署,兼容主流虚拟主机与云服务器 | + + +### 获取与使用 + +**安装方式**: + +1. 下载源码包并解压至网站根目录; +2. 访问 `install.php`,按指引完成数据库配置与管理员账号创建; +3. 登录后台进行基础设置(站点名称、积分设置、支付参数等); +4. 添加商品,即可正式运行。 + +**环境要求**: + +- PHP 7.4 及以上版本 +- MySQL / MariaDB / SQLite +- 服务器需支持 URL 重写(Apache `mod_rewrite` / Nginx 伪静态) +- PHP 扩展:`PDO`、`GD`、`CURL`、`JSON`、`Session` + + +### 参与贡献 + +我们欢迎所有形式的贡献,包括但不限于: + +- 在工单中报告 BUG 或提出改进建议; +- 在合并请求中提交代码修复或功能增强; +- 在自己的项目中部署使用,并分享反馈。 + + +### 写在最后 + +自由云商城是自由云科技团队「为人民服务」理念的延伸。希望这套小小的系统,能帮助到更多想做点事情的人。 + +少一些商业套路,多一些实在功能。用起来,改起来,分享出去。 + +这就是我们开源它的全部理由。 \ No newline at end of file diff --git a/admin/_common.php b/admin/_common.php new file mode 100644 index 0000000..79e02ff --- /dev/null +++ b/admin/_common.php @@ -0,0 +1,99 @@ + ['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 = '已将账号 + } + } +} +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..43b2722 --- /dev/null +++ b/admin/announcements.php @@ -0,0 +1,133 @@ +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..e9af58a --- /dev/null +++ b/admin/departments.php @@ -0,0 +1,75 @@ +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..ff0d593 --- /dev/null +++ b/admin/groups.php @@ -0,0 +1,118 @@ + 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..9263ae0 --- /dev/null +++ b/admin/index.php @@ -0,0 +1,118 @@ +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..8f539ac --- /dev/null +++ b/admin/login.php @@ -0,0 +1,56 @@ +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..2fd1a5e --- /dev/null +++ b/admin/logout.php @@ -0,0 +1,6 @@ + +

系统日志

+ +

+ +
+
+ + + + 可用日期: + + + + 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..5cc46c4 --- /dev/null +++ b/admin/orders.php @@ -0,0 +1,276 @@ +prepare('UPDATE ' . tn('orders') . ' SET status = ? WHERE id = ?')->execute([$status, $id]); + $msg = '订单 + } +} +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 = '发货信息已保存(订单 + $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 . '] 你的订单 + $body = '
' + . '

连接地址:' . 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 = '订单 + } +} +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 = '订单 +} +$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..cb86088 --- /dev/null +++ b/admin/products.php @@ -0,0 +1,130 @@ +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..ebe99a7 --- /dev/null +++ b/admin/settings.php @@ -0,0 +1,288 @@ +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 { + $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 = '
' + . '

发送时间:' . 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..5f6ca45 --- /dev/null +++ b/admin/tickets.php @@ -0,0 +1,330 @@ +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 . '] 您的工单 + $body = '
' + . '

' + . '

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

'; + 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..c4dc371 --- /dev/null +++ b/admin/users.php @@ -0,0 +1,181 @@ +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 = '已为用户 + } 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..4b237bb --- /dev/null +++ b/announcement.php @@ -0,0 +1,59 @@ +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..0ee80c5 --- /dev/null +++ b/announcements.php @@ -0,0 +1,34 @@ +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..85389d7 --- /dev/null +++ b/api.php @@ -0,0 +1,252 @@ + 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; +} +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, + 'cart_add' => true, + 'cart_update' => true, + 'cart_remove' => true, + 'cart_clear' => true, + 'sign_in' => true, + 'ticket_submit'=> true, + 'search' => false, + 'products' => false, + 'product_info' => false, + 'announcements'=> false, +]; +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, '登录已过期'); } +} +$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('新工单 + 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..b0883f3 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,263 @@ +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 ─── + 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 = { + 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..5fbfb20 --- /dev/null +++ b/assets/marked.js @@ -0,0 +1,234 @@ +(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\(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?:)?\/\(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..7a4ab11 --- /dev/null +++ b/assets/style.css @@ -0,0 +1,2847 @@ +: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..5e7df5d --- /dev/null +++ b/assets/vendor/fontawesome/all.min.css @@ -0,0 +1,3 @@ +.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..556c307 --- /dev/null +++ b/avatar_upload.php @@ -0,0 +1,114 @@ + 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']; +$imgInfo = @getimagesize($file['tmp_name']); +$mime = ($imgInfo && !empty($imgInfo['mime'])) ? $imgInfo['mime'] : ''; +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; +} +if (!$imgInfo || $imgInfo[0] < 64 || $imgInfo[1] < 64) { + echo json_encode(['ok' => false, 'msg' => '图片尺寸不能小于 64×64 像素']); + exit; +} +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; +$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; +} +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) { +} +$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) { + 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..357b1b2 --- /dev/null +++ b/captcha.php @@ -0,0 +1,39 @@ + $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..24fe2d4 --- /dev/null +++ b/checkout.php @@ -0,0 +1,137 @@ +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; + $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) { + $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..d673be5 --- /dev/null +++ b/cron_expire_notify.php @@ -0,0 +1,106 @@ + 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[] = '订单 + 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[] = '预览 + $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[] = '订单 + } +} +$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; +} +function buildExpireMail($name, $orderNo, $expiresAt, $daysLeft) +{ + $exp = date('Y-m-d H:i', strtotime($expiresAt)); + $site = SITE_NAME; + return '
    到期时间:' . h($exp) . '

    ' + . '

    剩余天数: 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; + } + public static function isSqlite() + { + return self::$driver === 'sqlite'; + } + public static function isMysql() + { + return self::$driver === 'mysql'; + } + public static function pdo() + { + return self::connect(); + } + public static function close() + { + self::$pdo = null; + } + 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; + } + 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..3134180 --- /dev/null +++ b/includes/Lang.php @@ -0,0 +1,121 @@ + $code, + 'name' => $data['_meta']['name'], + 'direction' => $data['_meta']['direction'] ?? 'ltr', + ]; + } + } + return $list; + } + 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]; + } + 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; + } + private static function replacePlaceholders($text, $args) + { + if (count($args) === 1 && is_array($args[0])) { + $map = $args[0]; + foreach ($map as $k => $v) { + $text = str_replace('{' . $k . '}', $v, $text); + } + } else { + foreach ($args as $i => $v) { + $text = str_replace('{' . $i . '}', $v, $text); + } + } + return $text; + } +} +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..492339b --- /dev/null +++ b/includes/Logger.php @@ -0,0 +1,70 @@ + '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) . ']'; + } + $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); + 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..e6cb1bd --- /dev/null +++ b/includes/Payment.php @@ -0,0 +1,67 @@ + $gw) { + $list[] = [ + 'code' => $code, + 'name' => $gw->getName(), + 'enabled' => true, + ]; + } + return $list; + } + 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()); + } + } + 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..7cbe563 --- /dev/null +++ b/includes/PaymentAlipay.php @@ -0,0 +1,106 @@ +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 = $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..d461e03 --- /dev/null +++ b/includes/PaymentWechat.php @@ -0,0 +1,112 @@ +mchId = getSetting('pay_wechat_mch_id', ''); + $this->apiKey = getSetting('pay_wechat_api_key', ''); + $this->appId = getSetting('pay_wechat_appid', ''); + } + public function getName() + { + return '微信支付'; + } + public function createOrder(array $order) + { + $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) + { + 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'] ?? ''; + return !empty($params['resource']['ciphertext']); + } + public function getTradeNo(array $params) + { + $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' => '成功']); + } + 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"; + $signature = hash_hmac('SHA256', $signStr, $this->apiKey); + 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; + } + private function decryptResource($resource) + { + $ciphertext = base64_decode($resource['ciphertext']); + $nonce = $resource['nonce']; + $associatedData = $resource['associated_data']; + $key = hash('sha256', $this->apiKey, true); + 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..bace412 --- /dev/null +++ b/includes/_install_redirect.php @@ -0,0 +1,23 @@ + 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..144d54b --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,97 @@ + + +
    + + +
    + + + + diff --git a/includes/functions.php b/includes/functions.php new file mode 100644 index 0000000..8af23e2 --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,468 @@ +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; + } +} +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 . ': +} +function avatarText($name) +{ + $name = trim((string) $name); + if ($name === '') { + return '?'; + } + return mb_strtoupper(mb_substr($name, 0, 1, 'UTF-8'), 'UTF-8'); +} +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 = '
    验证邮箱

    ' + . '

    '; +} +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 校验失败'); + } + } +} +function verifyCsrfToken($token) +{ + return !empty($token) && !empty($_SESSION['csrf']) && hash_equals((string)$_SESSION['csrf'], (string)$token); +} +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; +} +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]); +} +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; +} +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] ?? '普通'; +} +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] ?? '普通'; +} +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(); +} +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]; +} +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; + } +} +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()]; + } +} +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)); +} +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..f6462b8 --- /dev/null +++ b/includes/header.php @@ -0,0 +1,112 @@ + + + + + + + <?= h($pageTitle) ?> - <?= h(getSiteName()) ?> +' . "\n "; +} +?> + + + + + +

    + +
    + + +
    + +
    + + 你的邮箱尚未验证,点击重新发送验证邮件 +
    + +
    diff --git a/includes/init.php b/includes/init.php new file mode 100644 index 0000000..0f16ff6 --- /dev/null +++ b/includes/init.php @@ -0,0 +1,18 @@ +host = (string) $host; + $this->port = (int) $port; + $this->enc = (string) $enc; + $this->user = (string) $user; + $this->pass = (string) $pass; + } + 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 . ': + $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx + ); + if (!$sock) { + $this->lastError = "无法连接 SMTP 服务器({$this->host}:{$port}):$errstr ($errno)"; + return false; + } + stream_set_timeout($sock, $timeout); + if ($this->talk($sock, null, [220]) === false) { + fclose($sock); + return false; + } + $hostHeader = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost'; + if ($this->talk($sock, 'EHLO ' . $hostHeader, [250]) === false) { + fclose($sock); + return false; + } + 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; + } + } + 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; + } + 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; + 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..866d6f2 --- /dev/null +++ b/index.php @@ -0,0 +1,154 @@ +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..86c388b --- /dev/null +++ b/install.php @@ -0,0 +1,218 @@ + 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); + $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); + } + $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]); + $cfg = "getMessage(); + } + } + $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..be7c12b --- /dev/null +++ b/lang.php @@ -0,0 +1,19 @@ +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'] ?? '')); + $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'); + } + 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..7e7a48f --- /dev/null +++ b/logout.php @@ -0,0 +1,6 @@ +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..1d13622 --- /dev/null +++ b/mytickets.php @@ -0,0 +1,168 @@ + 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'; +?> + + + + +

    + + +
    +

    +
    +
    + + + + + +
    + +

    + +

    +
    +
    +

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

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

    +
    + +
    + +

    + +
    + + + + + + + + + + + + + + + +
    +
    +
    + +
    + + diff --git a/order_detail.php b/order_detail.php new file mode 100644 index 0000000..4b326b7 --- /dev/null +++ b/order_detail.php @@ -0,0 +1,109 @@ +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..928cd2a --- /dev/null +++ b/pay_notify.php @@ -0,0 +1,54 @@ + $gateway, 'raw_input' => file_get_contents('php: +$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]); + } 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..938acac --- /dev/null +++ b/points_debug.php @@ -0,0 +1,35 @@ +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"; + } + $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..4c4b2cc --- /dev/null +++ b/product.php @@ -0,0 +1,133 @@ +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..0df53e9 --- /dev/null +++ b/settings.php @@ -0,0 +1,141 @@ +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..6dfca69 --- /dev/null +++ b/sign.php @@ -0,0 +1,70 @@ +beginTransaction(); + $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..bb184a3 --- /dev/null +++ b/ticket.php @@ -0,0 +1,129 @@ +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(); + $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') + . ': + $url = rtrim($base, '/') . '/admin/tickets.php?view=' . $tid; + $typeLabel = ticketTypeLabel($type); + $orderLine = $type === 'renewal' + ? '

    类型:续期申请(关联订单 + : '

    类型:strong>普通工单

    '; + $body = '
    ' + . '

    +

    +

    +

    +
    + + diff --git a/upgrade-avatar-pay.php b/upgrade-avatar-pay.php new file mode 100644 index 0000000..9fc4616 --- /dev/null +++ b/upgrade-avatar-pay.php @@ -0,0 +1,70 @@ +升级:头像+支付'; +echo ''; +echo '

    🔧 升级脚本:头像上传 + 支付系统

    '; +$pdo = db(); +$prefix = tn(''); +$errors = 0; +try { + $cols = $pdo->query("SHOW COLUMNS FROM `{$prefix}users` LIKE 'avatar'")->fetchAll(); + if (empty($cols)) { + $pdo->exec("ALTER TABLE `{$prefix}users` ADD COLUMN `avatar` VARCHAR(255) DEFAULT '' COMMENT '头像文件路径' AFTER `role`"); + echo '

    ✅ users 表已添加 avatar 字段

    '; + } else { + echo '

    fetchAll(); + if (empty($tables)) { + $pdo->exec("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', + `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `trade_no` VARCHAR(64) DEFAULT '', + `status` ENUM('pending','paid','failed','refunded') NOT NULL DEFAULT 'pending', + `raw_response` TEXT DEFAULT NULL, + `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"); + echo '

    ✅ 已创建 pay_logs

    '; + } else { + echo '

    execute([$k]); + if (!$exists->fetch()) { + $pdo->prepare("INSERT INTO `{$prefix}settings` (k,v) VALUES (?,?)")->execute([$k, $v]); + } + } + echo '

    ✅ 支付配置初始值已检查/写入

    '; + echo '

    ✅ 升级完成!

    '; + echo '

    本文件可安全删除。如需使用支付功能:

    '; + echo '
      '; + echo '
    1. 在后台「设置」中配置支付宝 / 微信支付的参数
    2. '; + echo '
    3. 将支付回调 URL 设置为:' . h($_SERVER['HTTP_HOST'] . '/pay_notify.php?gateway=alipay (支付宝)或 ?gateway=wechat(微信)
    4. '; + echo '
    5. 用户可在「用户设置」中上传头像
    6. '; + echo '
    '; + echo '

    建议立即删除此文件: upgrade-avatar-pay.php

    '; +} catch (Exception $e) { + echo '

    ❌ 升级失败: ' . h($e->getMessage()) . '

    '; + $errors++; +} +echo ''; diff --git a/upgrade-expiry.php b/upgrade-expiry.php new file mode 100644 index 0000000..7a5507f --- /dev/null +++ b/upgrade-expiry.php @@ -0,0 +1,23 @@ +query("SHOW COLUMNS FROM `$table` LIKE " . $pdo->quote($col)); + return $r->rowCount() > 0; +} +$pdo = db(); +$table = tn('orders'); +$added = []; +if (!colExists($pdo, $table, 'expire_notify_sent')) { + $pdo->exec("ALTER TABLE `$table` ADD COLUMN `expire_notify_sent` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '到期前提醒邮件是否已发送 0未发 1已发'"); + $added[] = 'expire_notify_sent'; +} +echo "升级完成。\n"; +echo "新增字段:" . (empty($added) ? '(均已存在,无需改动)' : implode('、', $added)) . "\n"; +echo "如需启用到期前邮件提醒,请到后台「设置」配置 SMTP,并每日运行 cron_expire_notify.php。\n"; diff --git a/upgrade-staff.php b/upgrade-staff.php new file mode 100644 index 0000000..9e00aeb --- /dev/null +++ b/upgrade-staff.php @@ -0,0 +1,96 @@ + $msg, 'ok' => $ok]; } +function colExists($pdo, $table, $col) +{ + $stmt = $pdo->prepare( + 'SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?' + ); + $stmt->execute([DB_NAME, $table, $col]); + return (bool) $stmt->fetchColumn(); +} +function tableExists($pdo, $table) +{ + $stmt = $pdo->prepare( + 'SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?' + ); + $stmt->execute([DB_NAME, $table]); + return (bool) $stmt->fetchColumn(); +} +$tUsers = $prefix . 'users'; +if (!colExists($pdo, $tUsers, 'role')) { + $pdo->exec( + "ALTER TABLE `{$tUsers}` ADD COLUMN `role` VARCHAR(10) NOT NULL DEFAULT 'user' " + . "COMMENT 'admin管理员 cs客服 user普通用户' AFTER `is_admin`" + ); + upLog($steps, "已为 users 表新增 role 字段(默认 user)"); +} else { + upLog($steps, "users.role 字段已存在,跳过"); +} +$affected = $pdo->exec( + "UPDATE `{$tUsers}` SET role='admin' WHERE is_admin=1 AND (role IS NULL OR role='' OR role='user')" +); +upLog($steps, "已将 {$affected} 个 is_admin=1 的账号的 role 置为 admin(客服/普通用户不受影响)"); +$tTickets = $prefix . 'tickets'; +if (!colExists($pdo, $tTickets, 'assignee_id')) { + $pdo->exec( + "ALTER TABLE `{$tTickets}` ADD COLUMN `assignee_id` INT UNSIGNED DEFAULT NULL " + . "COMMENT '负责人(客服/管理员)用户ID,NULL=未分配'" + ); + $pdo->exec("ALTER TABLE `{$tTickets}` ADD KEY `idx_assignee` (`assignee_id`)"); + upLog($steps, "已为 tickets 表新增 assignee_id 字段及索引"); +} else { + upLog($steps, "tickets.assignee_id 字段已存在,跳过"); +} +$tReplies = $prefix . 'ticket_replies'; +if (!tableExists($pdo, $tReplies)) { + $pdo->exec( + "CREATE TABLE `{$tReplies}` ( + `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)', + `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" + ); + upLog($steps, "已创建 ticket_replies 表"); +} else { + upLog($steps, "ticket_replies 表已存在,跳过"); +} +if (PHP_SAPI === 'cli') { + foreach ($steps as $s) { + echo ($s['ok'] ? '[OK] ' : '[!!] ') . $s['msg'] . "\n"; + } + echo "升级完成。\n"; +} else { + header('Content-Type: text/html; charset=utf-8'); + echo '' + . '' + . '数据库升级'; + echo '

    数据库结构升级

    '; + foreach ($steps as $s) { + echo '
    ' . htmlspecialchars($s['msg'], ENT_QUOTES) . '
    '; + } + echo '
    升级完成,建议立即删除本文件(upgrade-staff.php),避免被他人重复访问。' + . '若结构已是最新,重复运行同样安全。
    '; + echo ''; +} diff --git a/verify.php b/verify.php new file mode 100644 index 0000000..0e7b606 --- /dev/null +++ b/verify.php @@ -0,0 +1,69 @@ +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'; +?> +
    +
    +
    + +
    +

    +

    + +
    +
    +