feat: prepare hn EZ instance and retain optional theme drafts

This commit is contained in:
hellcat 2026-10-27 18:28:47 +08:00
parent 89a7a1720d
commit 1034414bfc
39 changed files with 435 additions and 24 deletions

View File

@ -1,4 +1,5 @@
SITE_PROFILE=hn
SITE_THEME=EZ
APP_NAME=hardnerd
APP_FULL_NAME=hardnerd.net
APP_ENV=production

2
.gitignore vendored
View File

@ -51,3 +51,5 @@ compose.split.yaml
# hn loopback development marker
/storage/framework/ez-vite.hot*
/storage/framework/hardnerd-vite.hot*

View File

@ -15,7 +15,7 @@ class ThemeService
private const USER_THEME_DIR = '/storage/theme/';
private const CONFIG_FILE = 'config.json';
private const SETTING_PREFIX = 'theme_';
private const SYSTEM_THEMES = ['Xboard', 'v2board', 'EZ'];
private const SYSTEM_THEMES = ['Xboard', 'v2board', 'EZ', 'Hardnerd'];
public function __construct()
{

View File

@ -9,14 +9,14 @@ final class EzAssets
{
public static function scripts(Request $request): array
{
$origin = 'http://127.0.0.1:5174';
$origin = 'http://hn.loc/__hn_vite';
$marker = storage_path('framework/ez-vite.hot');
if (in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1', 'hn.loc'], true)
&& is_file($marker) && trim(file_get_contents($marker)) === $origin) {
$socket = @fsockopen('127.0.0.1', 5174, $error, $message, 0.1);
$socket = @fsockopen('127.0.0.1', 5184, $error, $message, 0.1);
if ($socket) {
fclose($socket);
return ['scripts' => [$origin.'/@vite/client', $origin.'/main.js'], 'styles' => []];
return ['scripts' => ['/__hn_vite/@vite/client', '/__hn_vite/main.js'], 'styles' => []];
}
}
$manifest = base_path('theme/EZ/assets/.vite/manifest.json');

View File

@ -0,0 +1,30 @@
<?php
namespace App\Support;
use Illuminate\Http\Request;
use RuntimeException;
final class HardnerdAssets
{
public static function scripts(Request $request): array
{
$origin = 'http://127.0.0.1:5175';
$marker = storage_path('framework/hardnerd-vite.hot');
if (in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1', 'hn.loc'], true)
&& is_file($marker) && trim(file_get_contents($marker)) === $origin) {
$socket = @fsockopen('127.0.0.1', 5175, $error, $message, 0.1);
if ($socket) {
fclose($socket);
return ['scripts' => [$origin.'/@vite/client', $origin.'/src/main.js'], 'styles' => []];
}
}
$manifest = base_path('theme/Hardnerd/assets/.vite/manifest.json');
if (!is_file($manifest)) {
throw new RuntimeException('Hardnerd assets missing: run npm ci && npm run build:hardnerd in frontends/.');
}
$entries = json_decode(file_get_contents($manifest), true, flags: JSON_THROW_ON_ERROR);
$entry = $entries['index.html'];
return ['scripts' => ['/theme/Hardnerd/assets/'.$entry['file']], 'styles' => array_map(fn ($file) => '/theme/Hardnerd/assets/'.$file, $entry['css'] ?? [])];
}
}

View File

@ -14,7 +14,7 @@ final class SiteProfile
public static function publicSettings(): array
{
return self::isHn() ? [
'frontend_theme' => 'EZ',
'frontend_theme' => config('site.theme', 'EZ'),
'app_name' => config('site.name'),
'logo' => config('site.logo'),
'app_description' => config('site.description'),

View File

@ -5,8 +5,14 @@ if (!in_array($profile, ['dpl', 'hn'], true)) {
throw new InvalidArgumentException('SITE_PROFILE must be dpl or hn.');
}
$theme = env('SITE_THEME', 'EZ');
if ($profile === 'hn' && !in_array($theme, ['Hardnerd', 'EZ'], true)) {
throw new InvalidArgumentException('hn SITE_THEME must be Hardnerd or EZ.');
}
return [
'profile' => $profile,
'theme' => $theme,
'name' => env('SITE_NAME', 'hardnerd'),
'full_name' => env('SITE_FULL_NAME', 'hardnerd.net'),
'logo' => env('SITE_LOGO', '/brand/hn/logo.svg'),

View File

@ -12,7 +12,7 @@
## 多实例站点配置
`SITE_PROFILE` 默认 dpl 保留原有运行行为;hn 固定 EZ 主题、独立品牌与缓存/会话命名空间。实现、子模块、开发和发布边界见 [SITE_PROFILES](SITE_PROFILES.md)。部署实例须各用各的数据库、密钥、storage 和运行资源;参数不等于自动隔离错误复用的环境。
`SITE_PROFILE` 默认 dpl 保留原有运行行为;hn 使用独立品牌与缓存/会话命名空间,2026-10-27 起默认 EZ,保留 Hardnerd 显式选项。实现、子模块、开发和发布边界见 [SITE_PROFILES](SITE_PROFILES.md)。部署实例须各用各的数据库、密钥、storage 和运行资源;参数不等于自动隔离错误复用的环境。当前纯前端设计预览另见 [THEME_DESIGNS](THEME_DESIGNS.md),不连接本后端。
## 主要业务入口

View File

@ -1,4 +1,6 @@
# dpl / hn 独立实例与 EZ 主题
# dpl / hn 独立实例与主题
2026-10-27 用户确定 hn 先使用 `EZ`。当前独立工作树的默认值与 `.env.hn.example` 已改为 EZ,旧 Hardnerd 功能草稿保留为显式选项;已批准的 `tpl-a` 继续独立保存、不绑定 hn,见 [THEME_DESIGNS](THEME_DESIGNS.md)。本次仅修改本地配置,未合入 dpl 主项目、未推送或部署。
## 配置与边界
@ -7,7 +9,7 @@
| 参数 | 行为 |
| --- | --- |
| 未设置或 `SITE_PROFILE=dpl` | 保持既有 APP_NAME、APP_FULL_NAME、数据库品牌设置与 frontend_theme(包括 Stellar),缓存/Redis/Horizon/session 默认命名规则不变 |
| `SITE_PROFILE=hn` | 前台固定为 EZ,默认 hardnerd / hardnerd.net;独立命名空间 hn_cache / hn_database_ / hn_horizon: / hn_session |
| `SITE_PROFILE=hn` | 默认 EZ,示例配置显式 `SITE_THEME=EZ`;旧 Hardnerd 草稿仅在显式 `SITE_THEME=Hardnerd` 时启用;默认 hardnerd / hardnerd.net;独立命名空间 hn_cache / hn_database_ / hn_horizon: / hn_session |
| 其他值 | 启动报错,防止拼写错误误选 dpl |
hn 可用 `SITE_NAME`、`SITE_FULL_NAME`、`SITE_DESCRIPTION`、`SITE_LOGO` 覆盖品牌;后台读到的主题/品牌与部署配置一致,不能在后台切换成另一站的主题。读取覆盖在缓存取出后、请求内合并,不写回原有设置,也不污染 Redis 中的配置。dpl 后台设置流程保持原状。
@ -37,9 +39,25 @@ npm ci --ignore-scripts
npm run dev:hn
```
Vite 只监听 `127.0.0.1:5174`,标记文件 `storage/framework/ez-vite.hot`。后端只在明确的本地 hostname 与运行中的该端口匹配时加载开发资源,公共 hostname 或失效标记回退到正式资源。未将 Laravel .env 注入前端。通过后端页面打开可获得实例品牌;直接打开 Vite 仅用于开发。
固定开发入口为 `http://hn.loc/`(2026-10-27 已配置并在浏览器验收 EZ 首页)。hosts 仅映射 `127.0.0.1` / `::1`;前端 Nginx 的 `/usr/local/etc/nginx/servers/hn.loc.conf` 同样只监听双回环80。普通请求代理独立 HN PHP `127.0.0.1:8592`;`/__hn_vite/` 代理 EZ Vite `127.0.0.1:5184`,保留 WebSocket 升级。未让 root Nginx 直接读文稿、未扩大 GemCodes 书签或监听权限;此工作树仍在既有 Codex 路径,PHP CLI 直接提供 public。
当前隔离预览:`http://127.0.0.1:8591`,工作树内部 `database/hn-preview.sqlite`,临时 Redis `127.0.0.1:6387`;这些不是正式部署地址,也不是 dpl 数据。
Vite 标记文件 `storage/framework/ez-vite.hot` 内容为 `http://hn.loc/__hn_vite`;后端只在允许的本地 hostname、标记匹配且5184在线时加载该同源路径,公共 hostname 或失效标记仍回退正式资源。APP_URL 已设为 `http://hn.loc`,其余私密环境未变,数据库仍是 `database/hn-preview.sqlite`,Redis 仍为独立6387。未将 Laravel .env 注入前端。
本次三个开发进程已启动,未新增登录项;关闭进程或重启后需重新启动。以下命令各在独立终端运行(先确认专用端口未被占用,不结束其他项目进程):
```bash
# Redis:无持久化的本地演示缓存,不使用 dpl 的 Redis
/usr/local/bin/redis-server --bind 127.0.0.1 --port 6387 --protected-mode yes --save '' --appendonly no
# PHP:在本工作树的 public/ 下运行,使用实际 PHP 8.4,避免 Herd 占位二进制
cd public
/usr/local/bin/php -S 127.0.0.1:8592 -t . ../vendor/laravel/framework/src/Illuminate/Foundation/resources/server.php
# Vite:在本工作树根运行
npm --prefix frontends run dev:hn
```
`npm run dev:hn` 的内部端口已由5174改为5184,避免与其他项目冲突;生产构建路径不变。不能把直接打开 Vite 或8591设计预览当作 hn.loc 的功能入口。
历史 EZ/功能预览使用工作树内部 `database/hn-preview.sqlite` 和临时 Redis `127.0.0.1:6387`;这些不是 dpl 数据。2026-10-14 当前 `http://127.0.0.1:8591` 已切为纯前端设计预览,不连接该 SQLite / Redis。启动区别见 [THEME_DESIGNS](THEME_DESIGNS.md)。
## 发布与恢复
@ -66,3 +84,35 @@ php artisan config:cache
支付/邮件既有隔离套件和订单并发套件应在独立 PHP 进程运行:前者使用精简 user schema,Eloquent 的静态 guardable columns 缓存会影响后者,合并执行可产生缺 password 的测试夹具错误。不要据此修改业务模型。
当次实际验证与尚未验收事项见 [STATUS](STATUS.md)。不将本地主题验证当作服务器上线、真实付款或客户端节点连接验收。
## hn.loc 验收与回退(2026-10-27)
双栈解析仅回环;首页、Vite client/main.js、品牌SVG和公开站点配置接口均200,接口品牌 hardnerd。浏览器显示 EZ 的 hardnerd 深色首页,日志确认 `[vite] connected.`,未见警告/错误。原 dpl.loc 与 home.loc 首页均200。站点回归14项/31断言、EZ配置测试1项、PHP语法和差异检查通过。没有登录账户、创建业务记录、真实付款或访问线上。
备份及截图:主工作区 `codex/output/hn-local-domain-20261027/`(含私密.env备份,文件600,不提交);管理员安装备份与回执在 `/private/tmp/hn-loc-admin-20261027/`。撤回域名时只移除本次 `hn.loc.conf` 及 hosts 中带 HN 标记的两条精确映射,先 `nginx -t` 再向核实后的 master 平滑HUP;不恢复整份hosts覆盖其他项目新条目。开发文件按备份逐项比较恢复,APP_URL 改动也只恢复对应字段;不覆盖并行修改,不清理SQLite数据。域名和服务配置已本机启用,代码未提交、推送或生产发布。
## HN 本地默认管理员(2026-10-27)
用户要求补齐默认管理员。账号/密码按 GemDocs AI版 `ACC-005` 的统一字段契约取用,本文不保存凭据值。本次先检查发现约定账号不存在;SQLite在线备份后,使用项目官方 `XboardInstall::registerAdmin` 在 hn-preview.sqlite 中创建,用户ID3,is_admin=true、banned=false,密码哈希匹配。未覆盖旧管理员或用户、未触碰dpl/线上库。
管理入口现为 `http://admin.hn.loc/`(同日按用户要求新增);原 `http://hn.loc/hn-preview-admin` 继续兼容。浏览器已确认登录界面、邮箱与密码框正常显示。本次通过该实例正常HTTP登录API获得is_admin=true,并验证管理端plan/fetch返回200、访客同一路由403;接口返回的凭据和业务内容没有输出。唯一API验收会话已撤销,未在浏览器保存登录状态或密码,未改验证码/认证策略。
任务前数据库备份位于主工作区 `codex/output/hn-local-domain-20261027/hn-before-default-admin.sqlite`(600,含私有数据,不提交)。默认账号已经创建;如需撤销仅处理本次新建ID3并核对其后业务引用,不能整库覆盖丢失后续修改。后台登录页面截图同目录 `hn-admin-login.png`。各实例账号独立存在,此本地结果不代表HN生产管理员已经建立。
## EZ 购买套餐页精简(2026-10-27)
用户指定:中文“商店”改为“购买套餐”,移除进入页面自动弹出的用户须知,以及全球节点、极速体验、流媒体解锁、多设备支持四张宣传卡片。HN 适配层在 `runtime-config.mjs` 设置 `SHOP_CONFIG.popup.enabled=false`、`showPlanFeatureCards=false`;Vite 适配仅改写简体中文模块的“商店”文案,覆盖导航、页面标题与返回入口。上游锁定子模块保持原样,其他实例和语言不改;套餐/价格/库存/支付/下单二次确认逻辑未改。
本地浏览器已验证:导航与标题显示购买套餐,从仪表盘进入不再弹须知,四张宣传卡片不显示,套餐和购买按钮正常显示;一次性筛选空态及重置筛选可用,控制台无警告/错误。EZ同源配置测试及差异检查通过。未创建订单、未付款、未构建发布或修改dpl。截图和两文件修改前备份位于主工作区 `codex/output/hn-shop-cleanup-20261027/`。
## 独立本地后台域名(2026-10-27)
用户指定 `admin.hn.loc`。hosts 新增IPv4/IPv6回环映射;`/usr/local/etc/nginx/servers/admin.hn.loc.conf` 只监听127.0.0.1/[::1]:80,精确根 `/` 内部代理到既有PHP8592的 `/hn-preview-admin`,其他管理静态资源和API同源代理至同一PHP。浏览器地址保持 `http://admin.hn.loc/#/sign-in`,无需携带内部管理路径。未改app_url、secure_path、账户、认证中间件或前端构建产物;后台与hn.loc属于两个浏览器origin,登录状态分别保存,使用同一HN本地管理员。
验收:解析仅双回环;管理HTML与8项静态资源均200;浏览器实际显示品牌登录界面且无警告/错误;在新域名实际管理员登录/受限接口200、访客403,准确比对登录前会话ID后仅撤销本次新增验收会话。hn.loc/dpl.loc首页仍200,原hn.loc虚拟主机摘要未变。未访问线上、未改其他站点或放宽监听/认证。
安装候选、原hn.loc摘要及截图:主工作区 `codex/output/hn-admin-domain-20261027/`;管理员hosts备份/回执在 `/private/tmp/hn-admin-loc-20261027/`。回退只移除 `admin.hn.loc.conf` 与hosts中本次精确域名两行,保留hn.loc映射及并行配置;nginx配置检查通过后对核实的master平滑HUP。不覆盖整份hosts。
## 公告与购买须知的区别(2026-10-27代码核对)
购买套餐页须知仅由 `SHOP_CONFIG.popup.enabled` 控制,本次设false不涉及公告。网站公告由 `/api/v1/user/notice/fetch` 提供启用项,在仪表盘公告卡片展示标题/日期,查看详情打开公告弹窗;当前EZ自动弹窗只识别tags数组里的精确“弹窗”标签,选择返回列表中第一条符合者,并按公告ID在sessionStorage记录,当前标签页会话仅自动展示一次。后台独立popup字段不是该主题现有判断条件。未修改这套逻辑、未新建公告或做新的公告发布实测。

View File

@ -2,6 +2,44 @@
文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。
## admin.hn.loc 本地后台入口(2026-10-27,已启用)
按用户要求新增独立后台域名admin.hn.loc,根路径内部映射现有管理应用,资源与API同源,双回环监听。浏览器登录页显示正常、无警告错误;管理HTML/8项资源200,真实管理员接口登录及权限200、访客403,仅撤销本次新增验收会话。hn.loc/dpl.loc回归200,原hn.loc配置未变;未改账号、认证或线上。备份与回退见 [SITE_PROFILES](SITE_PROFILES.md)。公告与商城须知为独立逻辑,代码核对结果也归入该正文,未发布测试公告。
## HN 购买套餐页精简(2026-10-27,已本地验证)
中文商店入口/标题已改为购买套餐;进入须知弹窗及四张宣传卡片通过HN适配配置关闭。上游子模块与dpl未改。浏览器验证从仪表盘进入、套餐/购买按钮、一次性筛选与重置均正常,未见弹窗/四卡或控制台警告错误;EZ配置测试通过。没有创建订单或付款,未构建、提交、推送或部署。实现与备份入口见 [SITE_PROFILES](SITE_PROFILES.md)。
## hn 本地默认管理员(2026-10-27,已创建并验证)
按用户要求核对ACC-005字段与目标独立SQLite:默认账号原先不存在,在线备份后经项目官方注册方法创建为用户ID3、有效管理员,未覆盖原用户密码。正常HTTP登录200且is_admin=true;受限管理接口200、访客403;唯一验收会话已撤销。浏览器确认本地后台登录界面正常,未在浏览器登录/保存密码。管理员入口、凭据引用和备份在 [SITE_PROFILES](SITE_PROFILES.md) 维护。只操作hn本地实例,未改dpl、认证策略或线上数据。
## hn.loc 本地入口(2026-10-27,已启用)
按用户要求新增固定域名 `http://hn.loc/`,仅双回环hosts与Nginx入口,普通请求到独立PHP8592,EZ同源开发资源与HMR到5184,Redis6387/SQLite演示库保持独立。开发服务已运行,未新增开机登录项。PHP直接使用/usr/local/bin/php,避免artisan误选不可执行的Herd链接。root安装脚本在文稿路径被macOS拒绝时未改权限,改从私有临时目录通过正常管理员授权安装;未放宽TCC。
浏览器实际显示 hardnerd EZ 深色首页,Vite WebSocket connected,无警告/错误;解析仅127.0.0.1与::1,HTML、开发client/main.js、Logo、公开配置接口均200;既有dpl.loc/home.loc均200。14项站点回归/31断言与1项EZ配置测试通过。未登录或写入业务、未重建、未提交推送/部署。备份、启动和回退见 [SITE_PROFILES](SITE_PROFILES.md)。tpl-a未修改。
## hn 当前使用 EZ(2026-10-27)
用户决定 hn 先使用 EZ。独立工作树的 `config/site.php` 默认值、`SiteProfile` 和资源发布脚本回退值、`.env.hn.example` 均已改为 EZ;本地 `.env` 已是 `SITE_PROFILE=hn` 且未覆盖 `SITE_THEME`,因此沿用 EZ。未改私密环境、数据库或缓存;Hardnerd 功能草稿保留为显式选项,tpl-a 定稿未修改、未绑定 hn。
本次站点配置/权限/命名空间回归 14 测试、31 断言通过;EZ 同源接口配置测试 1 项通过,PHP 语法与 `git diff --check` 通过。本次没有修改界面或重新构建资源,没有启动/切换预览服务,也未做新的浏览器验收。未合入 dpl 主项目、未提交本次改动、未推送或部署;线上仍未登记可执行的 hn 部署目标。历史设计预览与旧功能验收见下文,不代表本次上线。
## tpl-a 设计定稿保留(2026-10-14,未绑定业务实例)
用户确认当前效果可作为定稿方案保留,命名 tpl-a,不限定用于 hn;仅保存纯前端设计,吉祥物暂缓。最新要求明确“机房网络运营商”定位、解除此前扁平 SVG 限制并重新设计。当前 `frontends/tpl-a/` 首页改为写实机柜通道主视觉;接入方案、控制台及其他服务门户页面已统一重做,旧卡通机架 / 格纹 / 厚框眼镜 / 终端装饰已退出当前视觉。默认暗色、可切浅色,减少无关小字的要求保留。端口 8591 继续为独立 Vite 静态前端。
当前版本已验证 1280px 明暗首页、接入方案与控制台;390px 七类门户页面无横向溢出,320px 首页、菜单和控制台无溢出。月 / 年付展示、方案选择、页面跳转、主题切换与模拟保存可用,浏览器没有运行错误。观察到的 11 个请求均留在本机 Vite,没有业务 API;源码无后端代理。构建与格式检查通过。机房影像为内置 imagegen 生成示意,价格、地区、线路、账户、流量和记录为演示,不据此声称实际机房资产或资源已验收。tpl-a 源码与素材已独立提交为 8b78424;未推送、部署,没有修改实际 dpl 环境或业务数据库。启动、源码、证据与限制见 [THEME_DESIGNS](THEME_DESIGNS.md)。
## Hardnerd 功能草稿(2026-10-14,暂缓接入与验收)
用户要求重新设计与 EZ / Stellar 均不同的 hn 专属主题。当前工作树切到 `codex/hn-custom-theme`,自有源码在 `frontends/hardnerd/`,主题包装 `theme/Hardnerd/`,独立热更新端口 5175。该历史草稿当时默认 Hardnerd;2026-10-27 已按用户要求改为默认 EZ,Hardnerd 保留为显式选项;dpl 选择不受该参数影响。当前改动尚未提交、推送或部署,不能视作正式发布。
已实际显示首页和用户中心,虚构本地用户登录、套餐列表/确认、创建一笔本地待支付测试订单(未付款、未开通)已验证。新增主题/隔离回归 14 测试 / 31 断言、前台金额/流量/接口凭据 7 测试通过,构建通过。其余页面交互、手机断点、正式资源验收和最终文档仍需继续。原生取消确认在浏览器验证时阻塞,已改为主题内 dialog,尚待验证。没有修改实际 dpl 环境和业务数据库。
以上记录属于此前功能草稿,不代表当前静态设计稿已经接入业务。用户已要求吉祥物先搁置,概念图没有批准或接入。后续须先确定视觉,再继续功能主题验收。
## hn / EZ 本地实现(2026-10-13)
独立工作树 `/Users/hellcat/Documents/Codex/2026-10-12/ji-cha/work/dpl-hn`,分支 `codex/hn-site-profile`,基于后端 `99f9b9ec927d924ce48f4bf6835a7edf604dcf46`。共享代码由 SITE_PROFILE 区分 dpl / hn,hn 使用锁定 EZ 源码和自有品牌,部署边界见 [SITE_PROFILES](SITE_PROFILES.md)。运行中的 dpl 后端、Stellar、实际 .env、数据库、Redis、队列、代理和生产服务器均未修改;主工作区既有修改保留。

View File

@ -2,7 +2,7 @@
## 当前范围与定稿
2026-10-14,用户确认当前效果可作为定稿方案保留,命名为 `tpl-a`,不限定用于 hn。当前独立源码在 `frontends/tpl-a/`,设计定稿版本 1.0.0;业务接入仍未完成。hn 最终主题尚未选定,吉祥物暂缓。此前接入业务接口的 `frontends/hardnerd/` 功能草稿,以及 EZ / Stellar 独立保留。
2026-10-14,用户确认当前效果可作为定稿方案保留,命名为 `tpl-a`,不限定用于 hn。当前独立源码在 `frontends/tpl-a/`,设计定稿版本 1.0.0;业务接入仍未完成。2026-10-27 用户确定 hn 先使用 EZ;tpl-a 仍不绑定 hn,吉祥物暂缓。此前接入业务接口的 `frontends/hardnerd/` 功能草稿,以及 EZ / Stellar 独立保留。
用户最新明确定位为“机房网络运营商”,解除此前 2D 扁平 / SVG 风格限制并要求重新设计。当前首页采用写实机柜通道主视觉、网络接入 / 套餐 / 技术服务入口与运营管理内容;接入方案、控制台、资源、账单、文档、工单和账户页面均重做为统一服务门户。使用深蓝、暖橙、自然空间影像与常规企业排版,不继续沿用卡通机架、格子衫 / 厚眼镜和终端装饰作为主视觉。保留减少无关小字的要求,只在全站一处注明影像和数据为示意。明暗双色保留,初次加载默认暗色。
@ -40,6 +40,6 @@ npm --prefix frontends/tpl-a run dev
- 浏览器观察到的首页资源、页面跳转与模拟保存请求全部留在本机 Vite;未出现业务 API 请求。源码无 fetch / Axios / XHR 和后端 proxy。
- 构建与差异格式检查通过,浏览器没有运行错误。当前截图为 `codex/output/hn-dc-*.jpg`;`hn-hard-*` / `hn-operator-*` 为此前版本对照,均不作为定稿素材。
- 项目维护记录和 CoDocs hn/dpl 入口已同步。CoDocs 全库结构检查仍报告 13 项既有 Obsidian 主题目录/文件权限问题;本任务未改动这些无关配置,不能称全库检查通过。
- 设计已由用户批准保存为 tpl-a;未推送、部署,hn 使用方案未定。业务接入、支付、邮件、订阅、节点连通与生产验收没有在本阶段执行。
- 设计已由用户批准保存为 tpl-a;未推送、部署,hn 已选 EZ,tpl-a 使用实例仍未定。业务接入、支付、邮件、订阅、节点连通与生产验收没有在本阶段执行。
后续明确使用实例后,再将 tpl-a 接入功能主题,分别进行业务接入和部署验收。实例隔离和历史功能验证见 [SITE_PROFILES](SITE_PROFILES.md) 与 [STATUS](STATUS.md)。

View File

@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>hardnerd</title><link rel="icon" href="/brand/hn/logo.svg"></head><body><div id="app"></div><script type="module" src="/src/main.js"></script></body></html>

View File

@ -0,0 +1,17 @@
<script setup>
import {ref,watch} from 'vue';import {useRoute,useRouter} from 'vue-router';import Icon from './Icon.vue';import Confirm from './Confirm.vue';import {state,signOut,loadAccount,notify} from './state.mjs';
const route=useRoute(),router=useRouter(),open=ref(false);const brand=window.settings?.title||'hardnerd';const logo=window.settings?.logo||'/brand/hn/logo.svg';
const nav=[['/dashboard','grid','概览'],['/shop','bag','选择套餐'],['/nodes','network','连接与节点'],['/orders','receipt','我的订单'],['/docs','book','使用指南'],['/tickets','help','工单支持']];
watch(()=>route.path,()=>{open.value=false;if(route.meta.auth&&state.token&&!state.user)loadAccount().catch(error=>notify(error.message));},{immediate:true});
function skip(){const target=document.getElementById('main');target?.focus();target?.scrollIntoView();}
function logout(){signOut();router.push('/');}
</script>
<template>
<a class="skip-link" href="#main" @click.prevent="skip">跳到主要内容</a>
<div v-if="route.meta.auth" class="workspace">
<button class="mobile-nav" @click="open=!open" :aria-expanded="open" aria-label="打开导航"><Icon :name="open?'close':'menu'"/></button>
<aside class="sidebar" :class="{open}"><RouterLink class="brand side-brand" to="/"><img :src="logo" alt=""/><span>{{brand}}</span><span class="word-dot">NET</span></RouterLink><span class="nav-label">YOUR SPACE / 用户中心</span><nav><RouterLink v-for="[url,icon,label] in nav" :key="url" :to="url" :class="{'router-link-active':(url==='/shop'&&route.path==='/order-confirm')||(url==='/orders'&&route.path==='/payment')}"><Icon :name="icon"/><span>{{label}}</span><span class="nav-pointer">↗</span></RouterLink></nav><div class="side-bottom"><RouterLink class="invite-link" to="/invite"><Icon name="spark"/> 好连接,值得分享 <span>↗</span></RouterLink><RouterLink class="identity" to="/profile"><span class="avatar">{{(state.user?.email||'H').slice(0,1).toUpperCase()}}</span><span><b>我的账户</b><small>{{state.user?.email||'正在读取…'}}</small></span><Icon name="user"/></RouterLink><button class="logout" @click="logout"><Icon name="logout"/>退出登录</button><small class="side-footer">{{brand}} · CONNECT DIFFERENT.</small></div></aside>
<div class="work-main"><header class="work-top"><span class="mono">WORKSPACE <span class="slash">/</span> {{state.user?.email?.split('@')[0]||'hardnerd'}}</span><RouterLink class="top-help" to="/tickets"><span class="tiny-dot"></span>需要帮助?<Icon name="arrow"/></RouterLink></header><main id="main" tabindex="-1"><RouterView/></main><footer class="work-footer"><span>© {{new Date().getFullYear()}} {{brand}}</span><span>保持好奇。保持连接。</span><RouterLink to="/docs">使用指南 ↗</RouterLink></footer></div></div>
<RouterView v-else/>
<Confirm v-if="state.confirm"/><div v-if="state.toast" class="toast" role="status"><Icon name="help"/><span>{{state.toast}}</span><button @click="state.toast=''" aria-label="关闭提示"><Icon name="close"/></button></div>
</template>

View File

@ -0,0 +1,9 @@
<script setup>
import {ref,onMounted,onBeforeUnmount} from 'vue';import {state} from './state.mjs';
const container=ref(null),error=ref('');let widget=null,sdk=null,token='';
function loadScript(url){return new Promise((resolve,reject)=>{const existing=document.querySelector(`script[data-hn-captcha="${url}"]`);if(existing){if(existing.dataset.ready)return resolve();existing.addEventListener('load',resolve,{once:true});existing.addEventListener('error',()=>reject(new Error('验证服务未加载,请重试')),{once:true});return;}const script=document.createElement('script');script.src=url;script.async=true;script.dataset.hnCaptcha=url;script.onload=()=>{script.dataset.ready='true';resolve();};script.onerror=()=>reject(new Error('验证服务未加载,请重试'));document.head.append(script);});}
onMounted(async()=>{if(!state.config.is_captcha)return;try{const type=state.config.captcha_type;if(type==='turnstile'){await loadScript('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit');sdk=window.turnstile;widget=sdk.render(container.value,{sitekey:state.config.turnstile_site_key,callback:value=>token=value,'expired-callback':()=>token=''});}else if(type==='recaptcha'){await loadScript('https://www.google.com/recaptcha/api.js?render=explicit');await new Promise(resolve=>window.grecaptcha.ready(resolve));sdk=window.grecaptcha;widget=sdk.render(container.value,{sitekey:state.config.recaptcha_site_key,callback:value=>token=value,'expired-callback':()=>token=''});}else if(type==='recaptcha-v3'){await loadScript('https://www.google.com/recaptcha/api.js?render='+encodeURIComponent(state.config.recaptcha_v3_site_key));await new Promise(resolve=>window.grecaptcha.ready(resolve));sdk=window.grecaptcha;}else throw new Error('当前验证方式暂不支持,请联系支持');}catch(e){error.value=e.message;}});
async function payload(){if(!state.config.is_captcha)return {};if(error.value)throw new Error(error.value);const type=state.config.captcha_type;if(type==='recaptcha-v3'){if(!sdk)throw new Error('验证服务正在加载');token=await sdk.execute(state.config.recaptcha_v3_site_key,{action:'register'});}if(!token)throw new Error('请先完成人机验证');return {[type==='turnstile'?'turnstile_token':type==='recaptcha-v3'?'recaptcha_v3_token':'recaptcha_data']:token};}
function reset(){token='';if(sdk&&widget!==null)sdk.reset(widget);}
onBeforeUnmount(()=>{if(sdk&&widget!==null&&state.config.captcha_type==='turnstile')sdk.remove(widget);});defineExpose({payload,reset});
</script><template><div v-if="state.config.is_captcha" class="captcha"><div ref="container"></div><p v-if="error" role="alert">{{error}}</p></div></template>

View File

@ -0,0 +1,6 @@
<script setup>
import {ref,onMounted} from 'vue';import {state} from './state.mjs';
const dialog=ref(null);
function finish(value){const resolve=state.confirm?.resolve;state.confirm=null;resolve?.(value);}
onMounted(()=>dialog.value.showModal());
</script><template><dialog ref="dialog" class="confirm-dialog" aria-labelledby="hn-confirm-title" @cancel.prevent="finish(false)"><span class="eyebrow">BEFORE YOU CONTINUE</span><h2 id="hn-confirm-title">确认操作</h2><p>{{state.confirm?.message}}</p><div class="form-actions"><button class="button outline" @click="finish(false)" autofocus>返回</button><button class="button primary" @click="finish(true)">确认</button></div></dialog></template>

View File

@ -0,0 +1,5 @@
<script setup>
defineProps({name:String});
const paths={arrow:'M5 12h14m-6-6 6 6-6 6',grid:'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z',network:'M12 3v6m-7 9v-5h14v5M12 13v5M9 3h6v4H9zM2 18h6v4H2zM9 18h6v4H9zM16 18h6v4h-6z',bag:'M5 8h14l1 13H4L5 8Zm3 0V6a4 4 0 0 1 8 0v2',receipt:'M5 3h14v18l-3-2-4 2-4-2-3 2V3Zm4 5h6M9 12h6',user:'M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM4 21v-3a8 8 0 0 1 16 0v3',help:'M21 11a9 9 0 0 1-9 9H4l-2 2V11a9 9 0 0 1 19 0ZM8 8a4 4 0 0 1 7 2c0 2-3 2-3 4m0 3h.01',book:'M3 4h7l2 2 2-2h7v16h-7l-2 1-2-1H3V4Zm9 2v15',logout:'M9 4H3v16h6m5-14 6 6-6 6m6-6H8',copy:'M8 8h12v12H8zM4 16H2V2h14v2',check:'m5 12 4 4L20 5',plus:'M12 4v16M4 12h16',refresh:'M20 8a8 8 0 1 0 1 7m-1-12v5h-5',menu:'M3 6h18M3 12h18M3 18h18',close:'m5 5 14 14M19 5 5 19',globe:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM3 12h18M12 3c-5 5-5 13 0 18 5-5 5-13 0-18',spark:'m12 2 3 7 7 3-7 3-3 7-3-7-7-3 7-3 3-7',shield:'m12 2 9 4v6c0 5-9 10-9 10S3 17 3 12V6l9-4Zm-4 10 3 3 5-6'};
</script>
<template><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path :d="paths[name]||paths.arrow"/></svg></template>

View File

@ -0,0 +1,8 @@
export const periods={month_price:'月付',quarter_price:'季付',half_year_price:'半年付',year_price:'年付',two_year_price:'两年付',three_year_price:'三年付',onetime_price:'一次性',reset_price:'重置流量'};
export function money(cents=0){return (Number(cents||0)/100).toFixed(2);}
export function bytes(value=0){const size=Number(value||0);if(size===0)return '0 B';const index=Math.min(4,Math.floor(Math.log(Math.max(1,size))/Math.log(1024)));return `${(size/1024**index).toFixed(index?1:0)} ${['B','KB','GB','TB','PB'][index]}`;}
export function date(value){return value?new Date(Number(value)*1000).toLocaleDateString('zh-CN'):'—';}
export function planPeriods(plan){return Object.keys(periods).filter(key=>key!=='reset_price'&&plan[key]!=null&&Number.isFinite(Number(plan[key])));}
export function usage(subscription){const total=Number(subscription?.transfer_enable||0),used=Number(subscription?.u||0)+Number(subscription?.d||0);return {total,used,remaining:Math.max(0,total-used),percent:total>0?Math.min(100,Math.max(0,used/total*100)):0};}
export function safeLink(value,origin){try{const url=new URL(value,origin);return ['http:','https:'].includes(url.protocol)?url.href:null;}catch{return null;}}
export function statusLabel(status){return ['待支付','处理中','已取消','已完成','已折抵'][Number(status)]||'未知状态';}

18
frontends/hardnerd/src/main.js vendored Normal file
View File

@ -0,0 +1,18 @@
import {createApp} from 'vue';
import {createRouter,createWebHashHistory} from 'vue-router';
import App from './App.vue';
import Home from './pages/Home.vue';
import Auth from './pages/Auth.vue';
import Dashboard from './pages/Dashboard.vue';
import Plans from './pages/Plans.vue';
import Orders from './pages/Orders.vue';
import Account from './pages/Account.vue';
import Resources from './pages/Resources.vue';
import {state,signOut,notify,api} from './state.mjs';
import './style.css';
const routes=[{path:'/',component:Home},{path:'/landing',redirect:'/'},...['login','register','forgot-password'].map(path=>({path:'/'+path,component:Auth})),{path:'/dashboard',component:Dashboard,meta:{auth:true}},{path:'/shop',component:Plans,meta:{auth:true}},{path:'/order-confirm',component:Plans,meta:{auth:true}},{path:'/payment',component:Orders,meta:{auth:true}},{path:'/orders',component:Orders,meta:{auth:true}},{path:'/profile',component:Account,meta:{auth:true}},...['nodes','tickets','docs','invite'].map(path=>({path:'/'+path,component:Resources,meta:{auth:true}})),{path:'/:pathMatch(.*)*',redirect:'/'}];
const router=createRouter({history:createWebHashHistory(),routes,scrollBehavior:()=>({top:0})});
router.beforeEach(to=>{if(to.meta.auth&&!state.token)return {path:'/login',query:{redirect:to.fullPath}};document.title=(to.path==='/'?'连接不止于此':({'order-confirm':'确认订单',payment:'订单支付','forgot-password':'找回密码',dashboard:'概览',shop:'套餐',orders:'订单',profile:'账户',tickets:'支持',docs:'指南',nodes:'节点',invite:'邀请',login:'登录',register:'注册'}[to.path.slice(1)]||'hardnerd'))+' · '+(window.settings?.title||'hardnerd');});
window.addEventListener('hardnerd-expired',()=>{notify('登录已过期,请重新登录');router.replace('/login');});
api('/guest/comm/config').then(config=>(state.config=config||{},state.configReady=true)).catch(error=>{state.configError=true;notify(error.message);});
createApp(App).use(router).mount('#app');

View File

@ -0,0 +1,8 @@
<script setup>
import {ref,onMounted} from 'vue';import Icon from '../Icon.vue';import {state,api,action,loadAccount,copy,askConfirmation} from '../state.mjs';import {money,date} from '../core.mjs';
const oldPassword=ref(''),newPassword=ref(''),again=ref(''),expire=ref(true),traffic=ref(true),sessions=ref([]),error=ref('');
async function load(){try{await loadAccount();expire.value=!!state.user.remind_expire;traffic.value=!!state.user.remind_traffic;sessions.value=await api('/user/getActiveSession')||[];}catch(e){error.value=e.message;}}onMounted(load);
async function save(){await action(()=>api('/user/update',{method:'POST',data:{remind_expire:expire.value?1:0,remind_traffic:traffic.value?1:0}}),'提醒偏好已保存');}
async function change(){await action(async()=>{if(newPassword.value!==again.value)throw new Error('两次新密码不一致');await api('/user/changePassword',{method:'POST',data:{old_password:oldPassword.value,new_password:newPassword.value}});oldPassword.value='';newPassword.value='';again.value='';await load();},'密码已更新');}
async function revoke(session){if(!await askConfirmation('确认退出这台设备?'))return;await action(async()=>{await api('/user/removeActiveSession',{method:'POST',data:{session_id:session.id}});await load();},'设备会话已退出');}
</script><template><section class="page-heading"><div><span class="eyebrow">04 / MAKE YOURSELF AT HOME</span><h1>你的空间,你作主<span class="lime-dot">.</span></h1><p>账户信息、提醒偏好与登录设备。</p></div><span class="number-tag">HN / ACCOUNT</span></section><p v-if="error" class="error-note" role="alert">{{error}}</p><div class="account-grid"><section class="panel account-profile"><span class="avatar large">{{(state.user?.email||'H').slice(0,1).toUpperCase()}}</span><h2>{{state.user?.email?.split('@')[0]||'探索者'}}</h2><p class="muted email">{{state.user?.email||'正在读取…'}}</p><span class="account-tag">HARDNERD MEMBER</span><div class="profile-detail"><span>加入时间</span><b>{{date(state.user?.created_at)}}</b></div><div class="profile-detail"><span>账户余额</span><b>¥{{money(state.user?.balance)}}</b></div><div class="profile-detail"><span>上次登录</span><b>{{date(state.user?.last_login_at)}}</b></div><RouterLink to="/invite" class="button outline full"><Icon name="spark"/>邀请好友</RouterLink></section><div class="account-settings"><section class="panel"><div class="panel-title"><h2>消息提醒</h2><span class="mono">PREFERENCES</span></div><form @submit.prevent="save"><label class="toggle-row"><span><b>套餐到期提醒</b><small>接收账户套餐到期相关提醒</small></span><input type="checkbox" role="switch" v-model="expire"/></label><label class="toggle-row"><span><b>流量使用提醒</b><small>接收流量用量相关提醒</small></span><input type="checkbox" role="switch" v-model="traffic"/></label><button class="button dark" :disabled="state.busy||!state.user">保存偏好<Icon name="check"/></button></form></section><section class="panel"><div class="panel-title"><h2>修改密码</h2><span class="mono">SECURITY</span></div><form @submit.prevent="change"><label>当前密码<input v-model="oldPassword" type="password" autocomplete="current-password" required/></label><div class="field-pair"><label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="8" required/></label><label>确认新密码<input v-model="again" type="password" autocomplete="new-password" minlength="8" required/></label></div><button class="button dark" :disabled="state.busy">更新密码<Icon name="arrow"/></button></form></section><section class="panel"><div class="panel-title"><h2>登录设备</h2><span class="mono">SESSIONS</span></div><p v-if="!sessions.length" class="muted">暂无设备记录。</p><div v-for="session in sessions" :key="session.id" class="session-row"><div><b>{{session.device_info?.device||session.device_info?.browser||'登录设备'}}</b><small>{{date(session.last_used_at?Date.parse(session.last_used_at)/1000:0)}}</small></div><button @click="revoke(session)" class="text-button" :disabled="state.busy">退出此设备 ↗</button></div></section></div></div></template>

View File

@ -0,0 +1,15 @@
<script setup>
import {ref,computed,watch,onMounted} from 'vue';import {useRoute,useRouter} from 'vue-router';import Icon from '../Icon.vue';import Captcha from '../Captcha.vue';import {state,api,signIn,action,notify} from '../state.mjs';
const logo=window.settings?.logo||'/brand/hn/logo.svg';
const route=useRoute(),router=useRouter(),email=ref(''),password=ref(''),again=ref(''),code=ref(''),invite=ref(String(route.query.code||'')),remember=ref(false),agreed=ref(false),captcha=ref(null),sending=ref(false),countdown=ref(0);let interval;
const mode=computed(()=>route.path.slice(1)),isLogin=computed(()=>mode.value==='login'),isForgot=computed(()=>mode.value==='forgot-password');const title=computed(()=>isLogin.value?'回来,继续探索。':isForgot.value?'找回你的连接。':'很高兴,认识你。');
watch(()=>route.path,()=>{password.value='';again.value='';code.value='';captcha.value?.reset();});
import {onBeforeUnmount} from 'vue';onBeforeUnmount(()=>clearInterval(interval));
function destination(){const redirect=String(route.query.redirect||'');return redirect.startsWith('/')&&!redirect.startsWith('//')?redirect:'/dashboard';}
async function mailLogin(){if(!email.value){notify('请先填写邮箱');return;}await action(()=>api('/passport/auth/loginWithMailLink',{method:'POST',data:{email:email.value,redirect:'/dashboard'}}),'登录邮件已提交,请检查邮箱');}
onMounted(()=>{if(isLogin.value&&route.query.verify)action(async()=>{const data=await api('/passport/auth/token2Login?verify='+encodeURIComponent(route.query.verify));signIn(data.auth_data);router.replace(destination());});});
async function reconnect(){await action(async()=>{state.config=await api('/guest/comm/config')||{};state.configReady=true;state.configError=false;});}
async function sendCode(){if(!email.value){notify('请先填写邮箱');return;}if(sending.value||countdown.value)return;sending.value=true;try{await api('/passport/comm/sendEmailVerify',{method:'POST',data:{email:email.value,...await captcha.value?.payload()}});notify('验证码已发送,请检查邮箱');countdown.value=60;interval=setInterval(()=>{if(--countdown.value<=0)clearInterval(interval);},1000);}catch(error){notify(error.message);}finally{sending.value=false;captcha.value?.reset();}}
async function submit(){await action(async()=>{if(!isLogin.value&&password.value!==again.value)throw new Error('两次密码不一致');if(!isLogin.value&&!isForgot.value&&state.config.tos_url&&!agreed.value)throw new Error('请先阅读并同意服务条款');if(isLogin.value){const data=await api('/passport/auth/login',{method:'POST',data:{email:email.value,password:password.value}});signIn(data.auth_data,remember.value);router.push(destination());}else if(isForgot.value){await api('/passport/auth/forget',{method:'POST',data:{email:email.value,password:password.value,email_code:code.value}});notify('密码已更新,请登录');router.push('/login');}else{const data=await api('/passport/auth/register',{method:'POST',data:{email:email.value,password:password.value,email_code:code.value,invite_code:invite.value,...await captcha.value?.payload()}});signIn(data.auth_data);router.push('/dashboard');}});if(!isLogin.value)captcha.value?.reset();}
</script>
<template><div class="auth-layout"><aside class="auth-art"><RouterLink to="/" class="brand"><img :src="logo" alt=""/>hardnerd<sup>NET</sup></RouterLink><span class="eyebrow">A SPACE FOR THE CURIOUS.</span><div class="auth-slogan">世界很大。<br/>你的连接,<br/>可以<span>更远。</span></div><div class="auth-diagram" aria-hidden="true"><div class="diagram-ring"></div><span>HN</span><i></i><b>↗</b></div><div class="mono">STAY CURIOUS. / CONNECT DIFFERENT.</div></aside><main id="main" class="auth-main" tabindex="-1"><RouterLink to="/" class="auth-back">← 回到首页</RouterLink><div class="auth-form"><span class="eyebrow">{{isLogin?'WELCOME BACK':isForgot?'START AGAIN':'HELLO, HARDNERD.'}}</span><h1>{{title}}</h1><p class="muted">{{isLogin?'登录你的 hardnerd 空间。':isForgot?'通过邮箱验证码重设密码。':'用一个账户,开始新的连接。'}}</p><p v-if="state.configError" class="error-note">站点设置暂未加载。<button class="text-button" @click="reconnect">重新连接 ↗</button></p><form @submit.prevent="submit"><label>邮箱<input v-model="email" type="email" autocomplete="email" placeholder="you@example.com" required/></label><label>{{isForgot?'新密码':'密码'}}<input v-model="password" type="password" :autocomplete="isLogin?'current-password':'new-password'" :minlength="isLogin?1:8" placeholder="请输入密码" required/></label><label v-if="!isLogin">确认密码<input v-model="again" type="password" autocomplete="new-password" minlength="8" placeholder="再次输入密码" required/></label><label v-if="!isLogin&&(state.config.is_email_verify||isForgot)">邮箱验证码<div class="input-action"><input v-model="code" inputmode="numeric" maxlength="6" pattern="[0-9]{6}" required placeholder="6 位验证码"/><button type="button" :disabled="sending||countdown>0||!state.configReady" @click="sendCode">{{countdown?countdown+' 秒':sending?'发送中…':'发送验证码'}}</button></div></label><label v-if="!isLogin&&!isForgot">邀请码<span class="muted">{{state.config.is_invite_force?'(必填)':'(可选)'}}</span><input v-model="invite" :required="!!state.config.is_invite_force" placeholder="如果朋友邀请了你"/></label><Captcha v-if="!isLogin&&state.configReady" :key="mode" ref="captcha"/><div class="auth-options" v-if="isLogin"><label class="checkbox"><input v-model="remember" type="checkbox"/>记住这次登录</label><RouterLink to="/forgot-password">忘记密码?</RouterLink></div><label v-else-if="!isForgot&&state.config.tos_url" class="checkbox"><input v-model="agreed" type="checkbox" required/>我已阅读并同意<a v-if="state.config.tos_url" :href="state.config.tos_url" target="_blank" rel="noopener noreferrer">服务条款 ↗</a><span v-else>服务条款(尚未提供)</span></label><button class="button primary full" :disabled="state.busy||!state.configReady">{{state.busy?'正在处理…':!state.configReady?'正在连接…':isLogin?'进入我的空间':isForgot?'更新密码':'创建账户'}}<Icon name="arrow"/></button></form><button v-if="isLogin" class="text-button mail-login" @click="mailLogin" :disabled="state.busy||!state.configReady">使用邮件链接登录 ↗</button><p class="auth-switch">{{isLogin?'还没有账户?':'已经有账户?'}}<RouterLink :to="isLogin?'/register':'/login'">{{isLogin?'加入 hardnerd':'登录'}} ↗</RouterLink></p></div><footer class="mono">HARDNERD.NET / KEEP EXPLORING.</footer></main></div></template>

View File

@ -0,0 +1,5 @@
<script setup>
import {computed,ref,onMounted} from 'vue';import Icon from '../Icon.vue';import {state,api,notify,copy,loadAccount} from '../state.mjs';import {money,bytes,date,usage} from '../core.mjs';import DOMPurify from 'dompurify';
const metric=computed(()=>usage(state.subscription)),notices=ref([]),failed=ref(false),greeting=computed(()=>new Date().getHours()<12?'上午好':new Date().getHours()<18?'下午好':'晚上好');
onMounted(()=>api('/user/notice/fetch').then(data=>notices.value=data||[]).catch(()=>failed.value=true));
</script><template><section class="page-heading"><div><span class="eyebrow">01 / YOUR CONNECTION AT A GLANCE</span><h1>{{greeting}},探索者<span class="lime-dot">.</span></h1><p>你的连接近况,都在这里。</p></div><button class="icon-button" @click="loadAccount().catch(error=>notify(error.message))" aria-label="刷新账户数据"><Icon name="refresh"/></button></section><div v-if="!state.ready" class="loading-state" role="status">正在读取你的空间…</div><div v-else-if="!state.user" class="empty panel"><h2>暂时没有读到账户数据</h2><button class="button primary" @click="loadAccount().catch(error=>notify(error.message))">重新加载</button></div><template v-else><div class="dashboard-grid"><section class="connection-card"><div class="connection-top"><span class="eyebrow">MY CONNECTION / 我的连接</span><span class="status-pill" :class="{active:state.subscription?.plan_id}">{{state.subscription?.plan_id?'已有套餐':'尚未订阅'}}</span></div><h2>{{state.subscription?.plan?.name||'为下一次探索,\n选一条新连接。'}}</h2><p>{{state.subscription?.plan_id?'每一次好奇,都值得一个顺畅的开始。':'选择适合的套餐,把世界再打开一点。'}}</p><div class="connection-bottom"><RouterLink :to="state.subscription?.plan_id?'/nodes':'/shop'" class="button light">{{state.subscription?.plan_id?'开始连接':'查看套餐'}}<Icon name="arrow"/></RouterLink><span class="mono">HN / YOUR NEXT CHAPTER</span></div><div class="connection-orbit" aria-hidden="true"><span>↗</span></div></section><section class="usage-card panel"><div class="panel-title"><h2>本期流量</h2><span class="mono">DATA / 01</span></div><div class="usage-body"><div class="usage-ring" :style="{'--usage':metric.percent+'%'}"><div><strong>{{metric.percent.toFixed(0)}}<small>%</small></strong><span>已使用</span></div></div><div class="usage-numbers"><span>剩余流量</span><strong>{{bytes(metric.remaining)}}</strong><span>{{bytes(metric.used)}} / {{bytes(metric.total)}}</span></div></div><div class="usage-footer"><span>套餐到期</span><b>{{state.subscription?.expired_at===null&&state.subscription?.plan_id?'不限时':date(state.subscription?.expired_at)}}</b></div></section></div><div class="stat-strip"><div><span>账户余额</span><strong>¥{{money(state.user.balance)}}</strong><small>用于账户内消费</small></div><div><span>当前套餐</span><strong class="small-value">{{state.subscription?.plan?.name||'暂未订阅'}}</strong><small>{{state.subscription?.plan_id?'查看连接与节点':'选择一个适合你的套餐'}}</small></div><div><span>下次流量重置</span><strong class="small-value">{{date(state.subscription?.next_reset_at)}}</strong><small>以当前套餐规则为准</small></div></div><div class="lower-grid"><section class="panel quick-panel"><div class="panel-title"><h2>快捷连接</h2><span class="mono">ONE LESS STEP.</span></div><button class="quick-row" @click="copy(state.subscription?.subscribe_url)"><span class="square-icon"><Icon name="copy"/></span><div><b>复制订阅地址</b><p>导入你正在使用的客户端</p></div><Icon name="arrow"/></button><RouterLink class="quick-row" to="/docs"><span class="square-icon"><Icon name="book"/></span><div><b>第一次使用?从这里开始</b><p>查看配置和客户端使用指南</p></div><Icon name="arrow"/></RouterLink></section><section class="panel notices"><div class="panel-title"><h2>来自 hardnerd</h2><span class="mono">UPDATES</span></div><p v-if="failed" class="muted">公告暂未加载,稍后重试。</p><div v-else-if="!notices.length" class="quiet-state"><span class="mono">ALL QUIET HERE.</span><p>暂无新公告。继续你的探索吧。</p></div><details v-for="notice in notices" :key="notice.id"><summary>{{notice.title}}<span>{{date(notice.updated_at)}}</span></summary><div class="prose" v-html="DOMPurify.sanitize(notice.content||'')"></div></details></section></div></template></template>

View File

@ -0,0 +1,11 @@
<script setup>
import Icon from '../Icon.vue';import {state} from '../state.mjs';
function jump(id){document.getElementById(id)?.scrollIntoView({behavior:window.matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'});}
const logo=window.settings?.logo||'/brand/hn/logo.svg',brand=window.settings?.title||'hardnerd';
</script>
<template><div class="landing"><header class="landing-nav"><RouterLink to="/" class="brand"><img :src="logo" alt=""/>{{brand}}<sup>NET</sup></RouterLink><nav><a href="#idea" @click.prevent="jump('idea')">我们的主张</a><a href="#start" @click.prevent="jump('start')">开始连接</a></nav><RouterLink :to="state.token?'/dashboard':'/login'" class="nav-enter">{{state.token?'进入控制台':'登录账户'}} <Icon name="arrow"/></RouterLink></header>
<main id="main" tabindex="-1"><section class="hero"><div class="hero-copy"><span class="eyebrow"><span class="tiny-dot"></span> FOR THE CURIOUS. / 为好奇而连接</span><h1>连接<span class="outlined">不止</span><br/>于此<span class="lime-dot">.</span></h1><p>你的世界,不必只有眼前这一面。<br/>把连接交给 hardnerd,把好奇留给自己。</p><div class="hero-actions"><RouterLink :to="state.token?'/dashboard':'/register'" class="button primary">{{state.token?'打开我的空间':'开启你的连接'}}<Icon name="arrow"/></RouterLink><RouterLink to="/login" class="text-button">已有账户?登录 ↗</RouterLink></div><div class="hero-foot mono"><span>01 — STAY CURIOUS</span><span>NETWORK / WITHOUT THE NOISE</span></div></div>
<div class="hero-art" aria-hidden="true"><div class="art-caption mono">THE WORLD IS CLOSER<br/>THAN YOU THINK.</div><svg class="orbit-svg" viewBox="0 0 600 620"><defs><pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="1" fill="#8d9589"/></pattern></defs><rect x="0" y="0" width="600" height="620" fill="url(#dots)"/><g fill="none" stroke="#b9bdb0" stroke-width="1"><circle cx="300" cy="300" r="204"/><ellipse cx="300" cy="300" rx="204" ry="74"/><ellipse cx="300" cy="300" rx="100" ry="204"/><path d="M96 300h408M300 96v408M126 194h348M126 406h348"/></g><g fill="none" stroke="#171b1a" stroke-width="2"><path d="M112 335Q240 110 442 177M183 444Q412 437 442 177M112 335Q227 454 383 350"/></g><g fill="#b4f047" stroke="#171b1a" stroke-width="2"><circle cx="112" cy="335" r="9"/><circle cx="442" cy="177" r="9"/><circle cx="183" cy="444" r="9"/><circle cx="383" cy="350" r="9"/></g><g transform="translate(235 235) rotate(-8 65 65)"><rect width="130" height="130" rx="22" fill="#171b1a"/><path d="M28 26h18v78H28zm56 0h18v78H84" fill="#f4f3ee"/><path d="M20 51h34v25H28L20 68zm57 0h34v17l-8 8H77zM54 60h23" fill="#b4f047" stroke="#b4f047" stroke-width="5"/></g><text x="63" y="571" font-size="13" font-family="monospace">HN / 001</text><path d="M461 534h60m-10-10 10 10-10 10" fill="none" stroke="#171b1a" stroke-width="2"/></svg><span class="art-note mono">OPEN A NEW PERSPECTIVE ↗</span></div></section>
<div class="brand-band"><span>STAY CURIOUS.</span><span class="band-star">✳</span><span>CONNECT DIFFERENT.</span><span class="band-star">✳</span><span>HARDNERD.</span></div>
<section id="idea" class="manifesto"><div><span class="eyebrow">02 / LESS FRICTION. MORE WORLD.</span><h2>让连接简单。<br/>把空间留给探索。</h2></div><div class="principles"><article><span class="mono">01</span><div><h3>看得清楚,用得明白</h3><p>套餐、用量与到期时间,一眼就能找到。每一次选择,都有清楚的信息。</p></div></article><article><span class="mono">02</span><div><h3>从这里,开始连接</h3><p>订阅入口、节点列表与使用指南,集中在你的空间里。少一点寻找,多一点行动。</p></div></article><article><span class="mono">03</span><div><h3>遇到问题,有处可问</h3><p>把问题交给工单,让沟通有记录,让进展可追踪。</p></div></article></div></section>
<section id="start" class="start-panel"><span class="eyebrow">YOUR NEXT CONNECTION STARTS HERE.</span><h2>下一站,<br/>由你定义<span>.</span></h2><RouterLink :to="state.token?'/shop':'/register'" class="button light">{{state.token?'选择适合的套餐':'加入 hardnerd'}}<Icon name="arrow"/></RouterLink><span class="start-mark" aria-hidden="true">↗</span></section></main><footer class="landing-footer"><span class="brand">{{brand}}.net</span><span>© {{new Date().getFullYear()}} {{brand}} · 保持好奇。</span><RouterLink to="/login">用户登录 ↗</RouterLink></footer></div></template>

View File

@ -0,0 +1,9 @@
<script setup>
import {ref,onMounted,watch,computed,onBeforeUnmount} from 'vue';import {useRoute,useRouter} from 'vue-router';import QRCode from 'qrcode';import Icon from '../Icon.vue';import {api,state,action,notify,loadAccount,askConfirmation} from '../state.mjs';import {money,date,statusLabel,periods,safeLink} from '../core.mjs';
const route=useRoute(),router=useRouter(),orders=ref([]),detail=ref(null),methods=ref([]),method=ref(''),loading=ref(true),error=ref(''),paymentLink=ref(''),qr=ref('');let generation=0;const paying=computed(()=>route.path==='/payment');
async function load(){const n=++generation;loading.value=true;error.value='';paymentLink.value='';qr.value='';try{if(paying.value){const [order,payments]=await Promise.all([api('/user/order/detail?trade_no='+encodeURIComponent(route.query.trade_no||'')),api('/user/order/getPaymentMethod')]);if(n!==generation)return;detail.value=order;methods.value=payments||[];method.value=methods.value[0]?.id||'';}else{const value=await api('/user/order/fetch');if(n===generation)orders.value=value||[];}}catch(e){if(n===generation)error.value=e.message;}finally{if(n===generation)loading.value=false;}}
onMounted(load);watch(()=>route.fullPath,load);onBeforeUnmount(()=>generation++);
async function cancel(order){if(!await askConfirmation('确认取消这笔待支付订单?'))return;await action(async()=>{await api('/user/order/cancel',{method:'POST',data:{trade_no:order.trade_no}});await load();},'订单已取消');}
async function checkout(){await action(async()=>{const result=await api('/user/order/checkout',{method:'POST',data:{trade_no:detail.value.trade_no,method:method.value},raw:true});if(result.type===1){const link=safeLink(result.data,window.location.origin);if(!link)throw new Error('支付链接格式不正确,请联系支持');paymentLink.value=link;}else if(result.type===0){qr.value=await QRCode.toDataURL(String(result.data));}else{await load();await loadAccount();notify('订单状态已更新');}});}
async function refresh(){await action(async()=>{await api('/user/order/check?trade_no='+encodeURIComponent(detail.value.trade_no));await load();await loadAccount();},'已刷新订单状态');}
</script><template><section class="page-heading"><div><span class="eyebrow">03 / EVERY STEP, IN ONE PLACE</span><h1>{{paying?'完成这次连接':'你的每一次选择'}}<span class="lime-dot">.</span></h1><p>{{paying?'订单已创建。选择支付方式,完成开通。':'订单记录与处理进度,清楚可查。'}}</p></div><RouterLink v-if="paying" to="/orders" class="text-button">← 返回订单</RouterLink><button v-else class="icon-button" @click="load" aria-label="刷新订单"><Icon name="refresh"/></button></section><div v-if="loading" class="loading-state" role="status">正在读取订单…</div><div v-else-if="error" class="empty panel"><h2>暂时无法读取订单</h2><p>{{error}}</p><button class="button primary" @click="load">重试</button></div><div v-else-if="paying&&detail" class="checkout-grid"><section class="panel checkout-plan"><span class="status-pill" :class="{active:detail.status===3}">{{statusLabel(detail.status)}}</span><h2>{{detail.plan?.name||'套餐订单'}}</h2><p class="mono order-number">{{detail.trade_no}}</p><div class="summary-line"><span>计费周期</span><b>{{periods[detail.period]||detail.period}}</b></div><div class="summary-line"><span>创建时间</span><b>{{date(detail.created_at)}}</b></div><div class="summary-line"><span>订单金额</span><b>¥{{money(detail.total_amount)}}</b></div><div class="summary-line" v-if="detail.handling_amount"><span>手续费</span><b>¥{{money(detail.handling_amount)}}</b></div><div class="summary-total"><span>应付金额</span><strong>¥{{money(Number(detail.total_amount)+Number(detail.handling_amount||0))}}</strong></div><button class="text-button" v-if="detail.status===0" @click="cancel(detail)" :disabled="state.busy">取消订单</button></section><aside class="panel checkout-summary"><span class="eyebrow">NEXT STEP</span><h2>{{detail.status===0?'选择支付方式':'订单进度'}}</h2><template v-if="detail.status===0"><div v-if="!methods.length" class="quiet-state"><p>暂未配置可用支付方式。</p><RouterLink to="/tickets" class="text-button">联系支持 ↗</RouterLink></div><label class="payment-option" v-for="item in methods" :key="item.id"><input type="radio" v-model="method" :value="item.id"/>{{item.name}}<span v-if="item.handling_fee_percent||item.handling_fee_fixed" class="hint">手续费 {{item.handling_fee_percent||0}}% + ¥{{money(item.handling_fee_fixed)}}</span></label><button class="button primary full" v-if="methods.length" :disabled="state.busy||!method" @click="checkout">{{state.busy?'正在处理…':'获取支付入口'}}<Icon name="arrow"/></button><a v-if="paymentLink" class="button dark full" :href="paymentLink" target="_blank" rel="noopener noreferrer">前往支付页面 ↗</a><img v-if="qr" class="payment-qr" :src="qr" alt="订单支付二维码"/><p v-if="paymentLink||qr" class="hint">支付完成后,点击下方按钮刷新订单状态。</p></template><p v-else class="muted">{{detail.status===3?'订单已完成,可以开始使用连接。':detail.status===2?'这笔订单已取消。':'订单正在处理中,请稍后刷新。'}}</p><button class="button outline full" @click="refresh" :disabled="state.busy"><Icon name="refresh"/>刷新订单状态</button><RouterLink v-if="detail.status===3" to="/dashboard" class="text-button">返回我的空间 ↗</RouterLink></aside></div><div v-else-if="!orders.length" class="empty panel"><Icon name="receipt"/><span class="eyebrow">A FRESH START.</span><h2>第一笔连接,还在前方。</h2><p>你还没有订单。找到适合的套餐,开始新的探索。</p><RouterLink to="/shop" class="button primary">浏览套餐<Icon name="arrow"/></RouterLink></div><section v-else class="panel order-table"><div class="table-row table-head"><span>订单 / 套餐</span><span>金额</span><span>日期</span><span>状态</span><span></span></div><div class="table-row" v-for="order in orders" :key="order.trade_no"><div><b>{{order.plan?.name||'套餐订单'}}</b><small class="mono">{{order.trade_no}}</small></div><strong>¥{{money(order.total_amount)}}</strong><span>{{date(order.created_at)}}</span><span class="status-pill" :class="{active:order.status===3}">{{statusLabel(order.status)}}</span><button class="text-button" @click="router.push({path:'/payment',query:{trade_no:order.trade_no}})">{{order.status===0?'继续支付':'查看详情'}} ↗</button></div></section></template>

View File

@ -0,0 +1,10 @@
<script setup>
import {ref,computed,onMounted,watch} from 'vue';import {useRoute,useRouter} from 'vue-router';import Icon from '../Icon.vue';import {state,api,action,notify} from '../state.mjs';import {money,periods,planPeriods} from '../core.mjs';import DOMPurify from 'dompurify';
const route=useRoute(),router=useRouter(),plans=ref([]),loading=ref(true),error=ref(''),selections=ref({}),coupon=ref(''),couponInfo=ref(null);const selected=computed(()=>plans.value.find(p=>String(p.id)===String(route.query.id))),confirming=computed(()=>route.path==='/order-confirm');const period=computed(()=>selected.value?selections.value[selected.value.id]:'');const total=computed(()=>{let cents=Number(selected.value?.[period.value]||0);if(couponInfo.value?.type===1)cents-=Number(couponInfo.value.value||0);if(couponInfo.value?.type===2)cents*=Math.max(0,1-Number(couponInfo.value.value||0)/100);return Math.max(0,cents);});
async function load(){loading.value=true;error.value='';try{plans.value=await api('/user/plan/fetch')||[];for(const plan of plans.value){const options=planPeriods(plan);selections.value[plan.id]=options.includes(route.query.period)?route.query.period:options[0];}}catch(e){error.value=e.message;}finally{loading.value=false;}}
onMounted(load);watch(()=>route.fullPath,()=>{coupon.value='';couponInfo.value=null;if(selected.value&&planPeriods(selected.value).includes(route.query.period))selections.value[selected.value.id]=route.query.period;});watch(coupon,()=>couponInfo.value=null);
function choose(plan){router.push({path:'/order-confirm',query:{id:plan.id,period:selections.value[plan.id]}});}
async function verify(){await action(async()=>{couponInfo.value=await api('/user/coupon/check',{method:'POST',data:{code:coupon.value,plan_id:selected.value.id,period:period.value}});},'优惠码已验证');}
async function submit(){await action(async()=>{const trade=await api('/user/order/save',{method:'POST',data:{plan_id:selected.value.id,period:period.value,...(coupon.value?{coupon_code:coupon.value}:{})}});router.push({path:'/payment',query:{trade_no:trade}});});}
function content(plan){const limit=plan.display_device_limit??plan.device_limit;return DOMPurify.sanitize(String(plan.content||'').replaceAll('{{display_device_limit}}',limit==null||Number(limit)===0?'不限':String(limit)));}
</script><template><section class="page-heading"><div><span class="eyebrow">02 / FIND YOUR CONNECTION</span><h1>{{confirming?'确认你的选择':'给好奇,留点余量'}}<span class="lime-dot">.</span></h1><p>{{confirming?'核对套餐与周期,再创建订单。':'根据实际需求,找到适合的连接方式。'}}</p></div><RouterLink v-if="confirming" to="/shop" class="text-button">← 返回套餐列表</RouterLink><span v-else class="number-tag">HN / PLANS</span></section><div v-if="loading" class="loading-state" role="status">正在读取套餐…</div><div v-else-if="error" class="empty panel" role="alert"><h2>暂时无法读取套餐</h2><p>{{error}}</p><button class="button primary" @click="load">重试</button></div><template v-else-if="confirming"><div v-if="selected" class="checkout-grid"><section class="panel checkout-plan"><span class="eyebrow">YOUR SELECTION</span><h2>{{selected.name}}</h2><div class="prose" v-html="content(selected)"></div><h3>选择计费周期</h3><div class="period-grid"><button v-for="key in planPeriods(selected)" :key="key" :class="{selected:period===key}" @click="selections[selected.id]=key;couponInfo=null"><span>{{periods[key]}}</span><strong>¥{{money(selected[key])}}</strong></button></div><div class="plan-facts"><span>流量额度 <b>{{selected.transfer_enable}} GB</b></span><span>速率上限 <b>{{selected.speed_limit==null?'不限速':selected.speed_limit+' Mbps'}}</b></span></div></section><aside class="panel checkout-summary"><span class="eyebrow">ORDER SUMMARY</span><h2>订单摘要</h2><label>优惠码<div class="input-action"><input v-model="coupon" placeholder="如果你有优惠码"/><button @click="verify" :disabled="!coupon||state.busy">验证</button></div></label><div class="summary-line"><span>{{selected.name}}</span><b>{{periods[period]}}</b></div><div class="summary-line"><span>套餐金额</span><b>¥{{money(selected[period])}}</b></div><div v-if="couponInfo" class="summary-line"><span>优惠</span><b>−¥{{money(selected[period]-total)}}</b></div><div class="summary-total"><span>预计金额</span><strong>¥{{money(total)}}</strong></div><p class="hint">最终应付金额及手续费以创建后的订单为准。</p><button class="button primary full" @click="submit" :disabled="state.busy||!period">{{state.busy?'正在创建…':'创建订单'}}<Icon name="arrow"/></button></aside></div><div v-else class="empty panel"><h2>没有找到这个套餐</h2><RouterLink to="/shop" class="button primary">返回套餐列表</RouterLink></div></template><template v-else><div v-if="!plans.length" class="empty panel"><Icon name="bag"/><h2>套餐正在准备中</h2><p>目前暂无可购买套餐,请稍后回来看看。</p></div><div v-else class="plan-grid"><article class="plan-card" v-for="(plan,index) in plans" :key="plan.id"><div class="plan-top"><span class="mono">PLAN / {{String(index+1).padStart(2,'0')}}</span><Icon name="spark"/></div><h2>{{plan.name}}</h2><div class="plan-price"><span>¥</span><strong>{{money(plan[selections[plan.id]])}}</strong><small>/ {{periods[selections[plan.id]]}}</small></div><label class="period-select">计费周期<select v-model="selections[plan.id]"><option v-for="key in planPeriods(plan)" :key="key" :value="key">{{periods[key]}} · ¥{{money(plan[key])}}</option></select></label><div class="plan-facts"><span><Icon name="check"/>{{plan.transfer_enable}} GB 流量</span><span><Icon name="check"/>{{plan.speed_limit==null?'不限速':plan.speed_limit+' Mbps 速率上限'}}</span><span><Icon name="check"/>{{(plan.display_device_limit??plan.device_limit)?(plan.display_device_limit??plan.device_limit)+' 台设备':'设备数量不限'}}</span></div><div class="prose plan-description" v-html="content(plan)"></div><button class="button primary full" :disabled="!selections[plan.id]" @click="choose(plan)">选择这个套餐<Icon name="arrow"/></button></article></div><div class="help-strip"><span><Icon name="help"/>不确定怎么选?告诉我们你的使用需求。</span><RouterLink to="/tickets">联系支持 ↗</RouterLink></div></template></template>

View File

@ -0,0 +1,23 @@
<script setup>
import {ref,computed,onMounted,watch,onBeforeUnmount} from 'vue';import {useRoute,useRouter} from 'vue-router';import DOMPurify from 'dompurify';import {marked} from 'marked';import Icon from '../Icon.vue';import {state,api,action,copy,askConfirmation} from '../state.mjs';import {date,money,safeLink} from '../core.mjs';
const route=useRoute(),router=useRouter(),data=ref(null),loading=ref(true),error=ref(''),search=ref(''),creating=ref(false),subject=ref(''),message=ref(''),level=ref(0),reply=ref('');let generation=0;
const kind=computed(()=>route.path.slice(1)),heads={nodes:['05 / GET CONNECTED','把世界,接到眼前。','订阅地址、可用节点与连接入口。'],tickets:['06 / WE ARE HERE TO HELP','问题,交给我们。','新的工单、沟通记录和回复进展。'],docs:['07 / A LITTLE GUIDANCE','好连接,从懂得开始。','使用指南与常见问题。'],invite:['08 / GOOD THINGS TRAVEL','好连接,值得分享。','邀请好友,以及你的邀请记录。']};
const heading=computed(()=>heads[kind.value]);const filtered=computed(()=>{const values=Array.isArray(data.value)?data.value:[];return values.filter(item=>(item.name||item.subject||'').toLowerCase().includes(search.value.toLowerCase()));});
const documents=computed(()=>data.value&&kind.value==='docs'&&!route.query.id?Object.entries(data.value).flatMap(([category,items])=>(Array.isArray(items)?items:[]).map(item=>({...item,category}))):[]);const foundDocs=computed(()=>documents.value.filter(item=>(item.title||'').includes(search.value)));
function article(body){return DOMPurify.sanitize(marked.parse(String(body||'')));}
async function load(){const n=++generation;loading.value=true;error.value='';data.value=null;search.value='';try{let path=kind.value==='nodes'?'/user/server/fetch':kind.value==='tickets'?'/user/ticket/fetch':kind.value==='docs'?'/user/knowledge/fetch?language=zh-CN':'/user/invite/fetch';if(route.query.id)path+=(path.includes('?')?'&':'?')+'id='+encodeURIComponent(route.query.id);const value=await api(path);if(n===generation)data.value=value;}catch(e){if(n===generation)error.value=e.message;}finally{if(n===generation)loading.value=false;}}
onMounted(load);watch(()=>route.fullPath,()=>{creating.value=false;reply.value='';load();});onBeforeUnmount(()=>generation++);
async function createTicket(){await action(async()=>{await api('/user/ticket/save',{method:'POST',data:{subject:subject.value,message:message.value,level:level.value}});creating.value=false;subject.value='';message.value='';await load();},'工单已提交');}
async function sendReply(){await action(async()=>{await api('/user/ticket/reply',{method:'POST',data:{id:data.value.id,message:reply.value}});reply.value='';await load();},'回复已发送');}
async function closeTicket(){if(!await askConfirmation('确认关闭这张工单?'))return;await action(async()=>{await api('/user/ticket/close',{method:'POST',data:{id:data.value.id}});await load();},'工单已关闭');}
async function inviteCode(){await action(async()=>{await api('/user/invite/save');await load();},'邀请码已创建');}
function invitation(code){return window.location.origin+window.location.pathname+'#/register?code='+encodeURIComponent(code);}
const subscribeLink=computed(()=>safeLink(state.subscription?.subscribe_url,window.location.origin));
</script><template><section class="page-heading"><div><span class="eyebrow">{{heading[0]}}</span><h1>{{heading[1]}}</h1><p>{{heading[2]}}</p></div><button v-if="kind==='tickets'&&!route.query.id" class="button primary" @click="creating=!creating"><Icon name="plus"/>新建工单</button><RouterLink v-else-if="route.query.id" :to="'/'+kind" class="text-button">← 返回列表</RouterLink><span v-else class="number-tag">HN / {{kind.toUpperCase()}}</span></section>
<section v-if="kind==='nodes'" class="panel subscribe-panel"><div><span class="eyebrow">YOUR SUBSCRIPTION</span><h2>一个地址,把连接带走。</h2><p class="muted">复制订阅地址,在客户端中导入。订阅地址仅供你本人使用。</p></div><div class="subscription-actions"><button class="button primary" :disabled="!state.subscription?.subscribe_url" @click="copy(state.subscription?.subscribe_url)"><Icon name="copy"/>复制订阅地址</button><RouterLink to="/docs" class="button outline">查看使用指南<Icon name="arrow"/></RouterLink></div></section>
<form v-if="kind==='tickets'&&creating" class="panel ticket-form" @submit.prevent="createTicket"><div class="panel-title"><h2>把问题说清楚,我们更好帮到你。</h2><button type="button" class="icon-button" @click="creating=false" aria-label="关闭新工单"><Icon name="close"/></button></div><div class="field-pair"><label>问题主题<input v-model="subject" required maxlength="100" placeholder="例如:客户端无法导入订阅"/></label><label>优先级<select v-model="level"><option :value="0">一般</option><option :value="1">中等</option><option :value="2">紧急</option></select></label></div><label>问题描述<textarea v-model="message" required rows="5" placeholder="描述遇到的问题、使用的客户端及已经尝试的步骤。"></textarea></label><button class="button primary" :disabled="state.busy">提交工单<Icon name="arrow"/></button></form>
<div v-if="loading" class="loading-state" role="status">正在读取{{kind==='tickets'?'工单':kind==='nodes'?'节点':kind==='docs'?'指南':'邀请记录'}}…</div><div v-else-if="error" class="empty panel"><h2>暂时无法读取内容</h2><p>{{error}}</p><button class="button primary" @click="load">重试</button></div>
<template v-else-if="kind==='nodes'"><div class="list-toolbar"><h2>可用节点 <span class="count">{{Array.isArray(data)?data.length:0}}</span></h2><input v-model="search" aria-label="搜索节点" placeholder="搜索节点名称…"/></div><div v-if="!filtered.length" class="empty panel"><Icon name="globe"/><h2>{{state.subscription?.plan_id?'暂无可用节点':'你的第一条连接,还没开始。'}}</h2><p>{{state.subscription?.plan_id?'请稍后重试,或通过工单联系支持。':'购买套餐后,可在这里查看对应节点。'}}</p><RouterLink v-if="!state.subscription?.plan_id" to="/shop" class="button primary">查看套餐<Icon name="arrow"/></RouterLink></div><div v-else class="node-grid"><article class="panel node-card" v-for="node in filtered" :key="node.cache_key"><Icon name="globe"/><h3>{{node.name}}</h3><span class="status-pill" :class="{active:node.is_online}">{{node.is_online?'在线':'离线'}}</span><div class="node-meta"><span>{{node.type}}</span><span>{{node.rate}}× 倍率</span></div></article></div></template>
<template v-else-if="kind==='tickets'"><section v-if="route.query.id&&data" class="panel conversation"><div class="panel-title"><h2>{{data.subject}}</h2><span class="status-pill">{{data.status?'已关闭':'处理中'}}</span></div><article v-for="item in data.message||[]" :key="item.id" class="message" :class="{'from-me':item.is_me}"><span class="mono">{{item.is_me?'我':'hardnerd 支持'}} · {{date(item.created_at)}}</span><p>{{item.message}}</p></article><form v-if="!data.status" @submit.prevent="sendReply"><label>补充回复<textarea v-model="reply" required rows="4" placeholder="补充新的信息…"></textarea></label><div class="form-actions"><button class="button primary" :disabled="state.busy">发送回复<Icon name="arrow"/></button><button type="button" class="text-button" @click="closeTicket" :disabled="state.busy">关闭工单</button></div></form></section><div v-else-if="!filtered.length" class="empty panel"><Icon name="help"/><h2>这里很安静。</h2><p>你还没有工单。有问题时,我们就在这里。</p><button class="button primary" @click="creating=true">创建第一张工单<Icon name="arrow"/></button></div><section v-else class="panel ticket-list"><button class="ticket-row" v-for="ticket in filtered" :key="ticket.id" @click="router.push({path:'/tickets',query:{id:ticket.id}})"><span class="mono">#{{ticket.id}}</span><div><b>{{ticket.subject}}</b><small>{{date(ticket.updated_at)}}</small></div><span class="status-pill" :class="{active:ticket.reply_status===0&&!ticket.status}">{{ticket.status?'已关闭':ticket.reply_status===0?'待回复':'有新回复'}}</span><Icon name="arrow"/></button></section></template>
<template v-else-if="kind==='docs'"><article v-if="route.query.id&&data" class="panel article"><span class="eyebrow">{{data.category}}</span><h2>{{data.title}}</h2><div class="prose" v-html="article(data.body)"></div></article><template v-else><div class="list-toolbar"><h2>探索指南</h2><input v-model="search" aria-label="搜索指南" placeholder="搜索标题…"/></div><div v-if="!foundDocs.length" class="empty panel"><Icon name="book"/><h2>指南正在准备中。</h2><p>如果你需要帮助,可以先创建工单。</p><RouterLink to="/tickets" class="button outline">联系支持<Icon name="arrow"/></RouterLink></div><div v-else class="docs-grid"><RouterLink class="panel doc-card" v-for="doc in foundDocs" :key="doc.id" :to="{path:'/docs',query:{id:doc.id}}"><span class="eyebrow">{{doc.category}}</span><h2>{{doc.title}}</h2><span class="text-button">阅读指南<Icon name="arrow"/></span></RouterLink></div></template></template>
<template v-else-if="kind==='invite'"><section class="invite-hero"><div><span class="eyebrow">SHARE A GOOD CONNECTION.</span><h2>把好奇,<br/>带给朋友。</h2><p>创建你的专属邀请链接,分享给需要的人。</p><button class="button light" @click="inviteCode" :disabled="state.busy"><Icon name="plus"/>创建邀请码</button></div><span class="invite-art" aria-hidden="true">↗<br/>↗</span></section><div class="stat-strip"><div><span>已邀请用户</span><strong>{{data?.stat?.[0]||0}}</strong><small>注册人数</small></div><div><span>累计佣金</span><strong>¥{{money(data?.stat?.[1])}}</strong><small>以实际邀请记录为准</small></div><div><span>可用佣金</span><strong>¥{{money(data?.stat?.[4])}}</strong><small>奖励比例 {{data?.stat?.[3]||0}}%</small></div></div><section class="panel"><div class="panel-title"><h2>你的邀请链接</h2><span class="mono">PASS IT ON.</span></div><p v-if="!data?.codes?.length" class="muted">还没有邀请码,点击上方按钮创建。</p><div v-for="item in data?.codes||[]" :key="item.code" class="invite-row"><span class="mono">{{item.code}}</span><button class="button outline" @click="copy(invitation(item.code))"><Icon name="copy"/>复制邀请链接</button></div></section></template></template>

View File

@ -0,0 +1,24 @@
import {reactive} from 'vue';
const key='hardnerd.auth';
function stored(){try{return sessionStorage.getItem(key)||localStorage.getItem(key)||'';}catch{return '';}}
export const state=reactive({token:stored(),user:null,subscription:null,config:{},configReady:false,configError:false,confirm:null,toast:'',busy:false,ready:false});
let toastTimer;
export function notify(message){state.toast=message;clearTimeout(toastTimer);toastTimer=setTimeout(()=>state.toast='',5000);}
export function signIn(token,remember=false){if(typeof token!=='string'||!token.startsWith('Bearer '))throw new Error('登录凭据格式不正确');localStorage.removeItem(key);sessionStorage.removeItem(key);(remember?localStorage:sessionStorage).setItem(key,token);state.token=token;state.user=null;state.subscription=null;state.ready=false;}
export function signOut(){localStorage.removeItem(key);sessionStorage.removeItem(key);state.token='';state.user=null;state.subscription=null;state.ready=false;}
export async function api(path,{method='GET',data,raw=false}={}){
if(!path.startsWith('/')||path.startsWith('//'))throw new Error('接口地址无效');
const sentToken=state.token;const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),20000);
try{
const response=await fetch('/api/v1'+path,{method,credentials:'same-origin',redirect:'error',signal:controller.signal,headers:{Accept:'application/json',...(sentToken?{Authorization:sentToken}:{}),...(data?{'Content-Type':'application/json'}:{})},...(data?{body:JSON.stringify(data)}:{})});
let body;try{body=await response.json();}catch{throw new Error('服务暂时不可用,请稍后重试');}
if(state.token===sentToken&&(response.status===401||(response.status===403&&body.message==='Unauthorized'))){signOut();window.dispatchEvent(new Event('hardnerd-expired'));}
if(!response.ok){const detail=body.errors?Object.values(body.errors).flat()[0]:null;throw new Error(detail||body.message||'操作失败,请稍后重试');}
return raw?body:body.data;
}catch(error){if(error.name==='AbortError')throw new Error('请求超时,请检查网络后重试');throw error;}finally{clearTimeout(timer);}
}
export async function loadAccount(){const token=state.token;state.ready=false;try{const [user,subscription]=await Promise.all([api('/user/info'),api('/user/getSubscribe')]);if(state.token===token){state.user=user;state.subscription=subscription;}}finally{if(state.token===token)state.ready=true;}}
export async function action(callback,success){if(state.busy)return;state.busy=true;try{await callback();if(success)notify(success);}catch(error){notify(error.message);}finally{state.busy=false;}}
export async function copy(text){if(!text){notify('暂无可复制的内容');return;}try{await navigator.clipboard.writeText(text);notify('已复制');}catch{notify('浏览器未允许复制,请手动选择内容');}}
export function askConfirmation(message){if(state.confirm)state.confirm.resolve(false);return new Promise(resolve=>state.confirm={message,resolve});}

18
frontends/hardnerd/src/style.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,7 @@
import {test} from 'node:test';import assert from 'node:assert/strict';
const storage=()=>{const values=new Map();return {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};};
globalThis.sessionStorage=storage();globalThis.localStorage=storage();globalThis.window=new EventTarget();
const {api,state,signIn}=await import('../src/state.mjs');
test('API never sends credentials to an arbitrary origin',async()=>{let calls=0;globalThis.fetch=async()=>{calls++;throw new Error('unexpected HTTP');};await assert.rejects(api('//external.example/path'),/接口地址无效/);assert.equal(calls,0);});
test('authenticated requests use same-origin APIs and preserve structured errors',async()=>{signIn('Bearer local-test');globalThis.fetch=async(path,options)=>{assert.equal(path,'/api/v1/user/info');assert.equal(options.headers.Authorization,'Bearer local-test');assert.equal(options.redirect,'error');return {ok:false,status:422,json:async()=>({message:'invalid',errors:{email:['邮箱格式不正确']}})};};await assert.rejects(api('/user/info'),/邮箱格式不正确/);});
test('a stale expired request cannot sign out a newer session',async()=>{signIn('Bearer old-local-test');let resolve;globalThis.fetch=()=>new Promise(done=>resolve=done);const pending=api('/user/info');signIn('Bearer new-local-test');resolve({ok:false,status:401,json:async()=>({message:'Unauthorized'})});await assert.rejects(pending,/Unauthorized/);assert.equal(state.token,'Bearer new-local-test');});

View File

@ -0,0 +1,5 @@
import {test} from 'node:test';import assert from 'node:assert/strict';import {money,usage,planPeriods,safeLink,statusLabel} from '../src/core.mjs';
test('amounts stay in cents, including zero-price periods',()=>{assert.equal(money(1900),'19.00');assert.equal(money(0),'0.00');assert.deepEqual(planPeriods({month_price:0,year_price:1900,quarter_price:null}),['month_price','year_price']);});
test('traffic meters handle empty, exhausted and over-quota subscriptions',()=>{assert.deepEqual(usage(null),{total:0,used:0,remaining:0,percent:0});assert.equal(usage({transfer_enable:100,u:70,d:50}).remaining,0);assert.equal(usage({transfer_enable:100,u:70,d:50}).percent,100);assert.equal(usage({transfer_enable:100,u:20,d:5}).percent,25);});
test('payment links reject executable and malformed protocols',()=>{assert.equal(safeLink('javascript:alert(1)','https://hardnerd.net'),null);assert.equal(safeLink('data:text/html,payment','https://hardnerd.net'),null);assert.equal(safeLink('https://pay.example.test/order','https://hardnerd.net'),'https://pay.example.test/order');assert.equal(safeLink('/orders','https://hardnerd.net'),'https://hardnerd.net/orders');});
test('order labels preserve backend state meanings',()=>{assert.equal(statusLabel(0),'待支付');assert.equal(statusLabel(2),'已取消');assert.equal(statusLabel(3),'已完成');assert.equal(statusLabel(999),'未知状态');});

View File

@ -0,0 +1,18 @@
import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const root=path.dirname(fileURLToPath(import.meta.url));
const backend=path.resolve(root,'../..');
const marker=path.join(backend,'storage/framework/hardnerd-vite.hot');
export default defineConfig(({mode})=>({
root,envDir:false,publicDir:false,base:mode==='production'?'/theme/Hardnerd/assets/':'/',
plugins:[vue(),{name:'hardnerd-local-preview',configureServer(server){
server.httpServer?.once('listening',()=>{fs.mkdirSync(path.dirname(marker),{recursive:true});fs.writeFileSync(marker,'http://127.0.0.1:5175');});
server.httpServer?.once('close',()=>{if(fs.existsSync(marker))fs.unlinkSync(marker);});
}}],
resolve:{dedupe:['vue']},
build:{outDir:path.join(backend,'theme/Hardnerd/assets'),assetsDir:'chunks',manifest:true,emptyOutDir:true,sourcemap:false},
server:{host:'127.0.0.1',port:5175,strictPort:true,origin:'http://127.0.0.1:5175',cors:{origin:/^http:\/\/(127\.0\.0\.1|localhost|hn\.loc)(:\d+)?$/},fs:{allow:[root,path.join(backend,'frontends/node_modules')],deny:['**/.env','**/.env.*','**/.git','**/.git/**','**/.npmrc']},proxy:{'/api':'http://127.0.0.1:8591','/brand':'http://127.0.0.1:8591'}}
}));

View File

@ -13,6 +13,8 @@ export function adaptConfig(original, settings, location) {
config.DEFAULT_CONFIG.primaryColor = '#8bcf36';
config.AUTH_LAYOUT_CONFIG.splitLayout.leftContent.backgroundImage = '';
config.AUTHORIZED_DOMAINS = [location.hostname];
config.SHOP_CONFIG.showPlanFeatureCards = false;
config.SHOP_CONFIG.popup.enabled = false;
config.CLIENT_CONFIG.showDownloadCard = false;
config.CLIENT_CONFIG.clientLinks = {};
config.PROFILE_CONFIG.showRecentDevices = false;

View File

@ -9,7 +9,7 @@ const upstream = path.resolve(root, '../ez-theme');
const upstreamConfig = path.join(upstream, 'src/config/index.js');
const marker = path.join(backend, 'storage/framework/ez-vite.hot');
export default defineConfig(({mode}) => ({
root, envDir: false, base: mode === 'production' ? '/theme/EZ/assets/' : '/', publicDir: false,
root, envDir: false, base: mode === 'production' ? '/theme/EZ/assets/' : '/__hn_vite/', publicDir: false,
plugins: [{name:'hn-ez-adapter', enforce:'pre',
load(id) {
if (id === upstreamConfig) return `import {config as original} from ${JSON.stringify(upstreamConfig+'?hn-base')}; import {adaptConfig} from ${JSON.stringify(path.join(root,'runtime-config.mjs'))}; export const config=adaptConfig(original,window.settings||{},window.location);`;
@ -17,12 +17,13 @@ export default defineConfig(({mode}) => ({
transform(code, id) {
if (!id.startsWith(upstream + '/src/')) return;
code = code.replaceAll('src="/images/logo.png"', ':src="hnLogo"').replaceAll('/images/logo.png', '/brand/hn/logo.svg');
if (id === path.join(upstream,'src/i18n/locales/zh-CN.js')) code = code.replaceAll('商店', '购买套餐');
if (id === path.join(upstream,'src/appInit.js')) code = code.replace("app.mount('#app');", "app.config.globalProperties.hnLogo = window.settings?.logo || '/brand/hn/logo.svg'; app.mount('#app');");
if (id === path.join(upstream,'src/api/user.js')) code = code.replace(/export function getIpLocationInfo\(\)\s*\{[\s\S]*?\n\}/, 'export function getIpLocationInfo() { return Promise.resolve({data: null}); }');
return code;
},
configureServer(server) {
server.httpServer?.once('listening', () => {fs.mkdirSync(path.dirname(marker), {recursive:true}); fs.writeFileSync(marker,'http://127.0.0.1:5174');});
server.httpServer?.once('listening', () => {fs.mkdirSync(path.dirname(marker), {recursive:true}); fs.writeFileSync(marker,'http://hn.loc/__hn_vite');});
server.httpServer?.once('close', () => {if(fs.existsSync(marker))fs.unlinkSync(marker);});
}
}, vue({template:{transformAssetUrls:{includeAbsolute:false}}})],
@ -31,6 +32,6 @@ export default defineConfig(({mode}) => ({
css:{preprocessorOptions:{scss:{additionalData:'@use "@/assets/styles/base/variables.scss" as *;'}}},
optimizeDeps:{include:['axios','vue','vue-router','pinia','vue-i18n']},
build:{outDir:path.join(backend,'theme/EZ/assets'),assetsDir:'chunks',manifest:true,emptyOutDir:true,sourcemap:false},
server:{host:'127.0.0.1',port:5174,strictPort:true,origin:'http://127.0.0.1:5174',cors:{origin:/^http:\/\/(127\.0\.0\.1|localhost|hn\.loc)(:\d+)?$/},
fs:{allow:[path.resolve(root,'..')],deny:['**/.env','**/.env.*','**/.git','**/.git/**','**/.npmrc']},proxy:{'/api':'http://127.0.0.1:8591','/brand':'http://127.0.0.1:8591'}}
server:{host:'127.0.0.1',port:5184,strictPort:true,origin:'http://hn.loc',allowedHosts:['hn.loc'],hmr:{host:'hn.loc',clientPort:80},cors:{origin:/^http:\/\/(127\.0\.0\.1|localhost|hn\.loc)(:\d+)?$/},
fs:{allow:[path.resolve(root,'..')],deny:['**/.env','**/.env.*','**/.git','**/.git/**','**/.npmrc']},proxy:{'/api':'http://127.0.0.1:8592','/brand':'http://127.0.0.1:8592'}}
}));

View File

@ -5,7 +5,14 @@
"scripts": {
"dev:hn": "vite --config hn/vite.config.mjs",
"build:hn": "vite build --config hn/vite.config.mjs",
"test:hn": "node --test hn/tests/*.test.mjs"
"test:hn": "node --test hn/tests/*.test.mjs",
"dev:hardnerd": "vite --config hardnerd/vite.config.mjs",
"build:hardnerd": "vite build --config hardnerd/vite.config.mjs",
"test:hardnerd": "node --test hardnerd/tests/*.test.mjs",
"dev:design": "npm run dev:tpl-a",
"build:design": "npm run build:tpl-a",
"dev:tpl-a": "npm --prefix tpl-a run dev",
"build:tpl-a": "npm --prefix tpl-a run build"
},
"keywords": [],
"author": "",

View File

@ -1,16 +1,18 @@
<?php
// Run only after building EZ, within the hn deployment checkout.
// Run only after building the selected hn theme, within the hn deployment checkout.
require __DIR__.'/../../vendor/autoload.php';
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
if (!App\Support\SiteProfile::isHn()) {
throw new RuntimeException('EZ asset publication requires SITE_PROFILE=hn.');
throw new RuntimeException('hn asset publication requires SITE_PROFILE=hn.');
}
if (!is_file(base_path('theme/EZ/assets/.vite/manifest.json'))) {
throw new RuntimeException('Build EZ first: cd frontends && npm ci && npm run build:hn');
$theme = config('site.theme', 'EZ');
$build = $theme === 'EZ' ? 'build:hn' : 'build:hardnerd';
if (!is_file(base_path('theme/'.$theme.'/assets/.vite/manifest.json'))) {
throw new RuntimeException('Build '.$theme.' first: cd frontends && npm ci && npm run '.$build);
}
if (!Illuminate\Support\Facades\File::copyDirectory(base_path('theme/EZ'), public_path('theme/EZ'))) {
throw new RuntimeException('Failed to publish EZ assets.');
if (!Illuminate\Support\Facades\File::copyDirectory(base_path('theme/'.$theme), public_path('theme/'.$theme))) {
throw new RuntimeException('Failed to publish '.$theme.' assets.');
}
echo "EZ assets published for hn.\n";
echo $theme." assets published for hn.\n";

View File

@ -135,6 +135,39 @@ class SiteProfileTest extends TestCase
$this->getJson($url)->assertForbidden();
}
public function test_ez_is_available_as_an_explicit_hn_comparison_theme(): void
{
config(['site.profile'=>'hn','site.theme'=>'EZ']);
$this->assertSame('EZ', SiteProfile::publicSettings()['frontend_theme']);
SiteProfile::assertSettingsWritable(['frontend_theme'=>'EZ']);
}
public function test_custom_theme_never_uses_dev_assets_for_a_public_hostname(): void
{
try {
$assets = \App\Support\HardnerdAssets::scripts(\Illuminate\Http\Request::create('https://hardnerd.net/'));
foreach ($assets['scripts'] as $script) $this->assertStringStartsWith('/theme/Hardnerd/assets/', $script);
} catch (\RuntimeException $exception) {
$this->assertStringContainsString('Hardnerd assets missing', $exception->getMessage());
}
}
public function test_hn_rejects_an_unknown_theme_selector(): void
{
$_ENV['SITE_PROFILE']=$_SERVER['SITE_PROFILE']='hn';
$_ENV['SITE_THEME']=$_SERVER['SITE_THEME']='Stellar';
$this->expectException(\InvalidArgumentException::class);
require base_path('config/site.php');
}
public function test_dpl_does_not_apply_the_hn_theme_selector(): void
{
$_ENV['SITE_PROFILE']=$_SERVER['SITE_PROFILE']='dpl';
$_ENV['SITE_THEME']=$_SERVER['SITE_THEME']='Stellar';
$this->assertSame('dpl',(require base_path('config/site.php'))['profile']);
$this->assertSame([], SiteProfile::publicSettings());
}
public function test_invalid_selector_fails_instead_of_silently_selecting_dpl(): void
{
$old = $_ENV['SITE_PROFILE'] ?? null;

2
theme/.gitignore vendored
View File

@ -4,3 +4,5 @@
!.gitignore
!EZ
!Hardnerd

1
theme/Hardnerd/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/assets/

View File

@ -0,0 +1,6 @@
{
"name": "Hardnerd",
"description": "hardnerd 定制主题 · 独立设计与前台源码",
"version": "1.0.0",
"configs": []
}

View File

@ -0,0 +1,13 @@
@php($assets = \App\Support\HardnerdAssets::scripts(request()))
<!doctype html><html lang="zh-CN"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title }}</title><meta name="description" content="{{ $description }}"><link rel="icon" href="{{ $logo }}">
<script>window.settings={!! json_encode(['title'=>$title,'description'=>$description,'logo'=>$logo],JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT) !!};</script>
@foreach ($assets['styles'] as $style)
<link rel="stylesheet" href="{{ $style }}">
@endforeach
</head><body><div id="app"></div>
@foreach ($assets['scripts'] as $script)
<script type="module" src="{{ $script }}"></script>
@endforeach
</body></html>