-> 升级单个
+ * /admin/upgrade/applyall -> 一键全部升级
+ * /admin/upgrade/init -> 基础数据/表结构初始化(POST,仅超管)
+ */
+class UpgradeController extends Controller
+{
+ protected $layout = 'layouts/admin';
+
+ private function dir(): string
+ {
+ return BASE_PATH . '/install/upgrades';
+ }
+
+ public function index()
+ {
+ admin_required();
+ if (Db::driver() !== 'mysql') {
+ return $this->view('admin/upgrade', ['driver' => 'file']);
+ }
+ $this->requireSuper();
+
+ Installer::ensureUpgradeLog();
+ $files = $this->scan();
+ $applied = $this->appliedMap();
+
+ $list = [];
+ foreach ($files as $f) {
+ $name = basename($f);
+ $hash = md5_file($f);
+ $state = !isset($applied[$name]) ? 'pending'
+ : ($applied[$name] !== $hash ? 'changed' : 'done');
+ $list[] = [
+ 'name' => $name,
+ 'size' => filesize($f),
+ 'mtime' => filemtime($f),
+ 'state' => $state,
+ ];
+ }
+ // 排序:待升级 / 已变更 在前,已应用在后
+ $rank = ['pending' => 0, 'changed' => 1, 'done' => 2];
+ usort($list, fn($a, $b) => ($rank[$a['state']] ?? 9) - ($rank[$b['state']] ?? 9));
+
+ $pending = count(array_filter($list, fn($x) => $x['state'] !== 'done'));
+ $history = Db::query("SELECT * FROM db_upgrades ORDER BY applied_at DESC, id DESC LIMIT 50")->fetchAll();
+
+ return $this->view('admin/upgrade', [
+ 'driver' => 'mysql',
+ 'list' => $list,
+ 'pending' => $pending,
+ 'history' => $history,
+ ]);
+ }
+
+ /** 升级单个升级包 */
+ public function apply($file = null)
+ {
+ admin_required();
+ $this->requireSuper();
+ $file = basename((string)$file);
+ $path = $this->dir() . '/' . $file;
+ if (!is_file($path) || !preg_match('/\.sql$/i', $file)) {
+ $this->flash('升级包不存在', 'err');
+ $this->redirect('admin/upgrade');
+ return '';
+ }
+ try {
+ Installer::applySqlFile($path);
+ Installer::ensureUpgradeLog();
+ Db::query(
+ "INSERT INTO db_upgrades (file, hash, applied_at, applied_by, note) VALUES (?, ?, ?, ?, ?)",
+ [$file, md5_file($path), date('Y-m-d H:i:s'), ($_SESSION['admin']['username'] ?? 'admin'), '']
+ );
+ $this->flash("已升级:{$file}", 'ok');
+ } catch (\Throwable $e) {
+ $this->flash('升级失败:' . $e->getMessage(), 'err');
+ }
+ $this->redirect('admin/upgrade');
+ return '';
+ }
+
+ /** 一键升级全部待处理 */
+ public function applyAll()
+ {
+ admin_required();
+ $this->requireSuper();
+ $files = $this->scan();
+ $applied = $this->appliedMap();
+ $done = 0;
+ foreach ($files as $f) {
+ $name = basename($f);
+ $hash = md5_file($f);
+ if (isset($applied[$name]) && $applied[$name] === $hash) continue;
+ try {
+ Installer::applySqlFile($f);
+ Installer::ensureUpgradeLog();
+ Db::query(
+ "INSERT INTO db_upgrades (file, hash, applied_at, applied_by, note) VALUES (?, ?, ?, ?, ?)",
+ [$name, $hash, date('Y-m-d H:i:s'), ($_SESSION['admin']['username'] ?? 'admin'), '']
+ );
+ $done++;
+ } catch (\Throwable $e) {
+ $this->flash('升级失败:' . e($e->getMessage()), 'err');
+ $this->redirect('admin/upgrade');
+ return '';
+ }
+ }
+ $this->flash($done > 0 ? "已批量升级 {$done} 个升级包" : '没有需要升级的包', $done > 0 ? 'ok' : 'err');
+ $this->redirect('admin/upgrade');
+ return '';
+ }
+
+ /** 基础数据/表结构初始化(保留旧版能力:补齐新模块表与种子) */
+ public function init()
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('admin/upgrade'); return ''; }
+ admin_required();
+ $this->requireSuper();
+ if (!csrf_check()) {
+ $this->flash('表单已过期,请刷新后重试', 'err');
+ $this->redirect('admin/upgrade');
+ return '';
+ }
+ $msgs = Installer::upgrade();
+ $this->flash('基础数据升级完成 ✓ ' . implode(';', $msgs), 'ok');
+ $this->redirect('admin/upgrade');
+ return '';
+ }
+
+ /* ---------------- 工具 ---------------- */
+
+ private function requireSuper(): void
+ {
+ if (!is_admin() || admin_role() !== 'super_admin') {
+ App::forbidden('仅超级管理员可执行数据库升级');
+ }
+ }
+
+ private function scan(): array
+ {
+ $d = $this->dir();
+ return is_dir($d) ? (glob($d . '/*.sql') ?: []) : [];
+ }
+
+ private function appliedMap(): array
+ {
+ try {
+ return Db::query("SELECT file, hash FROM db_upgrades")->fetchAll(\PDO::FETCH_KEY_PAIR);
+ } catch (\Throwable $e) {
+ return [];
+ }
+ }
+}
diff --git a/app/Controllers/Admin/UserController.php b/app/Controllers/Admin/UserController.php
new file mode 100644
index 0000000..3b5cbd8
--- /dev/null
+++ b/app/Controllers/Admin/UserController.php
@@ -0,0 +1,193 @@
+model()->all();
+ return $this->view('admin/users', ['users' => $users, 'error' => '', 'ok' => '']);
+ }
+
+ public function create()
+ {
+ $p = $this->defaultPermsPair();
+ return $this->view('admin/user_form', [
+ 'user' => null,
+ 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'],
+ 'error' => '', 'ok' => '',
+ ]);
+ }
+
+ public function store()
+ {
+ if (!csrf_check()) {
+ $p = $this->defaultPermsPair();
+ return $this->view('admin/user_form', ['user' => null, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '表单已过期,请重试', 'ok' => '']);
+ }
+ $username = trim($this->post('username'));
+ $name = trim($this->post('name'));
+ $role = $this->post('role');
+ $password = $this->post('password');
+ $status = $this->post('status') ? 1 : 0;
+
+ $err = $this->validate($username, $role, $password);
+ if ($err) { $p = $this->defaultPermsPair(); return $this->view('admin/user_form', ['user' => null, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => $err, 'ok' => '']); }
+ if ($this->model()->byUsername($username)) {
+ $p = $this->defaultPermsPair();
+ return $this->view('admin/user_form', ['user' => null, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '该账号已存在', 'ok' => '']);
+ }
+ $this->model()->insert([
+ 'username' => $username,
+ 'name' => $name ?: $username,
+ 'password' => password_hash($password, PASSWORD_DEFAULT),
+ 'role' => $role,
+ 'crm_role' => $this->post('crm_role') ?: 'none',
+ 'psi_role' => $this->post('psi_role') ?: 'none',
+ 'crm_perms' => json_encode($this->collectPerms('crm', (array)($this->post('crm_pages') ?: [])), JSON_UNESCAPED_UNICODE),
+ 'psi_perms' => json_encode($this->collectPerms('psi', (array)($this->post('psi_pages') ?: [])), JSON_UNESCAPED_UNICODE),
+ 'status' => $status,
+ 'created_at' => date('Y-m-d'),
+ ]);
+ $this->redirect('admin/users');
+ }
+
+ public function edit($id)
+ {
+ $user = $this->model()->find($id);
+ if (!$user) return $this->redirect('admin/users');
+ $p = $this->userPerms($user);
+ return $this->view('admin/user_form', ['user' => $user, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '', 'ok' => '']);
+ }
+
+ public function update($id)
+ {
+ $user = $this->model()->find($id);
+ if (!$user) return $this->redirect('admin/users');
+ if (!csrf_check()) {
+ $p = $this->userPerms($user);
+ return $this->view('admin/user_form', ['user' => $user, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '表单已过期,请重试', 'ok' => '']);
+ }
+ $username = trim($this->post('username'));
+ $name = trim($this->post('name'));
+ $role = $this->post('role');
+ $password = $this->post('password');
+ $status = $this->post('status') ? 1 : 0;
+
+ $err = $this->validate($username, $role, $password, true);
+ if ($err) { $p = $this->userPerms($user); return $this->view('admin/user_form', ['user' => $user, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => $err, 'ok' => '']); }
+ if ($username !== $user['username'] && $this->model()->byUsername($username)) {
+ $p = $this->userPerms($user);
+ return $this->view('admin/user_form', ['user' => $user, 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'], 'error' => '该账号已存在', 'ok' => '']);
+ }
+ $data = [
+ 'username' => $username,
+ 'name' => $name ?: $username,
+ 'role' => $role,
+ 'crm_role' => $this->post('crm_role') ?: 'none',
+ 'psi_role' => $this->post('psi_role') ?: 'none',
+ 'crm_perms' => json_encode($this->collectPerms('crm', (array)($this->post('crm_pages') ?: [])), JSON_UNESCAPED_UNICODE),
+ 'psi_perms' => json_encode($this->collectPerms('psi', (array)($this->post('psi_pages') ?: [])), JSON_UNESCAPED_UNICODE),
+ 'status' => $status,
+ ];
+ if ($password !== '') {
+ $data['password'] = password_hash($password, PASSWORD_DEFAULT);
+ }
+ $this->model()->update($id, $data);
+ $this->redirect('admin/users');
+ }
+
+ public function destroy($id)
+ {
+ if (admin_uid() == $id) return $this->redirect('admin/users'); // 不能删除自己
+ $this->model()->delete($id);
+ $this->redirect('admin/users');
+ }
+
+ /** 超级管理员重置他人密码(自己重置走修改密码页) */
+ public function reset($id)
+ {
+ $user = $this->model()->find($id);
+ if (!$user) return $this->redirect('admin/users');
+ if (admin_uid() == $id) return $this->redirect('admin/password');
+ $new = $this->genPassword();
+ $this->model()->update($id, ['password' => password_hash($new, PASSWORD_DEFAULT)]);
+ $p = $this->userPerms($user);
+ return $this->view('admin/user_form', [
+ 'user' => $user,
+ 'crmPerms' => $p['crm'], 'psiPerms' => $p['psi'],
+ 'error' => '',
+ 'ok' => '已重置密码为:' . e($new) . '(请尽快通知对方修改)',
+ ]);
+ }
+
+ private function defaultPermsPair(): array
+ {
+ return ['crm' => $this->defaultPerms('crm'), 'psi' => $this->defaultPerms('psi')];
+ }
+
+ /** 从用户记录解码已保存的页面权限(无记录则默认全部可见) */
+ private function userPerms($user): array
+ {
+ $crm = $this->defaultPerms('crm');
+ $psi = $this->defaultPerms('psi');
+ if (!empty($user['crm_perms'])) { $d = @json_decode($user['crm_perms'], true); if (is_array($d)) $crm = $d; }
+ if (!empty($user['psi_perms'])) { $d = @json_decode($user['psi_perms'], true); if (is_array($d)) $psi = $d; }
+ return ['crm' => $crm, 'psi' => $psi];
+ }
+
+ private function defaultPerms(string $sys): array
+ {
+ $out = [];
+ foreach (\subsys_pages($sys) as $p) { if ($p !== 'dashboard') $out[$p] = true; }
+ return $out;
+ }
+
+ private function collectPerms(string $sys, array $checked): array
+ {
+ $out = [];
+ foreach (\subsys_pages($sys) as $p) { if ($p === 'dashboard') continue; $out[$p] = in_array($p, $checked, true); }
+ return $out;
+ }
+
+ private function validate($username, $role, $password, $isEdit = false): string
+ {
+ if ($username === '' || !preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) {
+ return '账号须为 3-30 位字母/数字/下划线';
+ }
+ if (!in_array($role, ['super_admin', 'admin', 'user', 'none'], true)) {
+ return '角色不合法';
+ }
+ if (!$isEdit && strlen($password) < 6) {
+ return '密码至少 6 位';
+ }
+ return '';
+ }
+
+ private function genPassword(): string
+ {
+ $chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+ $s = '';
+ for ($i = 0; $i < 10; $i++) {
+ $s .= $chars[random_int(0, strlen($chars) - 1)];
+ }
+ return $s;
+ }
+}
diff --git a/app/Controllers/CRM/ContactsController.php b/app/Controllers/CRM/ContactsController.php
new file mode 100644
index 0000000..1c0ac60
--- /dev/null
+++ b/app/Controllers/CRM/ContactsController.php
@@ -0,0 +1,123 @@
+ 'dashboard', 'label' => '仪表盘', 'icon' => '📊', 'url' => 'CRM'],
+ ['k' => 'customers', 'label' => '客户管理', 'icon' => '🤝', 'url' => 'CRM/customers'],
+ ['k' => 'leads', 'label' => '商机线索', 'icon' => '💡', 'url' => 'CRM/leads'],
+ ['k' => 'followups', 'label' => '跟进记录', 'icon' => '📞', 'url' => 'CRM/followups'],
+ ['k' => 'contacts', 'label' => '客户联系人', 'icon' => '👥', 'url' => 'CRM/contacts'],
+ ];
+ }
+
+ /** 列表:/CRM/contacts(全部)或 /CRM/contacts?customer_id=ID(某客户) */
+ public function index($customerId = null)
+ {
+ $customerId = $customerId ? (int)$customerId : (int)($_GET['customer_id'] ?? 0);
+ $customer = null;
+ if ($customerId) {
+ $customer = (new Customer())->find($customerId);
+ $contacts = (new Contact())->where('customer_id', $customerId);
+ } else {
+ $contacts = $this->allWithCustomer();
+ }
+ return $this->renderSubsys('crm', 'crm/contacts', [
+ 'contacts' => $contacts,
+ 'customer' => $customer,
+ ], $this->nav(), 'contacts');
+ }
+
+ public function create()
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ $preId = (int)($_GET['customer_id'] ?? 0);
+ $customers = (new Customer())->all();
+ return $this->renderSubsys('crm', 'crm/contact_form', [
+ 'contact' => null,
+ 'customers' => $customers,
+ 'preId' => $preId,
+ ], $this->nav(), 'contacts');
+ }
+
+ public function store()
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ if (!csrf_check()) return $this->redirect('CRM/contacts');
+ $cid = (int)$this->post('customer_id');
+ $data = $this->collect();
+ (new Contact())->insert($data);
+ return $this->redirect($cid ? "CRM/contacts?customer_id={$cid}" : 'CRM/contacts');
+ }
+
+ public function edit($id)
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ $contact = (new Contact())->find($id);
+ if (!$contact) return $this->redirect('CRM/contacts');
+ $customers = (new Customer())->all();
+ return $this->renderSubsys('crm', 'crm/contact_form', [
+ 'contact' => $contact,
+ 'customers' => $customers,
+ 'preId' => (int)($contact['customer_id'] ?? 0),
+ ], $this->nav(), 'contacts');
+ }
+
+ public function update($id)
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ if (!csrf_check()) return $this->redirect('CRM/contacts');
+ $contact = (new Contact())->find($id);
+ if (!$contact) return $this->redirect('CRM/contacts');
+ $cid = (int)$this->post('customer_id');
+ (new Contact())->update($id, $this->collect());
+ return $this->redirect($cid ? "CRM/contacts?customer_id={$cid}" : 'CRM/contacts');
+ }
+
+ public function destroy($id)
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ $contact = (new Contact())->find($id);
+ $cid = $contact ? (int)($contact['customer_id'] ?? 0) : 0;
+ (new Contact())->delete($id);
+ return $this->redirect($cid ? "CRM/contacts?customer_id={$cid}" : 'CRM/contacts');
+ }
+
+ private function collect(): array
+ {
+ return [
+ 'customer_id' => (int)$this->post('customer_id'),
+ 'name' => trim($this->post('name')),
+ 'title' => trim($this->post('title')),
+ 'phone' => trim($this->post('phone')),
+ 'email' => trim($this->post('email')),
+ 'wechat' => trim($this->post('wechat')),
+ 'is_primary' => $this->post('is_primary') ? 1 : 0,
+ 'remark' => trim($this->post('remark')),
+ 'created_at' => date('Y-m-d'),
+ ];
+ }
+
+ /** 全部联系人 + 客户名称(用于「全部联系人」视图) */
+ private function allWithCustomer(): array
+ {
+ try {
+ $rows = \Core\Db::query(
+ "SELECT c.*, cu.name AS customer_name FROM crm_contacts c
+ LEFT JOIN crm_customers cu ON cu.id=c.customer_id
+ ORDER BY c.customer_id, c.id"
+ )->fetchAll();
+ return $rows;
+ } catch (\Throwable $e) {
+ return (new Contact())->all();
+ }
+ }
+}
diff --git a/app/Controllers/CRM/CustomersController.php b/app/Controllers/CRM/CustomersController.php
new file mode 100644
index 0000000..c01b9cb
--- /dev/null
+++ b/app/Controllers/CRM/CustomersController.php
@@ -0,0 +1,103 @@
+ 'dashboard', 'label' => '仪表盘', 'icon' => '📊', 'url' => 'CRM'],
+ ['k' => 'customers', 'label' => '客户管理', 'icon' => '🤝', 'url' => 'CRM/customers'],
+ ['k' => 'leads', 'label' => '商机线索', 'icon' => '💡', 'url' => 'CRM/leads'],
+ ['k' => 'followups', 'label' => '跟进记录', 'icon' => '📞', 'url' => 'CRM/followups'],
+ ];
+ }
+
+ public function index()
+ {
+ $customers = (new Customer())->all();
+ $contactCounts = $this->contactCounts();
+ return $this->renderSubsys('crm', 'crm/customers', [
+ 'customers' => $customers,
+ 'contactCounts' => $contactCounts,
+ ], $this->nav(), 'customers');
+ }
+
+ /** 每个客户的联系人数量(键=customer_id) */
+ private function contactCounts(): array
+ {
+ $out = [];
+ try {
+ $rows = \Core\Db::query("SELECT customer_id, COUNT(*) AS n FROM crm_contacts GROUP BY customer_id");
+ foreach ($rows->fetchAll() as $r) { $out[(int)$r['customer_id']] = (int)$r['n']; }
+ } catch (\Throwable $e) {}
+ return $out;
+ }
+
+ public function create()
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ return $this->renderSubsys('crm', 'crm/customer_form', ['customer' => null], $this->nav(), 'customers');
+ }
+
+ public function store()
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ if (!csrf_check()) { return $this->renderSubsys('crm', 'crm/customer_form', ['customer' => null], $this->nav(), 'customers'); }
+ $data = $this->collect();
+ (new Customer())->insert($data);
+ return $this->redirect('CRM/customers');
+ }
+
+ public function edit($id)
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ $customer = (new Customer())->find($id);
+ if (!$customer) return $this->redirect('CRM/customers');
+ return $this->renderSubsys('crm', 'crm/customer_form', ['customer' => $customer], $this->nav(), 'customers');
+ }
+
+ public function update($id)
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ if (!csrf_check()) return $this->redirect('CRM/customers');
+ $customer = (new Customer())->find($id);
+ if (!$customer) return $this->redirect('CRM/customers');
+ (new Customer())->update($id, $this->collect());
+ return $this->redirect('CRM/customers');
+ }
+
+ public function destroy($id)
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ (new Customer())->delete($id);
+ return $this->redirect('CRM/customers');
+ }
+
+ private function collect(): array
+ {
+ return [
+ 'name' => trim($this->post('name')),
+ 'company' => trim($this->post('company')),
+ 'contact' => trim($this->post('contact')),
+ 'phone' => trim($this->post('phone')),
+ 'email' => trim($this->post('email')),
+ 'country' => trim($this->post('country')),
+ 'type' => $this->post('type') ?: 'brand',
+ 'source' => trim($this->post('source')),
+ 'level' => $this->post('level') ?: 'C',
+ 'customer_no' => trim($this->post('customer_no')),
+ 'industry' => trim($this->post('industry')),
+ 'region' => trim($this->post('region')),
+ 'credit_limit'=> (float)$this->post('credit_limit'),
+ 'status' => $this->post('status') ?: 'lead',
+ 'remark' => trim($this->post('remark')),
+ 'owner' => trim($this->post('owner')) ?: ($_SESSION['admin_name'] ?? ''),
+ 'created_at' => date('Y-m-d'),
+ ];
+ }
+}
diff --git a/app/Controllers/CRM/DashboardController.php b/app/Controllers/CRM/DashboardController.php
new file mode 100644
index 0000000..34d8a9b
--- /dev/null
+++ b/app/Controllers/CRM/DashboardController.php
@@ -0,0 +1,130 @@
+ 'dashboard', 'label' => '仪表盘', 'icon' => '📊', 'url' => 'CRM'],
+ ['k' => 'customers', 'label' => '客户管理', 'icon' => '🤝', 'url' => 'CRM/customers'],
+ ['k' => 'leads', 'label' => '商机线索', 'icon' => '💡', 'url' => 'CRM/leads'],
+ ['k' => 'followups', 'label' => '跟进记录', 'icon' => '📞', 'url' => 'CRM/followups'],
+ ['k' => 'contacts', 'label' => '客户联系人', 'icon' => '👥', 'url' => 'CRM/contacts'],
+ ];
+ }
+
+ /** 统一子路由入口 */
+ public function dispatch(array $s)
+ {
+ $res = $s[0] ?? 'dashboard';
+ $action = $s[1] ?? '';
+ $id = $s[2] ?? null;
+
+ // 仪表盘始终可进
+ if ($res === 'dashboard') {
+ return $this->dashboard();
+ }
+
+ // 用户管理(仅该系统管理员):统一管理本系统用户及其页面权限
+ if ($res === 'users') {
+ if (!\subsys_admin('crm')) {
+ \Core\App::forbidden('需要 CRM 管理员权限');
+ return;
+ }
+ $uc = new \App\Controllers\CRM\UsersController();
+ if ($action === '' || $action === 'index') return $uc->index();
+ if ($action === 'create') return $uc->create();
+ if ($action === 'store') return $uc->store();
+ if ($action === 'edit') return $uc->edit($id);
+ if ($action === 'update') return $uc->update($id);
+ if ($action === 'destroy') return $uc->destroy($id);
+ if ($action === 'reset') return $uc->reset($id);
+ \Core\App::notFound('未知操作: ' . $action);
+ return;
+ }
+
+ $map = [
+ 'customers' => 'CustomersController',
+ 'leads' => 'LeadsController',
+ 'followups' => 'FollowUpsController',
+ 'contacts' => 'ContactsController',
+ ];
+ if (!isset($map[$res])) {
+ \Core\App::notFound('未知页面: ' . $res);
+ return;
+ }
+ // 页面级权限:仪表盘始终可进,其余页面按分系统「页面可见权限」拦截
+ if (!\subsys_page_can('crm', $res)) {
+ \Core\App::forbidden('您没有访问该页面的权限');
+ return;
+ }
+ // 资源子操作路由:/CRM/{resource}[/{action}[/{id}]]
+ $method = $this->resolveSubsysAction($action);
+ if ($method === null) {
+ \Core\App::notFound('未知操作: ' . $action);
+ return;
+ }
+ $class = 'App\\Controllers\\CRM\\' . $map[$res];
+ $instance = new $class();
+ if (!method_exists($instance, $method)) {
+ \Core\App::notFound('操作不存在: ' . $method);
+ return;
+ }
+ return $instance->$method($id);
+ }
+
+ /** 将 URL 动作段解析为控制器方法名;不支持的动作返回 null */
+ private function resolveSubsysAction(string $action): ?string
+ {
+ $verbs = [
+ '' => 'index',
+ 'index' => 'index',
+ 'create' => 'create',
+ 'store' => 'store',
+ 'edit' => 'edit',
+ 'update' => 'update',
+ 'destroy' => 'destroy',
+ ];
+ return $verbs[$action] ?? null;
+ }
+
+ public function dashboard()
+ {
+ $customers = (new Customer())->all();
+ $leads = (new Lead())->all();
+ $follows = (new FollowUp())->all();
+
+ $stageCount = [];
+ foreach ($leads as $l) {
+ $stageCount[$l['stage'] ?? 'new'] = ($stageCount[$l['stage'] ?? 'new'] ?? 0) + 1;
+ }
+ $amountTotal = array_sum(array_map(fn($l) => (float)($l['amount'] ?? 0), $leads));
+ $typeCount = [];
+ foreach ($customers as $c) {
+ $typeCount[$c['type'] ?? 'trade'] = ($typeCount[$c['type'] ?? 'trade'] ?? 0) + 1;
+ }
+ $recent = array_slice(array_reverse($follows), 0, 8);
+ $recentCustomers = array_slice(array_reverse($customers), 0, 8);
+
+ return $this->renderSubsys('crm', 'crm/dashboard', [
+ 'customerTotal' => count($customers),
+ 'leadTotal' => count($leads),
+ 'followTotal' => count($follows),
+ 'amountTotal' => $amountTotal,
+ 'stageCount' => $stageCount,
+ 'typeCount' => $typeCount,
+ 'recent' => $recent,
+ 'recentCustomers'=> $recentCustomers,
+ ], $this->nav('dashboard'), 'dashboard');
+ }
+}
diff --git a/app/Controllers/CRM/FollowUpsController.php b/app/Controllers/CRM/FollowUpsController.php
new file mode 100644
index 0000000..4f3cbb2
--- /dev/null
+++ b/app/Controllers/CRM/FollowUpsController.php
@@ -0,0 +1,84 @@
+ 'dashboard', 'label' => '仪表盘', 'icon' => '📊', 'url' => 'CRM'],
+ ['k' => 'customers', 'label' => '客户管理', 'icon' => '🤝', 'url' => 'CRM/customers'],
+ ['k' => 'leads', 'label' => '商机线索', 'icon' => '💡', 'url' => 'CRM/leads'],
+ ['k' => 'followups', 'label' => '跟进记录', 'icon' => '📞', 'url' => 'CRM/followups'],
+ ];
+ }
+
+ public function index()
+ {
+ $follows = (new FollowUp())->all();
+ $customers = (new Customer())->all();
+ $cmap = [];
+ foreach ($customers as $c) { $cmap[$c['id']] = $c['name']; }
+ return $this->renderSubsys('crm', 'crm/followups', ['follows' => $follows, 'cmap' => $cmap], $this->nav(), 'followups');
+ }
+
+ public function create()
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ $customers = (new Customer())->all();
+ return $this->renderSubsys('crm', 'crm/followup_form', ['follow' => null, 'customers' => $customers], $this->nav(), 'followups');
+ }
+
+ public function store()
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ if (!csrf_check()) return $this->redirect('CRM/followups');
+ (new FollowUp())->insert($this->collect());
+ return $this->redirect('CRM/followups');
+ }
+
+ public function edit($id)
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ $follow = (new FollowUp())->find($id);
+ if (!$follow) return $this->redirect('CRM/followups');
+ $customers = (new Customer())->all();
+ return $this->renderSubsys('crm', 'crm/followup_form', ['follow' => $follow, 'customers' => $customers], $this->nav(), 'followups');
+ }
+
+ public function update($id)
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ if (!csrf_check()) return $this->redirect('CRM/followups');
+ $follow = (new FollowUp())->find($id);
+ if (!$follow) return $this->redirect('CRM/followups');
+ (new FollowUp())->update($id, $this->collect());
+ return $this->redirect('CRM/followups');
+ }
+
+ public function destroy($id)
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ (new FollowUp())->delete($id);
+ return $this->redirect('CRM/followups');
+ }
+
+ private function collect(): array
+ {
+ return [
+ 'customer_id' => (int)$this->post('customer_id'),
+ 'lead_id' => (int)$this->post('lead_id'),
+ 'content' => trim($this->post('content')),
+ 'next_at' => trim($this->post('next_at')),
+ 'way' => trim($this->post('way')),
+ 'result' => trim($this->post('result')),
+ 'owner' => trim($this->post('owner')) ?: ($_SESSION['admin_name'] ?? ''),
+ 'created_at' => date('Y-m-d'),
+ ];
+ }
+}
diff --git a/app/Controllers/CRM/LeadsController.php b/app/Controllers/CRM/LeadsController.php
new file mode 100644
index 0000000..75178fb
--- /dev/null
+++ b/app/Controllers/CRM/LeadsController.php
@@ -0,0 +1,86 @@
+ 'dashboard', 'label' => '仪表盘', 'icon' => '📊', 'url' => 'CRM'],
+ ['k' => 'customers', 'label' => '客户管理', 'icon' => '🤝', 'url' => 'CRM/customers'],
+ ['k' => 'leads', 'label' => '商机线索', 'icon' => '💡', 'url' => 'CRM/leads'],
+ ['k' => 'followups', 'label' => '跟进记录', 'icon' => '📞', 'url' => 'CRM/followups'],
+ ];
+ }
+
+ public function index()
+ {
+ $leads = (new Lead())->all();
+ $customers = (new Customer())->all();
+ $cmap = [];
+ foreach ($customers as $c) { $cmap[$c['id']] = $c['name']; }
+ return $this->renderSubsys('crm', 'crm/leads', ['leads' => $leads, 'cmap' => $cmap], $this->nav(), 'leads');
+ }
+
+ public function create()
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ $customers = (new Customer())->all();
+ return $this->renderSubsys('crm', 'crm/lead_form', ['lead' => null, 'customers' => $customers], $this->nav(), 'leads');
+ }
+
+ public function store()
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ if (!csrf_check()) return $this->redirect('CRM/leads');
+ (new Lead())->insert($this->collect());
+ return $this->redirect('CRM/leads');
+ }
+
+ public function edit($id)
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ $lead = (new Lead())->find($id);
+ if (!$lead) return $this->redirect('CRM/leads');
+ $customers = (new Customer())->all();
+ return $this->renderSubsys('crm', 'crm/lead_form', ['lead' => $lead, 'customers' => $customers], $this->nav(), 'leads');
+ }
+
+ public function update($id)
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ if (!csrf_check()) return $this->redirect('CRM/leads');
+ $lead = (new Lead())->find($id);
+ if (!$lead) return $this->redirect('CRM/leads');
+ (new Lead())->update($id, $this->collect());
+ return $this->redirect('CRM/leads');
+ }
+
+ public function destroy($id)
+ {
+ subsys_admin('crm') or \Core\App::forbidden('需要 CRM 管理员权限');
+ (new Lead())->delete($id);
+ return $this->redirect('CRM/leads');
+ }
+
+ private function collect(): array
+ {
+ return [
+ 'customer_id' => (int)$this->post('customer_id'),
+ 'title' => trim($this->post('title')),
+ 'amount' => (float)$this->post('amount'),
+ 'stage' => $this->post('stage') ?: 'new',
+ 'expected_close' => trim($this->post('expected_close')),
+ 'source' => trim($this->post('source')),
+ 'probability' => (int)$this->post('probability'),
+ 'owner' => trim($this->post('owner')) ?: ($_SESSION['admin_name'] ?? ''),
+ 'remark' => trim($this->post('remark')),
+ 'created_at' => date('Y-m-d'),
+ ];
+ }
+}
diff --git a/app/Controllers/CRM/UsersController.php b/app/Controllers/CRM/UsersController.php
new file mode 100644
index 0000000..010ed5c
--- /dev/null
+++ b/app/Controllers/CRM/UsersController.php
@@ -0,0 +1,12 @@
+ '客户案例',
+ 'description' => '酷冰甲降温服客户案例展示,覆盖消防、电力、钢铁、环卫、户外施工、车间制造等高温作业场景的真实合作项目,逐一呈现降温方案设计思路、现场使用效果与客户真实反馈,并附上适用行业与选型建议,为同类企业的高温防护升级提供可参考、可复用的实战样本,切实降低高温作业风险。',
+ 'keywords' => '降温服案例,客户案例,高温作业,降温方案,消防降温,工业应用,酷冰甲案例',
+ 'og_type' => 'website',
+ ]);
+ return $this->view('cases/index', [
+ 'pageSeo' => [
+ 'title' => $seo['title'],
+ 'description' => $seo['description'],
+ 'keywords' => $seo['keywords'],
+ 'og_type' => $seo['og_type'] ?: 'website',
+ 'og_image' => $seo['og_image'],
+ 'canonical' => $seo['canonical'],
+ 'noindex' => $seo['noindex'],
+ 'breadcrumb' => [
+ ['name' => '首页', 'url' => site_url()],
+ ['name' => '客户案例', 'url' => absolute_url()],
+ ],
+ ],
+ 'cases' => $case->published(20),
+ ]);
+ }
+
+ public function show($slug)
+ {
+ $case = new CustomerCase();
+ $c = $case->where('slug', $slug);
+ if (!$c && is_numeric($slug)) { $c = $case->find((int)$slug); }
+ if (!$c) { \Core\App::notFound(); return ''; }
+ // 浏览量 +1
+ $case->update($c['id'], ['views' => ($c['views'] ?? 0) + 1]);
+ $all = $case->published(20);
+ $idx = array_search($c, $all);
+ $prev = $idx !== false && $idx > 0 ? $all[$idx - 1] : null;
+ $next = $idx !== false && $idx < count($all) - 1 ? $all[$idx + 1] : null;
+
+ $cTitle = e($c['title'] ?? '案例详情');
+ $cSummary = mb_substr(strip_tags($c['summary'] ?? $c['body'] ?? ''), 0, 160);
+ $cImage = $c['image'] ?? '';
+ $publishedAt = $c['created_at'] ?? $c['published_at'] ?? date('Y-m-d');
+
+ // ── Article JSON-LD Schema(客户案例)────
+ $articleSchema = '';
+
+ return $this->view('cases/show', [
+ 'pageSeo' => [
+ 'title' => $cTitle,
+ 'description' => $cSummary,
+ 'og_type' => 'article',
+ 'og_image' => $cImage,
+ 'breadcrumb' => [
+ ['name' => '首页', 'url' => site_url()],
+ ['name' => '客户案例', 'url' => site_url('cases')],
+ ['name' => $c['title'] ?? '案例', 'url' => absolute_url()],
+ ],
+ 'jsonld' => $articleSchema,
+ ],
+ 'c' => $c,
+ 'prev' => $prev,
+ 'next' => $next,
+ ]);
+ }
+}
diff --git a/app/Controllers/ContactController.php b/app/Controllers/ContactController.php
new file mode 100644
index 0000000..cd3e157
--- /dev/null
+++ b/app/Controllers/ContactController.php
@@ -0,0 +1,79 @@
+post('website')) !== '') {
+ ip_rate_register($ip, 'contact', 900); // 仍计入限速窗口,避免探测
+ $sent = true; // 静默当作成功,避免机器人得知被拦截
+ } elseif (ip_rate_blocked($ip, 'contact', 5, 900)) {
+ $error = '提交过于频繁,请 15 分钟后再试。';
+ } else {
+ ip_rate_register($ip, 'contact', 900); // 真实提交尝试计入限速窗口(含后续校验失败)
+ if (!csrf_check()) {
+ $error = '表单已过期,请重试。';
+ } elseif (!captcha_check($this->post('captcha'))) {
+ $error = '验证码错误,请重新计算。';
+ } else {
+ $name = trim($this->post('name'));
+ $phone = trim($this->post('phone'));
+ $msg = trim($this->post('message'));
+ // 服务端校验:长度与联系电话格式(防垃圾/注入)
+ if (mb_strlen($name) < 2 || mb_strlen($name) > 40) {
+ $error = '请填写有效的姓名(2-40 字)。';
+ } elseif (!preg_match('/^[0-9+\-\s]{5,20}$/', $phone)) {
+ $error = '请填写有效的联系电话(5-20 位)。';
+ } elseif (mb_strlen($msg) < 5 || mb_strlen($msg) > 1000) {
+ $error = '请填写需求描述(5-1000 字)。';
+ } else {
+ $this->saveLead(compact('name', 'phone', 'msg') + ['at' => date('Y-m-d H:i:s')]);
+ $sent = true;
+ }
+ }
+ }
+ }
+ $seo = page_seo('contact', [
+ 'title' => '联系我们',
+ 'description' => '联系酷冰甲,获取降温服定制方案与专属报价。我们支持企业批量采购、LOGO刺绣、尺寸与面料定制,提供在线咨询、电话与邮件多种沟通方式。7天打样、全国发货,专业团队一对一对接您的高温防护需求,从选型到交付全程跟进,确保交付准时可靠,让合作更省心、更可靠。',
+ 'keywords' => '联系酷冰甲,降温服定制,降温服报价,降温服采购,企业定制,降温服厂家,酷冰甲联系',
+ 'og_type' => 'website',
+ ]);
+ $captcha = captcha_make();
+ return $this->view('contact/index', [
+ 'pageSeo' => [
+ 'title' => $seo['title'],
+ 'description' => $seo['description'],
+ 'keywords' => $seo['keywords'],
+ 'og_type' => $seo['og_type'] ?: 'website',
+ 'og_image' => $seo['og_image'],
+ 'canonical' => $seo['canonical'],
+ 'noindex' => $seo['noindex'],
+ 'breadcrumb' => [
+ ['name' => '首页', 'url' => site_url()],
+ ['name' => '联系我们', 'url' => absolute_url()],
+ ],
+ ],
+ 'sent' => $sent,
+ 'error' => $error,
+ 'captcha' => $captcha,
+ ]);
+ }
+
+ private function saveLead(array $data): void
+ {
+ if (Db::driver() !== 'file') return; // MySQL 模式可由后台扩展
+ $file = Db::fileDir() . '/leads.json';
+ $rows = is_file($file) ? json_decode(file_get_contents($file), true) ?: [] : [];
+ $rows[] = $data;
+ file_put_contents($file, json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+ }
+}
diff --git a/app/Controllers/Controller.php b/app/Controllers/Controller.php
new file mode 100644
index 0000000..a7e75a9
--- /dev/null
+++ b/app/Controllers/Controller.php
@@ -0,0 +1,66 @@
+layout);
+ }
+
+ protected function redirect(string $url)
+ {
+ header('Location: ' . site_url($url));
+ exit;
+ }
+
+ protected function back()
+ {
+ $this->redirect($_SERVER['HTTP_REFERER'] ?? '');
+ }
+
+ protected function json($data, int $code = 200)
+ {
+ http_response_code($code);
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode($data, JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+
+ /** 一次性会话消息(成功 ok / 错误 err),布局模板自动渲染 */
+ protected function flash(string $msg, string $type = 'err'): void
+ {
+ $_SESSION['flash'] = ['msg' => $msg, 'type' => $type];
+ }
+
+ protected function get(string $key, $default = '')
+ {
+ return $_GET[$key] ?? $default;
+ }
+
+ protected function post(string $key, $default = '')
+ {
+ return $_POST[$key] ?? $default;
+ }
+
+ /**
+ * 分系统(CRM / PSI)统一后台渲染:左侧导航 + 顶栏 + 内容区。
+ * 与 layouts/admin.php 同源规范,保证团队多系统体验一致、可维护。
+ * @param string $sys crm | psi
+ * @param string $view 视图名(如 crm/dashboard)
+ * @param array $nav 子系统导航项 [['k'=>, 'label'=>, 'icon'=>, 'url'=>]]
+ * @param string $seg 当前激活导航 key
+ */
+ protected function renderSubsys(string $sys, string $view, array $data, array $nav, string $seg): string
+ {
+ $data['_sys'] = $sys;
+ $data['_nav'] = $nav;
+ $data['_seg'] = $seg;
+ $data['_home'] = $sys; // 子系统首页路由段
+ return View::make($view, $data, 'layouts/subsys');
+ }
+}
diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php
new file mode 100644
index 0000000..4aad315
--- /dev/null
+++ b/app/Controllers/HomeController.php
@@ -0,0 +1,83 @@
+ '酷冰甲降温服官网 | 科技降温服定制·水冷循环·相变蓄冷·风冷背心·10套起订',
+ 'description' => '酷冰甲专注降温服的研发、生产与定制,提供水冷循环、相变蓄冷、风冷制冷、冰袋背心等多系列降温装备,广泛适用于消防、工业、电力、钢铁、环卫及户外高温作业场景。支持企业LOGO刺绣、尺寸与面料定制,10套起订,7天打样,全国发货,为您提供一站式高温防护解决方案。',
+ 'keywords' => '降温服,降温背心,水冷降温服,相变降温服,制冷背心,工业降温服,消防降温服,高温作业防护,降温服定制,酷冰甲',
+ 'og_type' => 'website',
+ ]);
+ // ── 首页 FAQ(可见文本 + FAQPage JSON-LD,GEO 高杠杆信号)────
+ $faqs = [
+ ['q' => '降温服是什么?它是怎么实现降温的?', 'a' => '降温服是一类为高温作业人群设计的主动或被动降温装备,主要通过三种原理散热:水冷循环(微型水泵驱动冷水在服装内管路循环带走体热)、相变蓄冷(冰袋或凝胶相变材料在融化过程中持续吸热)、涡扇风冷(小型风扇强制对流散热)。酷冰甲提供这三大系列,覆盖不同场景与续航需求。'],
+ ['q' => '穿降温服体感能降多少度?多久能起效?', 'a' => '在常规高温环境下,合格降温服可让核心体表感温度下降约 8–12℃。水冷与风冷方案接通或开机后数分钟内即可感受到明显凉意;相变冰袋方案放入预冷冰袋后即刻生效,单组冰袋可持续 2–4 小时。'],
+ ['q' => '降温服可以重复使用吗?一套能用多久?', 'a' => '可以。酷冰甲降温服主体为可水洗服装,水冷、风冷模块与相变冰袋均可反复使用。服装本体在正常保养下可用 2–3 个高温季,冰袋与电池模块按使用频率约 1–2 年更换即可。'],
+ ['q' => '支持企业定制和 LOGO 刺绣吗?起订量多少?', 'a' => '支持。我们提供企业 LOGO 绣字、颜色与面料定制、一人一码量体服务。柔性化生产,10 套起订,确认图纸后 7 天打样、约 28 天批量交付,适合班组、车间等小批量统一配发。'],
+ ['q' => '降温服适合哪些行业和场景?', 'a' => '广泛用于消防、钢铁、电力、化工、环卫、建筑、物流及户外军训等高温或暴晒场景,也适用于骑行、垂钓、观赛等个人户外降温。可按行业工况推荐对应系列与续航配置。'],
+ ['q' => '降温服怎么清洗和保养?', 'a' => '服装本体可轻柔机洗或手洗,避免浸泡电子模块;水冷、风冷主机与电池需拆下后擦干存放,冰袋用后擦干冷藏。长期不用请置于阴凉干燥处,电池保持半电存放。'],
+ ];
+ $faqJsonLd = '';
+
+ $data = [
+ 'pageSeo' => [
+ 'title' => $seo['title'],
+ 'description' => $seo['description'],
+ 'keywords' => $seo['keywords'],
+ 'og_type' => $seo['og_type'] ?: 'website',
+ 'og_image' => $seo['og_image'],
+ 'canonical' => $seo['canonical'],
+ 'noindex' => $seo['noindex'],
+ 'jsonld' => $faqJsonLd,
+ ],
+ 'banners' => array_filter($banner->all(), fn($b) => ($b['status'] ?? 1) == 1),
+ 'categories'=> $category->all(),
+ 'products' => $product->featured(8),
+ 'news' => $news->published(3),
+ 'cases' => (new CustomerCase())->published(3),
+ 'stats' => [
+ ['n' => '20', 'u' => '年', 'l' => '服装定制经验'],
+ ['n' => '6', 'u' => '大', 'l' => '降温产品系列'],
+ ['n' => '10', 'u' => '套', 'l' => '起订柔性生产'],
+ ['n' => '8', 'u' => '℃', 'l' => '体感直降'],
+ ],
+ 'advantages'=> [
+ ['n' => '01', 't' => '柔性化生产', 'd' => '小单亦可定制,10 套起订,留足面辅料灵活补单。'],
+ ['n' => '02', 't' => '量身打造', 'd' => '设计师结合企业文化与功能需求定向设计,5 天出方案。'],
+ ['n' => '03', 't' => '一人一码', 'd' => '资深打版师打板、上门量体,高度还原设计稿,合身合体。'],
+ ['n' => '04', 't' => '外贸级品质', 'd' => '156 道工序层层把控,欧美出口级标准出货。'],
+ ],
+ 'process' => [
+ ['t' => '需求沟通', 'd' => '了解行业、人群与场景,明确颜色款式与预算。'],
+ ['t' => '上门量体', 'd' => '试样衣、量体,采集精准尺寸数据。'],
+ ['t' => '设计款式', 'd' => '结合沟通结果量身设计降温服方案。'],
+ ['t' => '批量生产', 'd' => '确认图纸后快速打版、批量生产。'],
+ ['t' => '成衣交付', 'd' => '精心包装交付上门,启动售后服务。'],
+ ],
+ 'faqs' => $faqs,
+ ];
+ return $this->view('home/index', $data);
+ }
+}
diff --git a/app/Controllers/NewsController.php b/app/Controllers/NewsController.php
new file mode 100644
index 0000000..11322b4
--- /dev/null
+++ b/app/Controllers/NewsController.php
@@ -0,0 +1,84 @@
+ '新闻动态',
+ 'description' => '酷冰甲降温服行业新闻中心,汇集高温防护政策解读、降温技术深度解析、产品应用案例、客户现场实录与行业前沿动态,持续分享降温服选型、使用、保养与清洗知识,帮助企业做好高温作业人员的健康与安全防护。我们关注每一次技术迭代,也记录每一处真实应用,让高温防护更有依据、更可落地。',
+ 'keywords' => '降温服新闻,降温技术,高温防护,工业降温,降温服应用,行业动态,酷冰甲资讯,降温服知识',
+ 'og_type' => 'website',
+ ]);
+ return $this->view('news/index', [
+ 'pageSeo' => [
+ 'title' => $seo['title'],
+ 'description' => $seo['description'],
+ 'keywords' => $seo['keywords'],
+ 'og_type' => $seo['og_type'] ?: 'website',
+ 'og_image' => $seo['og_image'],
+ 'canonical' => $seo['canonical'],
+ 'noindex' => $seo['noindex'],
+ 'breadcrumb' => [
+ ['name' => '首页', 'url' => site_url()],
+ ['name' => '新闻动态', 'url' => absolute_url()],
+ ],
+ ],
+ 'news' => $news->published(20),
+ ]);
+ }
+
+ public function show($slug)
+ {
+ $news = new News();
+ $n = $news->where('slug', $slug);
+ if (!$n && is_numeric($slug)) { $n = $news->find((int)$slug); }
+ if (!$n) { \Core\App::notFound(); return ''; }
+ // 阅读量 +1
+ $news->update($n['id'], ['views' => ($n['views'] ?? 0) + 1]);
+ $all = $news->published(20);
+ $idx = array_search($n, $all);
+ $prev = $idx !== false && $idx > 0 ? $all[$idx - 1] : null;
+ $next = $idx !== false && $idx < count($all) - 1 ? $all[$idx + 1] : null;
+
+ $nTitle = e($n['title'] ?? '文章详情');
+ $nSummary = mb_substr(strip_tags($n['summary'] ?? $n['body'] ?? ''), 0, 160);
+ $nImage = $n['image'] ?? '';
+ $publishedAt = $n['created_at'] ?? $n['published_at'] ?? date('Y-m-d');
+
+ // ── Article JSON-LD Schema ──
+ $articleSchema = '';
+
+ return $this->view('news/show', [
+ 'pageSeo' => [
+ 'title' => $nTitle,
+ 'description' => $nSummary,
+ 'og_type' => 'article',
+ 'og_image' => $nImage,
+ 'breadcrumb' => [
+ ['name' => '首页', 'url' => site_url()],
+ ['name' => '新闻动态', 'url' => site_url('news')],
+ ['name' => $n['title'] ?? '文章', 'url' => absolute_url()],
+ ],
+ 'jsonld' => $articleSchema,
+ ],
+ 'n' => $n,
+ 'prev' => $prev,
+ 'next' => $next,
+ ]);
+ }
+}
diff --git a/app/Controllers/OrderController.php b/app/Controllers/OrderController.php
new file mode 100644
index 0000000..ec8c061
--- /dev/null
+++ b/app/Controllers/OrderController.php
@@ -0,0 +1,152 @@
+where('slug', $slug);
+ if (!$product) { \Core\App::notFound(); return ''; }
+ $err = isset($_GET['err']) ? '请填写姓名与手机号' : '';
+ return $this->view('order/checkout', ['p' => $product, 'slug' => $slug, 'err' => $err]);
+ }
+
+ public function store()
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('products'); }
+ if (!csrf_check()) { $this->redirect('products'); }
+ $slug = $this->post('slug', '');
+ $product = (new Product())->where('slug', $slug);
+ if (!$product) { $this->redirect('products'); }
+ $name = trim($this->post('name', ''));
+ $phone = trim($this->post('phone', ''));
+ $email = trim($this->post('email', ''));
+ $qty = max(1, (int) $this->post('qty', 1));
+ if ($name === '' || $phone === '') {
+ $this->redirect('order/checkout/' . $slug . '?err=1');
+ }
+ $amount = round((float) $product['price'] * $qty, 2);
+ $orderNo = $this->genNo();
+ $oid = (new Order())->insert([
+ 'order_no' => $orderNo,
+ 'product_id' => $product['id'],
+ 'product_name' => $product['name'],
+ 'customer_name' => $name,
+ 'phone' => $phone,
+ 'email' => $email,
+ 'qty' => $qty,
+ 'amount' => $amount,
+ 'channel' => '',
+ 'status' => 'pending',
+ 'created_at' => date('Y-m-d H:i:s'),
+ ]);
+ Notify::newCustomerOrder($orderNo, $name, $phone, $oid);
+ $this->redirect('order/pay/' . $orderNo);
+ }
+
+ public function pay($orderNo)
+ {
+ $order = (new Order())->where('order_no', $orderNo);
+ if (!$order) { \Core\App::notFound(); return ''; }
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $channel = $this->post('channel', '');
+ if ($channel === 'alipay' || $channel === 'wechat') {
+ if ($order['channel'] !== $channel) {
+ (new Order())->update($order['id'], ['channel' => $channel]);
+ $order['channel'] = $channel;
+ }
+ }
+ }
+
+ if ($order['status'] === 'paid') {
+ return $this->view('order/pay', ['order' => $order, 'paid' => true]);
+ }
+ if (empty($order['channel'])) {
+ return $this->view('order/pay', ['order' => $order, 'choose' => true]);
+ }
+ $gw = GatewayFactory::make($order['channel']);
+ $res = $gw->pay($order);
+ return $this->view('order/pay', ['order' => $order, 'gw' => $res]);
+ }
+
+ /** 演示支付:模拟支付成功(默认模式可用,便于走通全流程) */
+ public function demo($orderNo)
+ {
+ $o = (new Order())->where('order_no', $orderNo);
+ $channel = ($o && $o['channel'] === 'wechat') ? 'wechat' : 'alipay';
+ OrderService::markPaid($orderNo, 'DEMO' . time(), $channel);
+ $this->redirect('order/success/' . $orderNo);
+ }
+
+ public function success($orderNo)
+ {
+ $order = (new Order())->where('order_no', $orderNo);
+ if (!$order) { \Core\App::notFound(); return ''; }
+ $payments = (new Payment())->whereAll('order_no', $orderNo);
+ return $this->view('order/result', ['order' => $order, 'payments' => $payments]);
+ }
+
+ /**
+ * 客户查询订单:客户名 + 手机号 双重校验
+ * - 必填:客户名(下单时填写的姓名/单位)+ 手机号
+ * - 选填:订单号(精确查单笔)
+ */
+ public function query()
+ {
+ $orders = []; $no = ''; $name = ''; $phone = ''; $err = '';
+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
+ $no = trim($this->post('order_no', ''));
+ $name = trim($this->post('name', ''));
+ $phone = trim($this->post('phone', ''));
+
+ if ($name === '' || $phone === '') {
+ $err = '请输入客户名与手机号以核验身份';
+ } else {
+ $om = new Order();
+ $pm = new Payment();
+ $nameKey = mb_strtolower($name, 'UTF-8');
+ // 客户名 + 手机号 同时匹配
+ $matched = array_filter($om->all(), function ($o) use ($nameKey, $phone) {
+ $oName = mb_strtolower(trim($o['customer_name'] ?? ''), 'UTF-8');
+ $oPhone = trim($o['phone'] ?? '');
+ return $oName === $nameKey && $oPhone === $phone;
+ });
+
+ if ($no !== '') {
+ $matched = array_values(array_filter($matched, fn($o) => ($o['order_no'] ?? '') === $no));
+ }
+
+ if (empty($matched)) {
+ $err = $no !== ''
+ ? '未找到该订单号对应的订单,请核对客户名与手机号'
+ : '未找到匹配的客户名与手机号对应的订单';
+ } else {
+ $orders = array_map(function ($o) use ($pm) {
+ return ['order' => $o, 'payments' => $pm->whereAll('order_no', $o['order_no'])];
+ }, $matched);
+ }
+ }
+ }
+ return $this->view('order/query', [
+ 'orders' => $orders,
+ 'no' => $no,
+ 'name' => $name,
+ 'phone' => $phone,
+ 'err' => $err,
+ ]);
+ }
+
+ private function genNo(): string
+ {
+ return 'SQY' . date('YmdHis') . str_pad((int) ((microtime(true) * 1000) % 1000), 3, '0', STR_PAD_LEFT);
+ }
+}
diff --git a/app/Controllers/PSI/DashboardController.php b/app/Controllers/PSI/DashboardController.php
new file mode 100644
index 0000000..d8f90c8
--- /dev/null
+++ b/app/Controllers/PSI/DashboardController.php
@@ -0,0 +1,267 @@
+dashboard();
+ }
+
+ // 用户管理(仅该系统管理员):统一管理本系统用户及其页面权限
+ if ($res === 'users') {
+ if (!\subsys_admin('psi')) {
+ \Core\App::forbidden('需要 PSI 管理员权限');
+ return;
+ }
+ $uc = new \App\Controllers\PSI\UsersController();
+ if ($action === '' || $action === 'index') return $uc->index();
+ if ($action === 'create') return $uc->create();
+ if ($action === 'store') return $uc->store();
+ if ($action === 'edit') return $uc->edit($id);
+ if ($action === 'update') return $uc->update($id);
+ if ($action === 'destroy') return $uc->destroy($id);
+ if ($action === 'reset') return $uc->reset($id);
+ \Core\App::notFound('未知操作: ' . $action);
+ return;
+ }
+
+ // 订单管理:合并原「后台订单」到 PSI,方便管理人员在同一系统内快速处理
+ if ($res === 'orders') {
+ if (!\subsys_page_can('psi', 'orders')) {
+ \Core\App::forbidden('您没有访问订单管理的权限');
+ return;
+ }
+ $oc = new \App\Controllers\PSI\OrdersController();
+ if ($action === '' || $action === 'index') return $oc->index();
+ if ($action === 'show') return $oc->show($id);
+ if ($action === 'markPaid') return $oc->markPaid($id);
+ if ($action === 'destroy') return $oc->destroy($id);
+ \Core\App::notFound('未知操作: ' . $action);
+ return;
+ }
+
+ // 销售订单:录入 / 打印 / 关联出库
+ if ($res === 'sales_orders') {
+ if (!\subsys_page_can('psi', 'sales_orders')) { \Core\App::forbidden('您没有访问销售订单的权限'); return; }
+ $c = new \App\Controllers\PSI\SalesOrdersController();
+ if ($action === '' || $action === 'index') return $c->index();
+ if ($action === 'create') return $c->create();
+ if ($action === 'store') return $c->store();
+ if ($action === 'show') return $c->show($id);
+ if ($action === 'edit') return $c->edit($id);
+ if ($action === 'update') return $c->update($id);
+ if ($action === 'destroy') return $c->destroy($id);
+ if ($action === 'print') return $c->printDoc($id);
+ \Core\App::notFound('未知操作: ' . $action); return;
+ }
+
+ // 采购订单:录入 / 打印 / 收货入库
+ if ($res === 'purchase_orders') {
+ if (!\subsys_page_can('psi', 'purchase_orders')) { \Core\App::forbidden('您没有访问采购订单的权限'); return; }
+ $c = new \App\Controllers\PSI\PurchaseOrdersController();
+ if ($action === '' || $action === 'index') return $c->index();
+ if ($action === 'create') return $c->create();
+ if ($action === 'store') return $c->store();
+ if ($action === 'show') return $c->show($id);
+ if ($action === 'edit') return $c->edit($id);
+ if ($action === 'update') return $c->update($id);
+ if ($action === 'destroy') return $c->destroy($id);
+ if ($action === 'receive') return $c->receive($id);
+ if ($action === 'print') return $c->printDoc($id);
+ \Core\App::notFound('未知操作: ' . $action); return;
+ }
+
+ // 出库单:录入 / 打印(关联销售订单、扣减库存)
+ if ($res === 'outbounds') {
+ if (!\subsys_page_can('psi', 'outbounds')) { \Core\App::forbidden('您没有访问出库单的权限'); return; }
+ $c = new \App\Controllers\PSI\OutboundsController();
+ if ($action === '' || $action === 'index') return $c->index();
+ if ($action === 'create') return $c->create();
+ if ($action === 'store') return $c->store();
+ if ($action === 'show') return $c->show($id);
+ if ($action === 'edit') return $c->edit($id);
+ if ($action === 'update') return $c->update($id);
+ if ($action === 'destroy') return $c->destroy($id);
+ if ($action === 'print') return $c->printDoc($id);
+ \Core\App::notFound('未知操作: ' . $action); return;
+ }
+
+ // 报表中心:采购订单明细 / 销售·采购订单明细 / 交付明细
+ if ($res === 'reports') {
+ if (!\subsys_page_can('psi', 'reports')) { \Core\App::forbidden('您没有访问报表中心的权限'); return; }
+ $c = new \App\Controllers\PSI\ReportsController();
+ if ($action === '' || $action === 'index') return $c->index();
+ if ($action === 'poDetail') return $c->poDetail();
+ if ($action === 'soPo') return $c->soPo();
+ if ($action === 'delivery') return $c->delivery();
+ \Core\App::notFound('未知操作: ' . $action); return;
+ }
+
+ // 紧急提醒中心(所有 PSI 用户可见)
+ if ($res === 'reminders') {
+ $c = new \App\Controllers\PSI\RemindersController();
+ return $c->handle(array_slice($s, 1));
+ }
+
+ // 通知设置(仅 PSI 管理员,控制器内二次鉴权)
+ if ($res === 'notifications') {
+ $c = new \App\Controllers\PSI\NotificationsController();
+ return $c->handle(array_slice($s, 1));
+ }
+
+ // 销售出库(支持 show/print 查看与打印预览)
+ if ($res === 'sales') {
+ if (!\subsys_page_can('psi', 'sales')) { \Core\App::forbidden('您没有访问销售出库的权限'); return; }
+ $c = new \App\Controllers\PSI\SalesController();
+ if ($action === '' || $action === 'index') return $c->index();
+ if ($action === 'create') return $c->create();
+ if ($action === 'store') return $c->store();
+ if ($action === 'show') return $c->show($id);
+ if ($action === 'destroy') return $c->destroy($id);
+ if ($action === 'print') return $c->printDoc($id);
+ \Core\App::notFound('未知操作: ' . $action); return;
+ }
+
+ $map = [
+ 'materials' => 'MaterialsController',
+ 'products' => 'ProductsController',
+ 'suppliers' => 'SuppliersController',
+ 'purchases' => 'PurchasesController',
+ 'stock' => 'StockController',
+ ];
+ if (!isset($map[$res])) {
+ \Core\App::notFound('未知页面: ' . $res);
+ return;
+ }
+ // 页面级权限:按分系统「页面可见权限」拦截
+ if (!\subsys_page_can('psi', $res)) {
+ \Core\App::forbidden('您没有访问该页面的权限');
+ return;
+ }
+ // 资源子操作路由:/PSI/{resource}[/{action}[/{id}]]
+ // 支持 index/create/store/edit/update/destroy/adjust
+ $method = $this->resolveSubsysAction($action);
+ if ($method === null) {
+ \Core\App::notFound('未知操作: ' . $action);
+ return;
+ }
+ $class = 'App\\Controllers\\PSI\\' . $map[$res];
+ $instance = new $class();
+ if (!method_exists($instance, $method)) {
+ \Core\App::notFound('操作不存在: ' . $method);
+ return;
+ }
+ return $instance->$method($id);
+ }
+
+ /** 将 URL 动作段解析为控制器方法名;不支持的动作返回 null */
+ private function resolveSubsysAction(string $action): ?string
+ {
+ $verbs = [
+ '' => 'index',
+ 'index' => 'index',
+ 'create' => 'create',
+ 'store' => 'store',
+ 'edit' => 'edit',
+ 'update' => 'update',
+ 'destroy' => 'destroy',
+ 'adjust' => 'adjust',
+ ];
+ return $verbs[$action] ?? null;
+ }
+
+ public function dashboard()
+ {
+ $materials = (new Material())->all();
+ $products = (new Product())->all();
+ $purchases = (new Purchase())->all();
+ $sales = (new Sales())->all();
+
+ $matStock = array_sum(array_map(fn($m) => (float)($m['stock'] ?? 0), $materials));
+ $prodStock = array_sum(array_map(fn($p) => (float)($p['stock'] ?? 0), $products));
+ $purchaseAmt = array_sum(array_map(fn($p) => (float)($p['amount'] ?? 0), $purchases));
+ $salesAmt = array_sum(array_map(fn($s) => (float)($s['amount'] ?? 0), $sales));
+ $lowStock = [];
+ foreach ($materials as $m) { if ((float)($m['stock'] ?? 0) < 20) $lowStock[] = ['type' => '物料', 'item' => $m]; }
+ foreach ($products as $p) { if ((float)($p['stock'] ?? 0) < 20) $lowStock[] = ['type' => '成品', 'item' => $p]; }
+
+ $recentPurchases = array_slice(array_reverse($purchases), 0, 5);
+ $recentSales = array_slice(array_reverse($sales), 0, 5);
+
+ // 订单/出库业务指标(表未创建时静默降级)
+ $bySalesman = [];
+ $salesAmount = 0; $salesAmountMonth = 0;
+ $undelivered = ['count' => 0, 'amount' => 0];
+ $monthDeliveries = ['count' => 0, 'amount' => 0];
+ try {
+ $bySalesman = \Core\Db::query(
+ "SELECT o.salesman AS salesman, COUNT(DISTINCT o.id) AS orders,
+ COALESCE(SUM(i.amount),0) AS amount
+ FROM psi_sales_orders o
+ LEFT JOIN psi_sales_order_items i ON i.so_id=o.id
+ WHERE o.salesman <> '' AND o.status <> 'closed'
+ GROUP BY o.salesman ORDER BY amount DESC"
+ )->fetchAll();
+
+ $r = \Core\Db::query(
+ "SELECT COALESCE(SUM(i.amount),0) AS amt,
+ COALESCE(SUM(CASE WHEN MONTH(o.created_at)=MONTH(CURDATE()) AND YEAR(o.created_at)=YEAR(CURDATE()) THEN i.amount ELSE 0 END),0) AS amt_m
+ FROM psi_sales_orders o LEFT JOIN psi_sales_order_items i ON i.so_id=o.id
+ WHERE o.status <> 'closed'"
+ )->fetch();
+ $salesAmount = (float)($r['amt'] ?? 0);
+ $salesAmountMonth = (float)($r['amt_m'] ?? 0);
+
+ $u = \Core\Db::query(
+ "SELECT COUNT(*) AS c, COALESCE(SUM(i.amount),0) AS amt
+ FROM psi_sales_orders o LEFT JOIN psi_sales_order_items i ON i.so_id=o.id
+ WHERE o.status IN ('pending','partial')"
+ )->fetch();
+ $undelivered = ['count' => (int)($u['c'] ?? 0), 'amount' => (float)($u['amt'] ?? 0)];
+
+ $d = \Core\Db::query(
+ "SELECT COUNT(*) AS c, COALESCE(SUM(i.amount),0) AS amt
+ FROM psi_outbounds ob LEFT JOIN psi_outbound_items i ON i.ob_id=ob.id
+ WHERE MONTH(ob.created_at)=MONTH(CURDATE()) AND YEAR(ob.created_at)=YEAR(CURDATE())"
+ )->fetch();
+ $monthDeliveries = ['count' => (int)($d['c'] ?? 0), 'amount' => (float)($d['amt'] ?? 0)];
+ } catch (\Throwable $e) { /* 表未创建时不报错 */ }
+
+ return $this->renderSubsys('psi', 'psi/dashboard', [
+ 'materialTotal' => count($materials),
+ 'productTotal' => count($products),
+ 'matStock' => $matStock,
+ 'prodStock' => $prodStock,
+ 'purchaseAmt' => $purchaseAmt,
+ 'salesAmt' => $salesAmt,
+ 'lowStock' => $lowStock,
+ 'recentPurchases' => $recentPurchases,
+ 'recentSales' => $recentSales,
+ 'bySalesman' => $bySalesman,
+ 'salesAmount' => $salesAmount,
+ 'salesAmountMonth' => $salesAmountMonth,
+ 'undelivered' => $undelivered,
+ 'monthDeliveries' => $monthDeliveries,
+ ], $this->nav('dashboard'), 'dashboard');
+ }
+}
diff --git a/app/Controllers/PSI/MaterialsController.php b/app/Controllers/PSI/MaterialsController.php
new file mode 100644
index 0000000..8a88a3b
--- /dev/null
+++ b/app/Controllers/PSI/MaterialsController.php
@@ -0,0 +1,101 @@
+all();
+ $suppliers = (new Supplier())->all();
+ $smap = [];
+ foreach ($suppliers as $s) { $smap[$s['id']] = $s['name']; }
+ return $this->renderSubsys('psi', 'psi/materials', ['materials' => $materials, 'smap' => $smap], \psi_nav(), 'materials');
+ }
+
+ public function create()
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ $suppliers = (new Supplier())->all();
+ return $this->renderSubsys('psi', 'psi/material_form', ['material' => null, 'suppliers' => $suppliers], \psi_nav(), 'materials');
+ }
+
+ public function store()
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ if (!csrf_check()) return $this->redirect('PSI/materials');
+ (new Material())->insert($this->collect());
+ return $this->redirect('PSI/materials');
+ }
+
+ public function edit($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ $material = (new Material())->find($id);
+ if (!$material) return $this->redirect('PSI/materials');
+ $suppliers = (new Supplier())->all();
+ return $this->renderSubsys('psi', 'psi/material_form', ['material' => $material, 'suppliers' => $suppliers], \psi_nav(), 'materials');
+ }
+
+ public function update($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ if (!csrf_check()) return $this->redirect('PSI/materials');
+ $material = (new Material())->find($id);
+ if (!$material) return $this->redirect('PSI/materials');
+ (new Material())->update($id, $this->collect());
+ return $this->redirect('PSI/materials');
+ }
+
+ public function destroy($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ (new Material())->delete($id);
+ return $this->redirect('PSI/materials');
+ }
+
+ /** 手动调整库存(盘盈/盘亏/报损) */
+ public function adjust($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!csrf_check()) return $this->redirect('PSI/materials');
+ $qty = (float)$this->post('qty');
+ $direction = $this->post('direction') === 'out' ? 'out' : 'in';
+ $this->adjustStock('material', (int)$id, abs($qty), $direction, 'ADJ-' . date('Ymd'));
+ return $this->redirect('PSI/materials');
+ }
+ $material = (new Material())->find($id);
+ if (!$material) return $this->redirect('PSI/materials');
+ return $this->renderSubsys('psi', 'psi/adjust_form', ['item' => $material, 'type' => 'material'], \psi_nav(), 'materials');
+ }
+
+ private function collect(): array
+ {
+ return [
+ 'code' => trim($this->post('code')),
+ 'name' => trim($this->post('name')),
+ 'spec' => trim($this->post('spec')),
+ 'unit' => trim($this->post('unit')) ?: '个',
+ 'category' => trim($this->post('category')),
+ 'composition' => trim($this->post('composition')),
+ 'weight_gsm' => (float)$this->post('weight_gsm'),
+ 'width_cm' => (float)$this->post('width_cm'),
+ 'color' => trim($this->post('color')),
+ 'batch_no' => trim($this->post('batch_no')),
+ 'stock' => (float)$this->post('stock'),
+ 'price' => (float)$this->post('price'),
+ 'supplier_id' => (int)$this->post('supplier_id'),
+ 'remark' => trim($this->post('remark')),
+ 'created_at' => date('Y-m-d'),
+ ];
+ }
+}
diff --git a/app/Controllers/PSI/NotificationsController.php b/app/Controllers/PSI/NotificationsController.php
new file mode 100644
index 0000000..2b4eb60
--- /dev/null
+++ b/app/Controllers/PSI/NotificationsController.php
@@ -0,0 +1,73 @@
+save();
+ return;
+ }
+ $this->index();
+ }
+
+ public function index(): void
+ {
+ $s = new Setting();
+ $v = [];
+ foreach (self::KEYS as $k) {
+ $v[$k] = $s->get($k, '');
+ }
+ // 布尔项默认值
+ if ($v['notify_lowstock_enabled'] === '') $v['notify_lowstock_enabled'] = 1;
+ if ($v['notify_lowstock_threshold'] === '') $v['notify_lowstock_threshold'] = 20;
+
+ $this->renderSubsys('psi', 'psi/notifications', ['v' => $v], \psi_nav(), 'notifications');
+ }
+
+ public function save(): void
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) {
+ $this->redirect('PSI/notifications');
+ }
+ $s = new Setting();
+ $post = $_POST;
+ $s->set('notify_enabled', isset($post['notify_enabled']) ? 1 : 0);
+ $s->set('notify_email_enabled', isset($post['notify_email_enabled']) ? 1 : 0);
+ $s->set('notify_email_smtp_host', trim((string) ($post['notify_email_smtp_host'] ?? '')));
+ $s->set('notify_email_smtp_port', (int) ($post['notify_email_smtp_port'] ?? 465));
+ $s->set('notify_email_smtp_user', trim((string) ($post['notify_email_smtp_user'] ?? '')));
+ $s->set('notify_email_smtp_pass', trim((string) ($post['notify_email_smtp_pass'] ?? '')));
+ $s->set('notify_email_from', trim((string) ($post['notify_email_from'] ?? '')));
+ $s->set('notify_email_to', trim((string) ($post['notify_email_to'] ?? '')));
+ $s->set('notify_wechat_enabled', isset($post['notify_wechat_enabled']) ? 1 : 0);
+ $s->set('notify_wechat_webhook', trim((string) ($post['notify_wechat_webhook'] ?? '')));
+ $s->set('notify_wechat_mention', trim((string) ($post['notify_wechat_mention'] ?? '')));
+ $s->set('notify_lowstock_enabled', isset($post['notify_lowstock_enabled']) ? 1 : 0);
+ $s->set('notify_lowstock_threshold', max(0, (float) ($post['notify_lowstock_threshold'] ?? 20)));
+
+ $this->flash('通知设置已保存', 'ok');
+ $this->redirect('PSI/notifications');
+ }
+}
diff --git a/app/Controllers/PSI/OrdersController.php b/app/Controllers/PSI/OrdersController.php
new file mode 100644
index 0000000..bd37c76
--- /dev/null
+++ b/app/Controllers/PSI/OrdersController.php
@@ -0,0 +1,61 @@
+all());
+ return $this->renderSubsys('psi', 'psi/orders', ['orders' => $all], \subsys_nav('psi'), 'orders');
+ }
+
+ /** 详情:订单信息 + 付款记录 */
+ public function show($id)
+ {
+ $order = new Order();
+ $o = $order->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ $payments = (new Payment())->whereAll('order_no', $o['order_no']);
+ return $this->renderSubsys('psi', 'psi/order_show', [
+ 'o' => $o, 'payments' => $payments,
+ ], \subsys_nav('psi'), 'orders');
+ }
+
+ /** 标记为已支付(仅 PSI 管理员) */
+ public function markPaid($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
+ $order = new Order();
+ $o = $order->find($id);
+ if ($o && $o['status'] !== 'paid') {
+ OrderService::markPaid($o['order_no'], 'MANUAL' . time(), $o['channel'] ?: 'manual');
+ $this->flash('订单已标记为已支付', 'ok');
+ }
+ }
+ return $this->redirect('PSI/orders/show/' . $id);
+ }
+
+ /** 删除订单(仅 PSI 管理员) */
+ public function destroy($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
+ (new Order())->delete($id);
+ $this->flash('订单已删除', 'ok');
+ }
+ return $this->redirect('PSI/orders');
+ }
+}
diff --git a/app/Controllers/PSI/OutboundsController.php b/app/Controllers/PSI/OutboundsController.php
new file mode 100644
index 0000000..f525337
--- /dev/null
+++ b/app/Controllers/PSI/OutboundsController.php
@@ -0,0 +1,195 @@
+all());
+ $itemM = new OutboundItem();
+ foreach ($list as &$o) { $o['_items'] = $itemM->whereAll('ob_id', $o['id']); }
+ return $this->renderSubsys('psi', 'psi/outbounds', ['list' => $list], \psi_nav(), 'outbounds');
+ }
+
+ public function create()
+ {
+ $soNo = $this->get('so');
+ $so = $soNo ? (new SalesOrder())->where('order_no', $soNo) : null;
+ $soItems = $so ? (new SalesOrderItem())->whereAll('so_id', $so['id']) : [];
+ return $this->renderSubsys('psi', 'psi/outbound_form', [
+ 'o' => null, 'items' => [], 'so' => $so, 'soItems' => $soItems, 'products' => (new Product())->all(),
+ ], \psi_nav(), 'outbounds');
+ }
+
+ public function store()
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/outbounds/create');
+ $items = $this->parseItems();
+ if (empty($items)) { $this->flash('请至少添加一条出库明细', 'err'); return $this->redirect('PSI/outbounds/create'); }
+
+ $soNo = trim($this->post('so_no'));
+ $so = $soNo ? (new SalesOrder())->where('order_no', $soNo) : null;
+ $orderNo = \psi_gen_no('OUT');
+ $id = (new Outbound())->insert([
+ 'order_no' => $orderNo,
+ 'so_no' => $soNo,
+ 'customer' => trim($this->post('customer')) ?: ($so['customer'] ?? ''),
+ 'salesman' => trim($this->post('salesman')) ?: ($so['salesman'] ?? ($_SESSION['admin_name'] ?? '')),
+ 'warehouse' => trim($this->post('warehouse')),
+ 'status' => 'delivered',
+ 'delivery_date' => $this->post('delivery_date') ?: null,
+ 'remark' => trim($this->post('remark')),
+ 'created_at' => date('Y-m-d H:i:s'),
+ ]);
+ $this->applyItems($id, $items, $soNo, $orderNo);
+ if ($so) {
+ \psi_recompute_so($so['id']);
+ $st = (new SalesOrder())->find($so['id'])['status'];
+ (new Outbound())->update($id, ['status' => $st === 'delivered' ? 'delivered' : 'partial']);
+ }
+ $this->flash('出库单已保存', 'ok');
+ if ($this->post('auto_print', '1') !== '0') {
+ return $this->redirect('PSI/outbounds/print/' . $id);
+ }
+ return $this->redirect('PSI/outbounds/show/' . $id);
+ }
+
+ public function show($id)
+ {
+ $o = (new Outbound())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ $items = (new OutboundItem())->whereAll('ob_id', $id);
+ return $this->renderSubsys('psi', 'psi/outbound_show', ['o' => $o, 'items' => $items], \psi_nav(), 'outbounds');
+ }
+
+ public function edit($id)
+ {
+ $o = (new Outbound())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ $items = (new OutboundItem())->whereAll('ob_id', $id);
+ $so = $o['so_no'] ? (new SalesOrder())->where('order_no', $o['so_no']) : null;
+ $soItems = $so ? (new SalesOrderItem())->whereAll('so_id', $so['id']) : [];
+ return $this->renderSubsys('psi', 'psi/outbound_form', [
+ 'o' => $o, 'items' => $items, 'so' => $so, 'soItems' => $soItems, 'products' => (new Product())->all(),
+ ], \psi_nav(), 'outbounds');
+ }
+
+ public function update($id)
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/outbounds/edit/' . $id);
+ $o = (new Outbound())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ $items = $this->parseItems();
+ if (empty($items)) { $this->flash('请至少添加一条出库明细', 'err'); return $this->redirect('PSI/outbounds/edit/' . $id); }
+
+ $this->reverseItems($id); // 回冲库存与已交付
+ (new Outbound())->update($id, [
+ 'customer' => trim($this->post('customer')),
+ 'salesman' => trim($this->post('salesman')) ?: ($_SESSION['admin_name'] ?? ''),
+ 'warehouse' => trim($this->post('warehouse')),
+ 'delivery_date' => $this->post('delivery_date') ?: null,
+ 'remark' => trim($this->post('remark')),
+ ]);
+ $this->applyItems($id, $items, $o['so_no'], $o['order_no']);
+ if ($o['so_no']) {
+ $so = (new SalesOrder())->where('order_no', $o['so_no']);
+ if ($so) \psi_recompute_so((int)$so['id']);
+ }
+ $this->flash('出库单已更新', 'ok');
+ return $this->redirect('PSI/outbounds/show/' . $id);
+ }
+
+ public function destroy($id)
+ {
+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
+ $this->reverseItems($id);
+ (new OutboundItem())->deleteRaw('ob_id', $id);
+ (new Outbound())->delete($id);
+ $this->flash('出库单已删除', 'ok');
+ }
+ return $this->redirect('PSI/outbounds');
+ }
+
+ public function printDoc($id)
+ {
+ $o = (new Outbound())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ $items = (new OutboundItem())->whereAll('ob_id', $id);
+ $body = \App\Core\View::render('psi/outbound_print', ['o' => $o, 'items' => $items]);
+ echo \psi_print_shell('出库单 ' . $o['order_no'], $body);
+ }
+
+ /** 应用出库明细:写明细 + 扣库存 + 回写销售订单已交付量 */
+ private function applyItems(int $obId, array $items, string $soNo, string $orderNo): void
+ {
+ $itemM = new OutboundItem();
+ $so = $soNo ? (new SalesOrder())->where('order_no', $soNo) : null;
+ foreach ($items as $it) {
+ $it['ob_id'] = $obId;
+ $itemM->insert($it);
+ if ((int)($it['product_id'] ?? 0) > 0) {
+ StockHelper::adjustStock((int)$it['product_id'], (int)$it['qty'], 'out', '销售出库', $orderNo);
+ }
+ if ((int)($it['so_item_id'] ?? 0) > 0) {
+ $si = (new SalesOrderItem())->find($it['so_item_id']);
+ if ($si) {
+ (new SalesOrderItem())->update($si['id'], ['delivered_qty' => (int)$si['delivered_qty'] + (int)$it['qty']]);
+ }
+ }
+ }
+ }
+
+ /** 回冲:把出库明细的库存与销售订单已交付量还原 */
+ private function reverseItems(int $obId): void
+ {
+ $old = (new OutboundItem())->whereAll('ob_id', $obId);
+ foreach ($old as $oi) {
+ if ((int)($oi['product_id'] ?? 0) > 0) {
+ StockHelper::adjustStock((int)$oi['product_id'], (int)$oi['qty'], 'in', '出库冲正', $oi['ob_id'] ?? '');
+ }
+ if ((int)($oi['so_item_id'] ?? 0) > 0) {
+ $si = (new SalesOrderItem())->find($oi['so_item_id']);
+ if ($si) {
+ $back = max(0, (int)$si['delivered_qty'] - (int)$oi['qty']);
+ (new SalesOrderItem())->update($si['id'], ['delivered_qty' => $back]);
+ }
+ }
+ }
+ (new OutboundItem())->deleteRaw('ob_id', $obId);
+ }
+
+ private function parseItems(): array
+ {
+ $raw = $_POST['items'] ?? [];
+ $out = [];
+ foreach ($raw as $row) {
+ $name = trim((string)($row['name'] ?? ''));
+ $qty = (int)($row['qty'] ?? 0);
+ $price = (float)($row['price'] ?? 0);
+ if ($name === '' || $qty <= 0) continue;
+ $out[] = [
+ 'so_item_id' => (int)($row['so_item_id'] ?? 0),
+ 'product_id' => (int)($row['product_id'] ?? 0),
+ 'name' => $name,
+ 'spec' => trim((string)($row['spec'] ?? '')),
+ 'unit' => trim((string)($row['unit'] ?? '')),
+ 'qty' => $qty,
+ 'price' => $price,
+ 'amount' => round($qty * $price, 2),
+ ];
+ }
+ return $out;
+ }
+}
diff --git a/app/Controllers/PSI/ProductsController.php b/app/Controllers/PSI/ProductsController.php
new file mode 100644
index 0000000..c7f6c2d
--- /dev/null
+++ b/app/Controllers/PSI/ProductsController.php
@@ -0,0 +1,94 @@
+all();
+ return $this->renderSubsys('psi', 'psi/products', ['products' => $products], \psi_nav(), 'products');
+ }
+
+ public function create()
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ return $this->renderSubsys('psi', 'psi/product_form', ['product' => null], \psi_nav(), 'products');
+ }
+
+ public function store()
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ if (!csrf_check()) return $this->redirect('PSI/products');
+ (new Product())->insert($this->collect());
+ return $this->redirect('PSI/products');
+ }
+
+ public function edit($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ $product = (new Product())->find($id);
+ if (!$product) return $this->redirect('PSI/products');
+ return $this->renderSubsys('psi', 'psi/product_form', ['product' => $product], \psi_nav(), 'products');
+ }
+
+ public function update($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ if (!csrf_check()) return $this->redirect('PSI/products');
+ $product = (new Product())->find($id);
+ if (!$product) return $this->redirect('PSI/products');
+ (new Product())->update($id, $this->collect());
+ return $this->redirect('PSI/products');
+ }
+
+ public function destroy($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ (new Product())->delete($id);
+ return $this->redirect('PSI/products');
+ }
+
+ public function adjust($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!csrf_check()) return $this->redirect('PSI/products');
+ $qty = (float)$this->post('qty');
+ $direction = $this->post('direction') === 'out' ? 'out' : 'in';
+ $this->adjustStock('product', (int)$id, abs($qty), $direction, 'ADJ-' . date('Ymd'));
+ return $this->redirect('PSI/products');
+ }
+ $product = (new Product())->find($id);
+ if (!$product) return $this->redirect('PSI/products');
+ return $this->renderSubsys('psi', 'psi/adjust_form', ['item' => $product, 'type' => 'product'], \psi_nav(), 'products');
+ }
+
+ private function collect(): array
+ {
+ return [
+ 'code' => trim($this->post('code')),
+ 'name' => trim($this->post('name')),
+ 'spec' => trim($this->post('spec')),
+ 'unit' => trim($this->post('unit')) ?: '件',
+ 'category' => trim($this->post('category')),
+ 'style_no' => trim($this->post('style_no')),
+ 'color' => trim($this->post('color')),
+ 'size_run' => trim($this->post('size_run')),
+ 'season' => trim($this->post('season')),
+ 'year' => trim($this->post('year')),
+ 'stock' => (float)$this->post('stock'),
+ 'cost' => (float)$this->post('cost'),
+ 'price' => (float)$this->post('price'),
+ 'remark' => trim($this->post('remark')),
+ 'created_at' => date('Y-m-d'),
+ ];
+ }
+}
diff --git a/app/Controllers/PSI/PurchaseOrdersController.php b/app/Controllers/PSI/PurchaseOrdersController.php
new file mode 100644
index 0000000..905a60f
--- /dev/null
+++ b/app/Controllers/PSI/PurchaseOrdersController.php
@@ -0,0 +1,176 @@
+all());
+ $itemM = new PurchaseOrderItem();
+ foreach ($orders as &$o) { $o['_items'] = $itemM->whereAll('po_id', $o['id']); }
+ return $this->renderSubsys('psi', 'psi/purchase_orders', ['orders' => $orders], \psi_nav(), 'purchase_orders');
+ }
+
+ public function create()
+ {
+ return $this->renderSubsys('psi', 'psi/purchase_order_form', [
+ 'o' => null, 'items' => [], 'materials' => (new Material())->all(), 'products' => (new Product())->all(),
+ ], \psi_nav(), 'purchase_orders');
+ }
+
+ public function store()
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/purchase_orders/create');
+ $items = $this->parseItems();
+ if (empty($items)) { $this->flash('请至少添加一条采购明细', 'err'); return $this->redirect('PSI/purchase_orders/create'); }
+ $id = (new PurchaseOrder())->insert([
+ 'order_no' => $oNo = \psi_gen_no('PO'),
+ 'supplier_id' => (int)$this->post('supplier_id'),
+ 'supplier_name' => $supp = trim($this->post('supplier_name')),
+ 'salesman' => $buyer = trim($this->post('salesman')) ?: ($_SESSION['admin_name'] ?? ''),
+ 'status' => 'pending',
+ 'expected_at' => $this->post('expected_at') ?: null,
+ 'remark' => trim($this->post('remark')),
+ 'created_at' => date('Y-m-d H:i:s'),
+ ]);
+ $itemM = new PurchaseOrderItem();
+ foreach ($items as $it) { $it['po_id'] = $id; $itemM->insert($it); }
+ \Core\Notify::newPurchaseOrder($oNo, $supp, $buyer, $id);
+ $this->flash('采购订单已创建', 'ok');
+ if ($this->post('auto_print', '1') !== '0') {
+ return $this->redirect('PSI/purchase_orders/print/' . $id);
+ }
+ return $this->redirect('PSI/purchase_orders/show/' . $id);
+ }
+
+ public function show($id)
+ {
+ $o = (new PurchaseOrder())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ $items = (new PurchaseOrderItem())->whereAll('po_id', $id);
+ return $this->renderSubsys('psi', 'psi/purchase_order_show', ['o' => $o, 'items' => $items], \psi_nav(), 'purchase_orders');
+ }
+
+ public function edit($id)
+ {
+ $o = (new PurchaseOrder())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ if (in_array($o['status'], ['received'], true)) { $this->flash('已收货的采购订单不可编辑', 'err'); return $this->redirect('PSI/purchase_orders/show/' . $id); }
+ $items = (new PurchaseOrderItem())->whereAll('po_id', $id);
+ return $this->renderSubsys('psi', 'psi/purchase_order_form', [
+ 'o' => $o, 'items' => $items, 'materials' => (new Material())->all(), 'products' => (new Product())->all(),
+ ], \psi_nav(), 'purchase_orders');
+ }
+
+ public function update($id)
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/purchase_orders/edit/' . $id);
+ $o = (new PurchaseOrder())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ $items = $this->parseItems();
+ if (empty($items)) { $this->flash('请至少添加一条采购明细', 'err'); return $this->redirect('PSI/purchase_orders/edit/' . $id); }
+
+ (new PurchaseOrder())->update($id, [
+ 'supplier_id' => (int)$this->post('supplier_id'),
+ 'supplier_name' => trim($this->post('supplier_name')),
+ 'salesman' => trim($this->post('salesman')) ?: ($_SESSION['admin_name'] ?? ''),
+ 'expected_at' => $this->post('expected_at') ?: null,
+ 'remark' => trim($this->post('remark')),
+ ]);
+ $old = (new PurchaseOrderItem())->whereAll('po_id', $id);
+ $recv = [];
+ foreach ($old as $oi) { $recv[($oi['item_type'] ?? '') . '|' . ($oi['item_id'] ?? 0) . '|' . $oi['name']] = (int)($oi['received_qty'] ?? 0); }
+ (new PurchaseOrderItem())->deleteRaw('po_id', $id);
+ foreach ($items as $it) {
+ $key = ($it['item_type'] ?? '') . '|' . ($it['item_id'] ?? 0) . '|' . $it['name'];
+ $it['received_qty'] = $recv[$key] ?? 0;
+ $it['po_id'] = $id;
+ (new PurchaseOrderItem())->insert($it);
+ }
+ $this->flash('采购订单已更新', 'ok');
+ return $this->redirect('PSI/purchase_orders/show/' . $id);
+ }
+
+ /** 收货入库:成品增加库存 + 写入采购入库流水;更新已收数量与状态 */
+ public function receive($id)
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/purchase_orders/show/' . $id);
+ $o = (new PurchaseOrder())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ if ($o['status'] === 'received') { $this->flash('该采购订单已收货,不可重复操作', 'err'); return $this->redirect('PSI/purchase_orders/show/' . $id); }
+
+ $items = (new PurchaseOrderItem())->whereAll('po_id', $id);
+ $purchaseM = new Purchase();
+ foreach ($items as $it) {
+ if ((int)$it['qty'] <= 0) continue;
+ if ($it['item_type'] === 'product' && (int)$it['item_id'] > 0) {
+ StockHelper::adjustStock((int)$it['item_id'], (int)$it['qty'], 'in', '采购入库', $o['order_no']);
+ $purchaseM->insert([
+ 'product_id' => (int)$it['item_id'], 'qty' => (int)$it['qty'],
+ 'price' => (float)$it['price'], 'amount' => (float)$it['amount'],
+ 'supplier' => $o['supplier_name'], 'order_no' => $o['order_no'],
+ 'created_at' => date('Y-m-d H:i:s'),
+ ]);
+ }
+ (new PurchaseOrderItem())->update($it['id'], ['received_qty' => (int)$it['qty']]);
+ }
+ (new PurchaseOrder())->update($id, ['status' => 'received']);
+ $this->flash('采购订单已收货入库', 'ok');
+ return $this->redirect('PSI/purchase_orders/show/' . $id);
+ }
+
+ public function destroy($id)
+ {
+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
+ (new PurchaseOrderItem())->deleteRaw('po_id', $id);
+ (new PurchaseOrder())->delete($id);
+ $this->flash('采购订单已删除', 'ok');
+ }
+ return $this->redirect('PSI/purchase_orders');
+ }
+
+ public function printDoc($id)
+ {
+ $o = (new PurchaseOrder())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ $items = (new PurchaseOrderItem())->whereAll('po_id', $id);
+ $body = \App\Core\View::render('psi/purchase_order_print', ['o' => $o, 'items' => $items]);
+ echo \psi_print_shell('采购订单 ' . $o['order_no'], $body);
+ }
+
+ private function parseItems(): array
+ {
+ $raw = $_POST['items'] ?? [];
+ $out = [];
+ foreach ($raw as $row) {
+ $name = trim((string)($row['name'] ?? ''));
+ $qty = (int)($row['qty'] ?? 0);
+ $price = (float)($row['price'] ?? 0);
+ if ($name === '' || $qty <= 0) continue;
+ $out[] = [
+ 'item_type' => $row['item_type'] === 'product' ? 'product' : 'material',
+ 'item_id' => (int)($row['item_id'] ?? 0),
+ 'name' => $name,
+ 'spec' => trim((string)($row['spec'] ?? '')),
+ 'unit' => trim((string)($row['unit'] ?? '')),
+ 'qty' => $qty,
+ 'price' => $price,
+ 'amount' => round($qty * $price, 2),
+ 'received_qty' => 0,
+ ];
+ }
+ return $out;
+ }
+}
diff --git a/app/Controllers/PSI/PurchasesController.php b/app/Controllers/PSI/PurchasesController.php
new file mode 100644
index 0000000..499c933
--- /dev/null
+++ b/app/Controllers/PSI/PurchasesController.php
@@ -0,0 +1,71 @@
+all();
+ $suppliers = (new Supplier())->all();
+ $smap = [];
+ foreach ($suppliers as $s) { $smap[$s['id']] = $s['name']; }
+ return $this->renderSubsys('psi', 'psi/purchases', ['purchases' => $purchases, 'smap' => $smap], \psi_nav(), 'purchases');
+ }
+
+ public function create()
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ $suppliers = (new Supplier())->all();
+ $materials = (new \App\Models\PSI\Material())->all();
+ $products = (new \App\Models\PSI\Product())->all();
+ return $this->renderSubsys('psi', 'psi/purchase_form', [
+ 'purchase' => null, 'suppliers' => $suppliers,
+ 'materials' => $materials, 'products' => $products,
+ ], \psi_nav(), 'purchases');
+ }
+
+ public function store()
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ if (!csrf_check()) return $this->redirect('PSI/purchases');
+ $itemType = $this->post('item_type') === 'product' ? 'product' : 'material';
+ $itemId = (int)$this->post('item_id');
+ $qty = (float)$this->post('qty');
+ $price = (float)$this->post('price');
+ if ($itemId <= 0 || $qty <= 0) { $this->flash('请选择有效的物料/成品并填写数量'); return $this->redirect('PSI/purchases'); }
+ $no = 'PI' . date('Ymd') . '-' . substr(uniqid(), -4);
+ (new Purchase())->insert([
+ 'order_no' => $no,
+ 'supplier_id' => (int)$this->post('supplier_id'),
+ 'item_type' => $itemType,
+ 'item_id' => $itemId,
+ 'qty' => $qty,
+ 'price' => $price,
+ 'amount' => $qty * $price,
+ 'status' => 'stocked',
+ 'batch_no' => trim($this->post('batch_no')),
+ 'expected_at' => trim($this->post('expected_at')),
+ 'remark' => trim($this->post('remark')),
+ 'created_at' => date('Y-m-d'),
+ ]);
+ $this->adjustStock($itemType, $itemId, $qty, 'in', $no, trim($this->post('batch_no')));
+ $this->flash('采购入库成功,库存已更新', 'ok');
+ return $this->redirect('PSI/purchases');
+ }
+
+ public function destroy($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ (new Purchase())->delete($id);
+ return $this->redirect('PSI/purchases');
+ }
+}
diff --git a/app/Controllers/PSI/RemindersController.php b/app/Controllers/PSI/RemindersController.php
new file mode 100644
index 0000000..cb72afb
--- /dev/null
+++ b/app/Controllers/PSI/RemindersController.php
@@ -0,0 +1,111 @@
+create();
+ return;
+ }
+ if ($action === 'mark' && $id > 0) {
+ $this->markRead($id);
+ return;
+ }
+ if ($action === 'markAll') {
+ $this->markAll();
+ return;
+ }
+ $this->index();
+ }
+
+ public function index(): void
+ {
+ if (!subsys_user('psi')) { http_response_code(403); echo '无权限'; return; }
+
+ $events = (new Event())->all();
+ $events = array_reverse($events); // 最新在前
+ $uid = (int) ($_SESSION['admin_uid'] ?? 0);
+ $unread = 0;
+ foreach ($events as &$ev) {
+ $read = json_decode($ev['read_by'] ?? '[]', true) ?: [];
+ $ev['_read'] = in_array($uid, $read, true);
+ if (!$ev['_read']) $unread++;
+ }
+
+ $this->renderSubsys('psi', 'psi/reminders', [
+ 'events' => $events,
+ 'unread' => $unread,
+ ], \psi_nav(), 'reminders');
+ }
+
+ public function markRead(int $id): void
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('PSI/reminders'); }
+ if (!subsys_user('psi')) { http_response_code(403); return; }
+ $uid = (int) ($_SESSION['admin_uid'] ?? 0);
+ $ev = (new Event())->find($id);
+ if ($ev) {
+ $read = json_decode($ev['read_by'] ?? '[]', true) ?: [];
+ if (!in_array($uid, $read, true)) {
+ $read[] = $uid;
+ (new Event())->update($id, ['read_by' => json_encode($read, JSON_UNESCAPED_UNICODE)]);
+ }
+ }
+ $this->redirect('PSI/reminders');
+ }
+
+ public function markAll(): void
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->redirect('PSI/reminders'); }
+ if (!subsys_user('psi')) { http_response_code(403); return; }
+ $uid = (int) ($_SESSION['admin_uid'] ?? 0);
+ $events = (new Event())->all();
+ foreach ($events as $ev) {
+ $read = json_decode($ev['read_by'] ?? '[]', true) ?: [];
+ if (!in_array($uid, $read, true)) {
+ $read[] = $uid;
+ (new Event())->update($ev['id'], ['read_by' => json_encode($read, JSON_UNESCAPED_UNICODE)]);
+ }
+ }
+ $this->redirect('PSI/reminders');
+ }
+
+ /** 手动发起一条紧急提醒(通知负责人) */
+ public function create(): void
+ {
+ if (!subsys_user('psi')) { http_response_code(403); echo '无权限'; return; }
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!csrf_check()) { $this->flash('表单已过期,请重试', 'err'); $this->redirect('PSI/reminders'); }
+ $title = trim($this->post('title', ''));
+ $body = trim($this->post('body', ''));
+ if ($title === '') { $this->flash('请填写提醒标题', 'err'); $this->redirect('PSI/reminders'); }
+ Notify::fire('manual', $title, $body ?: $title, [
+ 'level' => 'urgent', 'url' => 'PSI/reminders',
+ ]);
+ $this->flash('已发起紧急提醒,并已通知相关负责人', 'ok');
+ $this->redirect('PSI/reminders');
+ }
+
+ $this->renderSubsys('psi', 'psi/reminder_form', [
+ 'title' => '', 'body' => '',
+ ], \psi_nav(), 'reminders');
+ }
+}
diff --git a/app/Controllers/PSI/ReportsController.php b/app/Controllers/PSI/ReportsController.php
new file mode 100644
index 0000000..247b4ab
--- /dev/null
+++ b/app/Controllers/PSI/ReportsController.php
@@ -0,0 +1,93 @@
+renderSubsys('psi', 'psi/reports', [
+ 'poCount' => count((new PurchaseOrder())->all()),
+ 'soCount' => count((new SalesOrder())->all()),
+ 'obCount' => count((new Outbound())->all()),
+ ], \psi_nav(), 'reports');
+ }
+
+ /** 采购订单明细 */
+ public function poDetail()
+ {
+ $orders = array_reverse((new PurchaseOrder())->all());
+ $itemM = new PurchaseOrderItem();
+ foreach ($orders as &$o) { $o['_items'] = $itemM->whereAll('po_id', $o['id']); }
+ return $this->renderSubsys('psi', 'psi/report_po_detail', ['orders' => $orders], \psi_nav(), 'reports');
+ }
+
+ /** 销售 / 采购订单明细(合并筛选) */
+ public function soPo()
+ {
+ $type = $this->get('type', 'all');
+ $so = $po = [];
+ if ($type !== 'purchase') {
+ $so = array_reverse((new SalesOrder())->all());
+ $soItemM = new SalesOrderItem();
+ foreach ($so as &$o) {
+ $o['_items'] = $soItemM->whereAll('so_id', $o['id']);
+ $o['_amt'] = array_sum(array_map(fn($i) => (float)($i['amount'] ?? 0), $o['_items']));
+ }
+ }
+ if ($type !== 'sales') {
+ $po = array_reverse((new PurchaseOrder())->all());
+ $poItemM = new PurchaseOrderItem();
+ foreach ($po as &$o) {
+ $o['_items'] = $poItemM->whereAll('po_id', $o['id']);
+ $o['_amt'] = array_sum(array_map(fn($i) => (float)($i['amount'] ?? 0), $o['_items']));
+ }
+ }
+ return $this->renderSubsys('psi', 'psi/report_so_po', [
+ 'type' => $type, 'so' => $so, 'po' => $po,
+ ], \psi_nav(), 'reports');
+ }
+
+ /** 交付明细:出库单 + 销售订单交付进度(应发/已发/未发) */
+ public function delivery()
+ {
+ $list = array_reverse((new Outbound())->all());
+ $obItemM = new OutboundItem();
+ foreach ($list as &$o) { $o['_items'] = $obItemM->whereAll('ob_id', $o['id']); }
+
+ $soList = (new SalesOrder())->all();
+ $soItemM = new SalesOrderItem();
+ $progress = [];
+ foreach ($soList as $so) {
+ $items = $soItemM->whereAll('so_id', $so['id']);
+ $ordered = array_sum(array_map(fn($i) => (int)($i['qty'] ?? 0), $items));
+ $delivered = array_sum(array_map(fn($i) => (int)($i['delivered_qty'] ?? 0), $items));
+ $amt = array_sum(array_map(fn($i) => (float)($i['amount'] ?? 0), $items));
+ if ($ordered <= 0) continue;
+ $progress[] = [
+ 'order_no' => $so['order_no'],
+ 'customer' => $so['customer'],
+ 'salesman' => $so['salesman'],
+ 'status' => $so['status'],
+ 'ordered' => $ordered,
+ 'delivered' => $delivered,
+ 'remain' => max(0, $ordered - $delivered),
+ 'amount' => $amt,
+ ];
+ }
+ return $this->renderSubsys('psi', 'psi/report_delivery', [
+ 'list' => $list, 'progress' => $progress,
+ ], \psi_nav(), 'reports');
+ }
+}
diff --git a/app/Controllers/PSI/SalesController.php b/app/Controllers/PSI/SalesController.php
new file mode 100644
index 0000000..750bbb8
--- /dev/null
+++ b/app/Controllers/PSI/SalesController.php
@@ -0,0 +1,110 @@
+all();
+ return $this->renderSubsys('psi', 'psi/sales', ['sales' => $sales], \psi_nav(), 'sales');
+ }
+
+ public function create()
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ $materials = (new \App\Models\PSI\Material())->all();
+ $products = (new \App\Models\PSI\Product())->all();
+ return $this->renderSubsys('psi', 'psi/sale_form', [
+ 'sale' => null, 'materials' => $materials, 'products' => $products,
+ ], \psi_nav(), 'sales');
+ }
+
+ public function store()
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ if (!csrf_check()) return $this->redirect('PSI/sales');
+ $itemType = $this->post('item_type') === 'material' ? 'material' : 'product';
+ $itemId = (int)$this->post('item_id');
+ $qty = (float)$this->post('qty');
+ $price = (float)$this->post('price');
+ if ($itemId <= 0 || $qty <= 0) { $this->flash('请选择有效的成品/物料并填写数量'); return $this->redirect('PSI/sales'); }
+ $model = $itemType === 'product' ? new Product() : new Material();
+ $item = $model->find($itemId);
+ if (!$item || (float)($item['stock'] ?? 0) < $qty) {
+ $this->flash('库存不足,无法出库(当前库存:' . (isset($item) ? (float)$item['stock'] : 0) . ')');
+ return $this->redirect('PSI/sales');
+ }
+ $no = 'SO' . date('Ymd') . '-' . substr(uniqid(), -4);
+ $saleId = (new Sales())->insert([
+ 'order_no' => $no,
+ 'customer' => trim($this->post('customer')),
+ 'channel' => $this->post('channel') === 'export' ? 'export' : 'domestic',
+ 'region' => trim($this->post('region')),
+ 'item_id' => $itemId,
+ 'item_type' => $itemType,
+ 'qty' => $qty,
+ 'price' => $price,
+ 'amount' => $qty * $price,
+ 'status' => 'shipped',
+ 'batch_no' => trim($this->post('batch_no')),
+ 'remark' => trim($this->post('remark')),
+ 'created_at' => date('Y-m-d'),
+ ]);
+ $this->adjustStock($itemType, $itemId, $qty, 'out', $no, trim($this->post('batch_no')));
+ $this->flash('销售出库成功,库存已扣减', 'ok');
+ if ($this->post('auto_print', '1') !== '0') {
+ return $this->redirect('PSI/sales/print/' . $saleId);
+ }
+ return $this->redirect('PSI/sales');
+ }
+
+ public function show($id)
+ {
+ $sale = (new Sales())->find($id);
+ if (!$sale) { \Core\App::notFound('销售记录不存在'); return; }
+ $itemInfo = $this->lookupItem($sale['item_id'] ?? 0, $sale['item_type'] ?? '');
+ return $this->renderSubsys('psi', 'psi/sales_show', [
+ 's' => $sale,
+ 'item' => $itemInfo,
+ ], \psi_nav(), 'sales');
+ }
+
+ public function printDoc($id)
+ {
+ $sale = (new Sales())->find($id);
+ if (!$sale) { \Core\App::notFound('销售记录不存在'); return; }
+ $itemInfo = $this->lookupItem($sale['item_id'] ?? 0, $sale['item_type'] ?? '');
+ $body = \Core\View::buffer('psi/sales_print', ['s' => $sale, 'item' => $itemInfo]);
+ echo \psi_print_shell('销售出库单 - ' . e($sale['order_no']), $body);
+ }
+
+ /** 尝试从成品表或物料表查找物品信息 */
+ private function lookupItem(int $itemId, string $itemType = ''): array
+ {
+ if ($itemId <= 0) return ['name' => '—', 'spec' => '', 'unit' => ''];
+ if ($itemType === 'material') {
+ $row = (new Material())->find($itemId);
+ } else {
+ $row = (new Product())->find($itemId) ?: (new Material())->find($itemId);
+ }
+ if ($row) return ['name' => $row['name'] ?? '—', 'spec' => $row['spec'] ?? '', 'unit' => $row['unit'] ?? ''];
+ return ['name' => '—', 'spec' => '', 'unit' => ''];
+ }
+
+ public function destroy($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ (new Sales())->delete($id);
+ return $this->redirect('PSI/sales');
+ }
+}
diff --git a/app/Controllers/PSI/SalesOrdersController.php b/app/Controllers/PSI/SalesOrdersController.php
new file mode 100644
index 0000000..e464931
--- /dev/null
+++ b/app/Controllers/PSI/SalesOrdersController.php
@@ -0,0 +1,156 @@
+all(); // id 升序
+ $orders = array_reverse($orders); // 最新在前
+ $itemM = new SalesOrderItem();
+ foreach ($orders as &$o) {
+ $o['_items'] = $itemM->whereAll('so_id', $o['id']);
+ }
+ return $this->renderSubsys('psi', 'psi/sales_orders', ['orders' => $orders], \psi_nav(), 'sales_orders');
+ }
+
+ public function create()
+ {
+ $products = (new Product())->all();
+ return $this->renderSubsys('psi', 'psi/sales_order_form', [
+ 'o' => null, 'items' => [], 'products' => $products,
+ ], \psi_nav(), 'sales_orders');
+ }
+
+ public function store()
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/sales_orders/create');
+ $items = $this->parseItems();
+ if (empty($items)) { $this->flash('请至少添加一条商品明细', 'err'); return $this->redirect('PSI/sales_orders/create'); }
+ $id = (new SalesOrder())->insert([
+ 'order_no' => $oNo = \psi_gen_no('SO'),
+ 'customer' => $cust = trim($this->post('customer')),
+ 'salesman' => $sales = trim($this->post('salesman')) ?: ($_SESSION['admin_name'] ?? ''),
+ 'channel' => $this->post('channel') === 'export' ? 'export' : 'domestic',
+ 'region' => trim($this->post('region')),
+ 'delivery_date' => $this->post('delivery_date') ?: null,
+ 'remark' => trim($this->post('remark')),
+ 'status' => 'pending',
+ 'created_at' => date('Y-m-d H:i:s'),
+ ]);
+ $itemM = new SalesOrderItem();
+ foreach ($items as $it) { $it['so_id'] = $id; $itemM->insert($it); }
+ \Core\Notify::newSalesOrder($oNo, $cust, $sales, $id);
+ $this->flash('销售订单已创建', 'ok');
+ if ($this->post('auto_print', '1') !== '0') {
+ return $this->redirect('PSI/sales_orders/print/' . $id);
+ }
+ return $this->redirect('PSI/sales_orders/show/' . $id);
+ }
+
+ public function show($id)
+ {
+ $o = (new SalesOrder())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ $items = (new SalesOrderItem())->whereAll('so_id', $id);
+ return $this->renderSubsys('psi', 'psi/sales_order_show', [
+ 'o' => $o, 'items' => $items,
+ ], \psi_nav(), 'sales_orders');
+ }
+
+ public function edit($id)
+ {
+ $o = (new SalesOrder())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ $items = (new SalesOrderItem())->whereAll('so_id', $id);
+ $products = (new Product())->all();
+ return $this->renderSubsys('psi', 'psi/sales_order_form', [
+ 'o' => $o, 'items' => $items, 'products' => $products,
+ ], \psi_nav(), 'sales_orders');
+ }
+
+ public function update($id)
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !csrf_check()) return $this->redirect('PSI/sales_orders/edit/' . $id);
+ $o = (new SalesOrder())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ $items = $this->parseItems();
+ if (empty($items)) { $this->flash('请至少添加一条商品明细', 'err'); return $this->redirect('PSI/sales_orders/edit/' . $id); }
+
+ (new SalesOrder())->update($id, [
+ 'customer' => trim($this->post('customer')),
+ 'salesman' => trim($this->post('salesman')) ?: ($_SESSION['admin_name'] ?? ''),
+ 'channel' => $this->post('channel') === 'export' ? 'export' : 'domestic',
+ 'region' => trim($this->post('region')),
+ 'delivery_date' => $this->post('delivery_date') ?: null,
+ 'remark' => trim($this->post('remark')),
+ ]);
+
+ // 保留已交付数量(delivered_qty)避免覆盖出库记录
+ $old = (new SalesOrderItem())->whereAll('so_id', $id);
+ $delivered = [];
+ foreach ($old as $oi) { $delivered[($oi['product_id'] ?? 0) . '|' . $oi['name']] = (int)($oi['delivered_qty'] ?? 0); }
+ (new SalesOrderItem())->deleteRaw('so_id', $id);
+ foreach ($items as $it) {
+ $key = ($it['product_id'] ?? 0) . '|' . $it['name'];
+ $it['delivered_qty'] = $delivered[$key] ?? 0;
+ $it['so_id'] = $id;
+ (new SalesOrderItem())->insert($it);
+ }
+ \psi_recompute_so($id);
+ $this->flash('销售订单已更新', 'ok');
+ return $this->redirect('PSI/sales_orders/show/' . $id);
+ }
+
+ public function destroy($id)
+ {
+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_check()) {
+ (new SalesOrderItem())->deleteRaw('so_id', $id);
+ (new SalesOrder())->delete($id);
+ $this->flash('销售订单已删除', 'ok');
+ }
+ return $this->redirect('PSI/sales_orders');
+ }
+
+ public function printDoc($id)
+ {
+ $o = (new SalesOrder())->find($id);
+ if (!$o) { \Core\App::notFound(); return; }
+ $items = (new SalesOrderItem())->whereAll('so_id', $id);
+ $body = \App\Core\View::render('psi/sales_order_print', ['o' => $o, 'items' => $items]);
+ echo \psi_print_shell('销售订单 ' . $o['order_no'], $body);
+ }
+
+ /** 解析提交的商品明细行 */
+ private function parseItems(): array
+ {
+ $raw = $_POST['items'] ?? [];
+ $out = [];
+ foreach ($raw as $row) {
+ $name = trim((string)($row['name'] ?? ''));
+ $qty = (int)($row['qty'] ?? 0);
+ $price = (float)($row['price'] ?? 0);
+ if ($name === '' || $qty <= 0) continue;
+ $out[] = [
+ 'product_id' => (int)($row['product_id'] ?? 0),
+ 'name' => $name,
+ 'spec' => trim((string)($row['spec'] ?? '')),
+ 'unit' => trim((string)($row['unit'] ?? '')),
+ 'qty' => $qty,
+ 'price' => $price,
+ 'amount' => round($qty * $price, 2),
+ 'delivered_qty' => 0,
+ ];
+ }
+ return $out;
+ }
+}
diff --git a/app/Controllers/PSI/StockController.php b/app/Controllers/PSI/StockController.php
new file mode 100644
index 0000000..88d19c6
--- /dev/null
+++ b/app/Controllers/PSI/StockController.php
@@ -0,0 +1,23 @@
+all();
+ $moves = array_reverse($moves);
+ $nameMap = [];
+ foreach ((new Material())->all() as $m) { $nameMap['material:' . $m['id']] = $m['name']; }
+ foreach ((new Product())->all() as $p) { $nameMap['product:' . $p['id']] = $p['name']; }
+ return $this->renderSubsys('psi', 'psi/stock', ['moves' => $moves, 'nameMap' => $nameMap], \psi_nav(), 'stock');
+ }
+}
diff --git a/app/Controllers/PSI/StockHelper.php b/app/Controllers/PSI/StockHelper.php
new file mode 100644
index 0000000..3fc817d
--- /dev/null
+++ b/app/Controllers/PSI/StockHelper.php
@@ -0,0 +1,42 @@
+find($itemId);
+ if (!$item) return;
+ $cur = (float)($item['stock'] ?? 0);
+ $new = $direction === 'in' ? $cur + $qty : max(0, $cur - $qty);
+ $model->update($itemId, ['stock' => $new]);
+ (new StockMove())->insert([
+ 'item_type' => $itemType,
+ 'item_id' => $itemId,
+ 'direction' => $direction,
+ 'qty' => $qty,
+ 'ref_no' => $refNo,
+ 'batch_no' => $batchNo,
+ 'remark' => '',
+ 'created_at' => date('Y-m-d'),
+ ]);
+
+ // 低库存预警:仅当出库且跌破阈值时触发(避免重复骚扰)
+ $threshold = \Core\Notify::lowStockThreshold();
+ if (\Core\Notify::lowStockEnabled()
+ && $direction === 'out'
+ && $new <= $threshold
+ && $cur > $threshold) {
+ \Core\Notify::lowStockEvent($itemType, $item['name'] ?? '', $new, $threshold, $itemId);
+ }
+ }
+}
diff --git a/app/Controllers/PSI/SuppliersController.php b/app/Controllers/PSI/SuppliersController.php
new file mode 100644
index 0000000..0af29f2
--- /dev/null
+++ b/app/Controllers/PSI/SuppliersController.php
@@ -0,0 +1,72 @@
+all();
+ return $this->renderSubsys('psi', 'psi/suppliers', ['suppliers' => $suppliers], \psi_nav(), 'suppliers');
+ }
+
+ public function create()
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ return $this->renderSubsys('psi', 'psi/supplier_form', ['supplier' => null], \psi_nav(), 'suppliers');
+ }
+
+ public function store()
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ if (!csrf_check()) return $this->redirect('PSI/suppliers');
+ (new Supplier())->insert($this->collect());
+ return $this->redirect('PSI/suppliers');
+ }
+
+ public function edit($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ $supplier = (new Supplier())->find($id);
+ if (!$supplier) return $this->redirect('PSI/suppliers');
+ return $this->renderSubsys('psi', 'psi/supplier_form', ['supplier' => $supplier], \psi_nav(), 'suppliers');
+ }
+
+ public function update($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ if (!csrf_check()) return $this->redirect('PSI/suppliers');
+ $supplier = (new Supplier())->find($id);
+ if (!$supplier) return $this->redirect('PSI/suppliers');
+ (new Supplier())->update($id, $this->collect());
+ return $this->redirect('PSI/suppliers');
+ }
+
+ public function destroy($id)
+ {
+ subsys_admin('psi') or \Core\App::forbidden('需要 PSI 管理员权限');
+ (new Supplier())->delete($id);
+ return $this->redirect('PSI/suppliers');
+ }
+
+ private function collect(): array
+ {
+ return [
+ 'name' => trim($this->post('name')),
+ 'contact' => trim($this->post('contact')),
+ 'phone' => trim($this->post('phone')),
+ 'country' => trim($this->post('country')),
+ 'type' => trim($this->post('type')),
+ 'grade' => trim($this->post('grade')),
+ 'ontime_rate'=> (float)$this->post('ontime_rate'),
+ 'qc_rate' => (float)$this->post('qc_rate'),
+ 'remark' => trim($this->post('remark')),
+ 'created_at' => date('Y-m-d'),
+ ];
+ }
+}
diff --git a/app/Controllers/PSI/UsersController.php b/app/Controllers/PSI/UsersController.php
new file mode 100644
index 0000000..c80859d
--- /dev/null
+++ b/app/Controllers/PSI/UsersController.php
@@ -0,0 +1,12 @@
+bySlug($slug);
+ if (!$p) { \Core\App::notFound(); return ''; }
+
+ $pTitle = e($p['title'] ?? '页面');
+ $pSummary = mb_substr(strip_tags($p['summary'] ?? $p['body'] ?? ''), 0, 160);
+
+ $seo = page_seo($slug, [
+ 'title' => $pTitle,
+ 'description' => $pSummary,
+ 'keywords' => '',
+ 'og_type' => 'article',
+ ]);
+ return $this->view('page/show', [
+ 'pageSeo' => [
+ 'title' => $seo['title'],
+ 'description' => $seo['description'],
+ 'keywords' => $seo['keywords'],
+ 'og_type' => $seo['og_type'] ?: 'article',
+ 'og_image' => $seo['og_image'],
+ 'canonical' => $seo['canonical'],
+ 'noindex' => $seo['noindex'],
+ 'breadcrumb' => [
+ ['name' => '首页', 'url' => site_url()],
+ ['name' => $p['title'] ?? '页面', 'url' => absolute_url()],
+ ],
+ ],
+ 'p' => $p,
+ ]);
+ }
+}
diff --git a/app/Controllers/PayController.php b/app/Controllers/PayController.php
new file mode 100644
index 0000000..dfee111
--- /dev/null
+++ b/app/Controllers/PayController.php
@@ -0,0 +1,43 @@
+verifyNotify($_POST);
+ if ($no) {
+ OrderService::markPaid($no, $_POST['trade_no'] ?? '', 'alipay');
+ echo 'success';
+ } else {
+ echo 'fail';
+ }
+ } elseif ($channel === 'wechat') {
+ $xml = file_get_contents('php://input');
+ $data = $this->xmlToArray($xml);
+ $gw = GatewayFactory::make('wechat');
+ $no = $gw->verifyNotify($data);
+ if ($no) {
+ OrderService::markPaid($no, $data['transaction_id'] ?? '', 'wechat');
+ echo '';
+ } else {
+ echo '';
+ }
+ } else {
+ echo 'invalid';
+ }
+ exit;
+ }
+
+ private function xmlToArray($xml)
+ {
+ $r = @simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
+ return $r ? json_decode(json_encode($r), true) : [];
+ }
+}
diff --git a/app/Controllers/ProductController.php b/app/Controllers/ProductController.php
new file mode 100644
index 0000000..8dd5f3a
--- /dev/null
+++ b/app/Controllers/ProductController.php
@@ -0,0 +1,132 @@
+byCategory($catId)
+ : array_filter($product->all(), fn($p) => ($p['status'] ?? 1) == 1);
+ $cat = $catId ? $category->find($catId) : null;
+
+ $catName = $cat['name'] ?? '';
+ if ($cat) {
+ $seo = page_seo('product_category', [
+ 'title' => $catName . '降温服 - 酷冰甲科技降温·定制批发',
+ 'description' => "酷冰甲{$catName}系列降温服,采用科技降温方案,专为高温作业与户外暴晒场景设计,具备清凉持久、轻便透气、可循环重复使用等特点。支持企业定制、LOGO刺绣与小批量批发,10套起订,7天打样,全国发货。",
+ 'keywords' => $catName . '降温服,' . $catName . ',降温服定制,降温背心,工业降温,酷冰甲',
+ ]);
+ foreach (['title', 'description', 'keywords'] as $f) {
+ $seo[$f] = str_replace('{cat}', $catName, $seo[$f]);
+ }
+ } else {
+ $seo = page_seo('products', [
+ 'title' => '降温服产品中心 - 水冷/相变/风冷多系列 | 酷冰甲',
+ 'description' => '酷冰甲降温服产品中心,系统展示水冷循环降温服、相变冰袋降温背心、风冷制冷背心、冰马甲等多系列产品,按使用场景与降温方式分类,参数规格与适用行业一目了然。支持企业批量定制、LOGO刺绣与免费拿样,提供专业选型建议与透明报价,助力高温作业安全防护。',
+ 'keywords' => '降温服产品,水冷降温服,相变降温服,风冷降温服,制冷背心,冰马甲,工业降温装备,降温服批发,降温服定制,酷冰甲产品',
+ ]);
+ }
+ $pageTitle = $catName ? $catName . ' - 产品中心' : '产品中心';
+ $pageDesc = $catName ? str_replace('{cat}', $catName, "酷冰甲{cat}系列降温服,科技降温,清凉定制。") : '酷冰甲全系列降温服产品:水冷循环、相变蓄冷、涡扇风冷、工业降温工装,支持小批量定制。';
+
+ return $this->view('product/index', [
+ 'pageSeo' => [
+ 'title' => $seo['title'],
+ 'description' => $seo['description'],
+ 'keywords' => $seo['keywords'],
+ 'og_type' => 'website',
+ 'og_image' => $seo['og_image'],
+ 'canonical' => $seo['canonical'],
+ 'noindex' => $seo['noindex'],
+ 'breadcrumb' => $catName ? [
+ ['name' => '首页', 'url' => site_url()],
+ ['name' => '产品中心', 'url' => site_url('products')],
+ ['name' => $catName, 'url' => absolute_url()],
+ ] : null,
+ ],
+ 'products' => $products,
+ 'categories'=> $category->all(),
+ 'activeCat' => $catId,
+ 'cat' => $cat,
+ ]);
+ }
+
+ public function show($slug)
+ {
+ $product = new Product();
+ $p = $product->where('slug', $slug);
+ if (!$p && is_numeric($slug)) { $p = $product->find((int)$slug); }
+ if (!$p) { \Core\App::notFound(); return ''; }
+
+ $category = new Category();
+ $cat = $category->find($p['category_id'] ?? 0);
+ $related = array_filter($product->byCategory($p['category_id'] ?? 0), fn($x) => $x['id'] != $p['id']);
+
+ $pName = e($p['name'] ?? '产品详情');
+ $pSummary = mb_substr(strip_tags($p['summary'] ?? $p['body'] ?? ''), 0, 160);
+ $pImage = $p['cover'] ?? '';
+ $pPrice = $p['price'] ?? '';
+ $pSku = $p['sku'] ?? ($p['model'] ?? '');
+
+ // ── Product JSON-LD Schema ──
+ $productSchema = '';
+
+ // ── 产品页 FAQ(可见文本 + FAQPage JSON-LD,GEO 信号)────
+ $faqs = [
+ ['q' => '这款降温服采用什么降温原理?', 'a' => '根据系列不同,分别采用水冷循环、相变蓄冷或涡扇风冷原理散热:水冷通过微型水泵驱动冷水循环带走体热,相变依靠冰袋/凝胶融化吸热,风冷由风扇强制对流降温。详情可在商品规格表中查看对应方案。'],
+ ['q' => '一次可使用多长时间?', 'a' => '相变冰袋方案单组可持续 2–4 小时,可随用随换;水冷与风冷方案续航取决于电池容量,具体以商品规格为准,支持备用电池延长作业时间。'],
+ ['q' => '是否支持企业定制与 LOGO 刺绣?', 'a' => '支持。提供企业 LOGO 绣字、颜色与面料定制、一人一码量体服务,10 套起订,确认图纸后 7 天打样、约 28 天批量交付。'],
+ ['q' => '如何选择合适的尺码?', 'a' => '提供标准尺码表并支持上门量体,下单后可按身高体重推荐尺码;特殊体型或工种可单独打版,确保合身与活动便利。'],
+ ];
+ $faqSchema = '';
+
+ return $this->view('product/show', [
+ 'pageSeo' => [
+ 'title' => $pName,
+ 'description' => $pSummary,
+ 'og_type' => 'product',
+ 'og_image' => $pImage,
+ 'breadcrumb' => [
+ ['name' => '首页', 'url' => site_url()],
+ ['name' => '产品中心', 'url' => site_url('products')],
+ ['name' => $p['name'] ?? '产品', 'url' => absolute_url()],
+ ],
+ 'jsonld' => $productSchema . $faqSchema,
+ ],
+ 'p' => $p,
+ 'cat' => $cat,
+ 'related' => array_slice($related, 0, 3),
+ 'specs' => $product->specsArray($p),
+ 'faqs' => $faqs,
+ ]);
+ }
+}
diff --git a/app/Controllers/Subsys/UsersController.php b/app/Controllers/Subsys/UsersController.php
new file mode 100644
index 0000000..47d760e
--- /dev/null
+++ b/app/Controllers/Subsys/UsersController.php
@@ -0,0 +1,256 @@
+sys() === 'psi' ? 'PSI 进销存' : 'CRM 客户管理';
+ }
+
+ private function nav(): array
+ {
+ return \subsys_nav($this->sys());
+ }
+
+ public function index()
+ {
+ $sys = $this->sys();
+ $other = $sys === 'crm' ? 'psi' : 'crm';
+ $col = $sys . '_role';
+ $rows = Db::query("SELECT * FROM admin_users WHERE `{$col}` != 'none' ORDER BY id ASC")->fetchAll();
+ // 子系统管理员仅管理「本系统的用户」,严格执行边界隔离:
+ // - 不可越权操作后台超管 / 全局管理员;
+ // - 不可跨界看到 / 管理另一系统(CRM 看不到 PSI,PSI 看不到 CRM)。
+ if (admin_role() !== 'super_admin') {
+ $rows = array_filter($rows, static function ($r) use ($other) {
+ // 排除后台超管 / 全局管理员
+ if (in_array($r['role'] ?? 'none', ['super_admin', 'admin'], true)) return false;
+ // 排除另一系统的管理员(防跨界)
+ if (($r[$other . '_role'] ?? 'none') === 'admin') return false;
+ return true;
+ });
+ }
+ return $this->renderSubsys($sys, 'subsys/users', [
+ 'users' => $rows,
+ 'sysName' => $this->sysName(),
+ 'pages' => \subsys_pages($sys),
+ 'sys' => $sys,
+ ], $this->nav(), 'users');
+ }
+
+ public function create()
+ {
+ $sys = $this->sys();
+ $user = [
+ 'id' => 0, 'username' => '', 'name' => '', 'status' => 1,
+ $sys . '_role' => 'user',
+ $sys . '_perms' => '',
+ ];
+ return $this->renderSubsys($sys, 'subsys/user_form', [
+ 'user' => $user,
+ 'sysName'=> $this->sysName(),
+ 'pages' => \subsys_pages($sys),
+ 'sys' => $sys,
+ 'edit' => false,
+ ], $this->nav(), 'users');
+ }
+
+ public function store()
+ {
+ $sys = $this->sys();
+ $upper = strtoupper($sys);
+ if (!csrf_check()) {
+ $this->flash('表单已过期,请重试', 'err');
+ return $this->redirect($upper . '/users/create');
+ }
+ $username = trim($this->post('username'));
+ $name = trim($this->post('name'));
+ $pwd = $this->post('password');
+ $role = $this->post($sys . '_role') === 'admin' ? 'admin' : 'user';
+ $perms = $this->collectPerms($sys);
+ $status = $this->post('status') === '0' ? 0 : 1;
+
+ if ($username === '' || $pwd === '') {
+ $this->flash('用户名和密码不能为空', 'err');
+ return $this->redirect($upper . '/users/create');
+ }
+ if (!preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) {
+ $this->flash('账号须为 3-30 位字母/数字/下划线', 'err');
+ return $this->redirect($upper . '/users/create');
+ }
+ if (strlen($pwd) < 6) {
+ $this->flash('密码至少 6 位', 'err');
+ return $this->redirect($upper . '/users/create');
+ }
+ if ((new AdminUser())->byUsername($username)) {
+ $this->flash('用户名已存在', 'err');
+ return $this->redirect($upper . '/users/create');
+ }
+
+ (new AdminUser())->insert([
+ 'username' => $username,
+ 'password' => password_hash($pwd, PASSWORD_DEFAULT),
+ 'name' => $name,
+ 'role' => 'none', // 子系统账号:全局后台角色为 none
+ $sys . '_role' => $role,
+ $sys . '_perms' => json_encode($perms, JSON_UNESCAPED_UNICODE),
+ 'status' => $status,
+ 'created_at' => date('Y-m-d H:i:s'),
+ ]);
+ $this->flash('用户已创建', 'ok');
+ return $this->redirect($upper . '/users');
+ }
+
+ public function edit($id)
+ {
+ $sys = $this->sys();
+ $user = (new AdminUser())->find($id);
+ if (!$user || !$this->manageable($user)) {
+ return $this->redirect(strtoupper($sys) . '/users');
+ }
+ return $this->renderSubsys($sys, 'subsys/user_form', [
+ 'user' => $user,
+ 'sysName'=> $this->sysName(),
+ 'pages' => \subsys_pages($sys),
+ 'sys' => $sys,
+ 'edit' => true,
+ ], $this->nav(), 'users');
+ }
+
+ public function update($id)
+ {
+ $sys = $this->sys();
+ $upper = strtoupper($sys);
+ if (!csrf_check()) {
+ $this->flash('表单已过期,请重试', 'err');
+ return $this->redirect($upper . '/users');
+ }
+ $user = (new AdminUser())->find($id);
+ if (!$user || !$this->manageable($user)) {
+ $this->flash('无权操作该用户', 'err');
+ return $this->redirect($upper . '/users');
+ }
+ $username = trim($this->post('username'));
+ $name = trim($this->post('name'));
+ $pwd = $this->post('password');
+ $role = $this->post($sys . '_role') === 'admin' ? 'admin' : 'user';
+ $perms = $this->collectPerms($sys);
+ $status = $this->post('status') === '0' ? 0 : 1;
+
+ if ($username === '' || !preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) {
+ $this->flash('账号不合法', 'err');
+ return $this->redirect($upper . '/users/edit/' . $id);
+ }
+ $existing = (new AdminUser())->byUsername($username);
+ if ($existing && (int)$existing['id'] !== (int)$id) {
+ $this->flash('用户名已存在', 'err');
+ return $this->redirect($upper . '/users/edit/' . $id);
+ }
+ if ($pwd !== '' && strlen($pwd) < 6) {
+ $this->flash('密码至少 6 位', 'err');
+ return $this->redirect($upper . '/users/edit/' . $id);
+ }
+
+ $data = [
+ 'username' => $username,
+ 'name' => $name,
+ $sys . '_role' => $role,
+ $sys . '_perms' => json_encode($perms, JSON_UNESCAPED_UNICODE),
+ 'status' => $status,
+ ];
+ if ($pwd !== '') {
+ $data['password'] = password_hash($pwd, PASSWORD_DEFAULT);
+ }
+ (new AdminUser())->update($id, $data);
+ $this->flash('用户已更新', 'ok');
+ return $this->redirect($upper . '/users');
+ }
+
+ public function destroy($id)
+ {
+ $sys = $this->sys();
+ $upper = strtoupper($sys);
+ if (!csrf_check()) {
+ $this->flash('操作已失效,请重试', 'err');
+ return $this->redirect($upper . '/users');
+ }
+ $user = (new AdminUser())->find($id);
+ if (!$user || !$this->manageable($user) || (int)$user['id'] === (int)admin_uid()) {
+ $this->flash('无法删除该用户', 'err');
+ return $this->redirect($upper . '/users');
+ }
+ (new AdminUser())->delete($id);
+ $this->flash('用户已删除', 'ok');
+ return $this->redirect($upper . '/users');
+ }
+
+ /** 管理员为该用户重置密码 */
+ public function reset($id)
+ {
+ $sys = $this->sys();
+ $upper = strtoupper($sys);
+ $user = (new AdminUser())->find($id);
+ if (!$user || !$this->manageable($user)) {
+ $this->flash('无权操作该用户', 'err');
+ return $this->redirect($upper . '/users');
+ }
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!csrf_check()) {
+ $this->flash('表单已过期,请重试', 'err');
+ return $this->redirect($upper . '/users/reset/' . $id);
+ }
+ $pwd = $this->post('password');
+ if ($pwd === '' || strlen($pwd) < 6) {
+ $this->flash('密码至少 6 位', 'err');
+ return $this->redirect($upper . '/users/reset/' . $id);
+ }
+ (new AdminUser())->update($id, ['password' => password_hash($pwd, PASSWORD_DEFAULT)]);
+ $this->flash('密码已重置', 'ok');
+ return $this->redirect($upper . '/users');
+ }
+ return $this->renderSubsys($sys, 'subsys/user_reset', [
+ 'user' => $user,
+ 'sysName'=> $this->sysName(),
+ 'sys' => $sys,
+ ], $this->nav(), 'users');
+ }
+
+ /** 收集页面权限:返回 {page: bool} 形式(与后台 UserController 一致,登录时按 JSON 解码) */
+ private function collectPerms(string $sys): array
+ {
+ $out = [];
+ foreach (\subsys_pages($sys) as $p) {
+ if ($p === 'dashboard') continue;
+ $out[$p] = $this->post('perm_' . $p) ? true : false;
+ }
+ return $out;
+ }
+
+ /** 当前登录管理员是否可管理该目标用户(严格边界隔离:只管本系统用户,禁止跨界) */
+ private function manageable(array $target): bool
+ {
+ if (admin_role() === 'super_admin') return true;
+ $sys = $this->sys();
+ $other = $sys === 'crm' ? 'psi' : 'crm';
+ // 不能管理后台超管 / 全局管理员
+ if (in_array($target['role'] ?? 'none', ['super_admin', 'admin'], true)) return false;
+ // 只能管理本系统用户
+ if (($target[$sys . '_role'] ?? 'none') === 'none') return false;
+ // 不能跨界管理另一系统的管理员(CRM 管不了 PSI,PSI 管不了 CRM)
+ if (($target[$other . '_role'] ?? 'none') === 'admin') return false;
+ return true;
+ }
+}
diff --git a/app/Core/App.php b/app/Core/App.php
new file mode 100644
index 0000000..d15f791
--- /dev/null
+++ b/app/Core/App.php
@@ -0,0 +1,407 @@
+ true,
+ 'cookie_samesite' => 'Lax',
+ 'cookie_secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), // HTTPS 下仅安全传输
+ ]);
+ }
+
+ // 2. 自动加载(Core / App\Controllers / App\Models)
+ spl_autoload_register(function ($class) {
+ $prefixes = [
+ 'Core\\' => BASE_PATH . '/app/Core/',
+ 'App\\Controllers\\' => BASE_PATH . '/app/Controllers/',
+ 'App\\Models\\' => BASE_PATH . '/app/Models/',
+ ];
+ foreach ($prefixes as $prefix => $base) {
+ if (strncmp($class, $prefix, strlen($prefix)) === 0) {
+ $rel = substr($class, strlen($prefix));
+ $file = $base . str_replace('\\', '/', $rel) . '.php';
+ if (is_file($file)) { require $file; return true; }
+ }
+ }
+ return false;
+ });
+ }
+
+ public static function run()
+ {
+ self::init();
+ self::dispatch(self::parseRoute());
+ }
+
+ /** 解析请求路径为段数组 */
+ public static function parseRoute(): array
+ {
+ $uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
+ // 仅当 SCRIPT_NAME 真实以 index.php 结尾时(Apache/生产模式)才剥离目录前缀;
+ // 内置服务器路由模式下 SCRIPT_NAME 等于请求路径,此时不剥离,避免误删 admin 等段。
+ $script = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
+ if (substr($script, -strlen('/index.php')) === '/index.php') {
+ $dir = dirname($script);
+ if ($dir !== '/' && strpos($uri, $dir) === 0) {
+ $uri = substr($uri, strlen($dir));
+ }
+ }
+ $uri = preg_replace('#/index\.php$#i', '', $uri);
+ $uri = trim($uri, '/');
+ if ($uri === '') return [];
+ return explode('/', $uri);
+ }
+
+ /** 路由分发 */
+ public static function dispatch(array $segments)
+ {
+ // 静态资源直出
+ $asset = BASE_PATH . '/public/' . implode('/', $segments);
+ if ($segments && is_file($asset) && !is_dir($asset)) {
+ self::serveFile($asset);
+ return;
+ }
+
+ $admin = self::config('app.admin_path', 'admin');
+ if (!empty($segments) && $segments[0] === $admin) {
+ self::dispatchAdmin(array_slice($segments, 1));
+ return;
+ }
+ // 分系统入口:CRM 客户管理 / PSI 进销存(统一受 super_admin 与分系统权限管辖)
+ // 大小写不敏感:/crm、/CRM、/psi、/PSI 均可进入,避免因 URL 大小写不同导致 404
+ if (!empty($segments)) {
+ $seg0 = strtoupper($segments[0]);
+ if ($seg0 === 'CRM' || $seg0 === 'PSI') {
+ self::dispatchSubsys($seg0, array_slice($segments, 1));
+ return;
+ }
+ }
+ self::dispatchFront($segments);
+ }
+
+ private static function dispatchSubsys(string $raw, array $s)
+ {
+ $sys = strtoupper($raw); // CRM / PSI
+ $sysKey = $sys === 'PSI' ? 'psi' : 'crm';
+ // 未登录或无该系统角色:拦截(拥有 crm/psi 任意角色即可进入;写操作由各 Controller 的 subsys_admin 二次把关)
+ if (!subsys_can_enter($sysKey)) {
+ self::forbidden('您没有访问「' . $sys . '」系统的权限');
+ return;
+ }
+ // 统一入口:CRM\DashboardController / PSI\DashboardController 内部再做子路由
+ $ctrl = 'App\\Controllers\\' . $sys . '\\DashboardController';
+ $action = 'dispatch';
+ if (!class_exists($ctrl)) { self::notFound("系统不存在: $sys"); return; }
+ $instance = new $ctrl();
+ if (!method_exists($instance, $action)) { self::notFound("入口不存在: $action"); return; }
+ try {
+ $html = call_user_func_array([$instance, $action], [$s]);
+ } catch (\Throwable $e) {
+ // 子系统异常兜底:保留左导 + 右框,框内显示错误,绝不白屏
+ error_log('Subsys[' . $sys . '] error: ' . $e->getMessage());
+ $html = self::subsysErrorFrame($sysKey, $instance, $e);
+ }
+ if (is_string($html)) echo $html;
+ }
+
+ private static function dispatchFront(array $s)
+ {
+ $key = $s[0] ?? '';
+ $seg2 = $s[1] ?? null;
+
+ // 验证码图片输出(独立端点,直接输出 PNG)
+ if ($key === 'captcha' && $seg2 === 'image') {
+ captcha_image();
+ return;
+ }
+
+ // Sitemap 动态生成(XML 格式,搜索引擎自动抓取)
+ if ($key === 'sitemap.xml') {
+ self::outputSitemap();
+ return;
+ }
+
+ // 资源详情路由(带第二段 slug)
+ if (($key === 'product' || $key === 'products') && $seg2) {
+ self::call('App\\Controllers\\ProductController', 'show', [$seg2]);
+ return;
+ }
+ if ($key === 'news' && $seg2) {
+ self::call('App\\Controllers\\NewsController', 'show', [$seg2]);
+ return;
+ }
+ if ($key === 'cases' && $seg2) {
+ self::call('App\\Controllers\\CaseController', 'show', [$seg2]);
+ return;
+ }
+
+ // 订单 / 支付(含网关异步通知,无需登录)
+ if ($key === 'order') {
+ $action = $s[1] ?? 'checkout';
+ $param = $s[2] ?? null;
+ self::call('App\\Controllers\\OrderController', $action, [$param]);
+ return;
+ }
+ if ($key === 'pay') {
+ $action = $s[1] ?? 'notify';
+ $param = $s[2] ?? null;
+ self::call('App\\Controllers\\PayController', $action, [$param]);
+ return;
+ }
+
+ $map = [
+ '' => ['HomeController', 'index'],
+ 'home' => ['HomeController', 'index'],
+ 'products' => ['ProductController', 'index'],
+ 'news' => ['NewsController', 'index'],
+ 'cases' => ['CaseController', 'index'],
+ 'page' => ['PageController', 'show'],
+ 'contact' => ['ContactController', 'index'],
+ ];
+ if (isset($map[$key])) {
+ [$ctrl, $action] = $map[$key];
+ $param = $seg2;
+ self::call('App\\Controllers\\' . $ctrl, $action, [$param]);
+ return;
+ }
+ // 未知路径:按单页 slug 处理(/about、/service ...)
+ self::call('App\\Controllers\\PageController', 'show', [$key]);
+ }
+
+ private static function dispatchAdmin(array $s)
+ {
+ $res = $s[0] ?? '';
+ $action = $s[1] ?? 'index';
+ $id = $s[2] ?? null;
+ $map = [
+ '' => ['Admin\\DashboardController', 'index'],
+ 'dashboard' => ['Admin\\DashboardController', 'index'],
+ 'login' => ['Admin\\AuthController', 'login'],
+ 'logout' => ['Admin\\AuthController', 'logout'],
+ 'password' => ['Admin\\AuthController', 'password'],
+ 'products' => ['Admin\\ProductController', 'index'],
+ 'categories'=> ['Admin\\CategoryController', 'index'],
+ 'news' => ['Admin\\NewsController', 'index'],
+ 'pages' => ['Admin\\PageController', 'index'],
+ 'banners' => ['Admin\\BannerController', 'index'],
+ 'settings' => ['Admin\\SettingController', 'index'],
+ 'theme' => ['Admin\\SettingController', 'theme'],
+ 'seo' => ['Admin\\SettingController', 'seo'],
+ 'users' => ['Admin\\UserController', 'index'],
+ 'system' => ['Admin\\SystemController', 'index'],
+ 'orders' => ['Admin\\OrderController', 'index'],
+ 'payments' => ['Admin\\SettingController', 'payment'],
+ 'cases' => ['Admin\\CaseController', 'index'],
+ 'media' => ['Admin\\MediaController', 'index'],
+ 'upgrade' => ['Admin\\UpgradeController', 'index'],
+ 'db' => ['Admin\\DatabaseController', 'index'],
+ ];
+ if (!isset($map[$res])) {
+ self::notFound();
+ return;
+ }
+ // 权限能力检查:未登录或权限不足直接拦截(登录态由对应控制器再兜底)
+ $capMap = [
+ 'users' => 'users',
+ 'system' => 'users',
+ 'upgrade' => 'users',
+ 'db' => 'users',
+ 'settings' => 'settings',
+ 'theme' => 'settings',
+ 'seo' => 'settings',
+ 'payments' => 'settings',
+ 'products' => 'products',
+ 'categories'=> 'categories',
+ 'news' => 'news',
+ 'pages' => 'pages',
+ 'banners' => 'banners',
+ 'orders' => 'orders',
+ 'cases' => 'cases',
+ 'media' => 'pages',
+ ];
+ if (isset($capMap[$res]) && !admin_can($capMap[$res])) {
+ self::forbidden('当前账号无访问「' . $res . '」的权限');
+ return;
+ }
+ [$ctrl, $default] = $map[$res];
+ $method = ($action === 'index') ? $default : $action;
+ self::call('App\\Controllers\\' . $ctrl, $method, [$id]);
+ }
+
+ private static function call($class, $method, array $args = [])
+ {
+ if (!class_exists($class)) { self::notFound("类不存在: $class"); return; }
+ $instance = new $class();
+ if (!method_exists($instance, $method)) { self::notFound("方法不存在: $method"); return; }
+ $html = call_user_func_array([$instance, $method], $args);
+ if (is_string($html)) echo $html;
+ }
+
+ public static function notFound($msg = '')
+ {
+ http_response_code(404);
+ echo '404
+
+ 404
页面不存在' . ($msg ? ':' . htmlspecialchars($msg) : '') . '
+
返回首页
';
+ }
+
+ public static function forbidden(string $msg = '')
+ {
+ http_response_code(403);
+ echo '403
+
+ 403
无访问权限' . ($msg ? ':' . htmlspecialchars($msg) : '') . '
+
返回后台
';
+ }
+
+ /**
+ * 子系统异常兜底框:左导 + 右框保持完整,框内显示错误信息,绝不白屏。
+ */
+ private static function subsysErrorFrame(string $sysKey, $instance, \Throwable $e): string
+ {
+ $nav = method_exists($instance, 'nav') ? $instance->nav('') : [];
+ $content = '页面加载出错
'
+ . '
系统遇到问题,错误已记录,请稍后重试或联系管理员
'
+ . '' . e('错误信息:' . $e->getMessage()) . '
';
+ return \Core\View::make('layouts/subsys', [
+ 'content' => $content,
+ '_sys' => $sysKey,
+ '_nav' => $nav,
+ '_seg' => '',
+ ]);
+ }
+
+ private static function serveFile($file)
+ {
+ $real = realpath($file);
+ $pub = realpath(BASE_PATH . '/public');
+ if ($real === false || $pub === false || strpos($real, $pub . DIRECTORY_SEPARATOR) !== 0 || !is_file($real)) {
+ self::notFound('非法文件访问');
+ return;
+ }
+ $mime = mime_content_type($real);
+ header('Content-Type: ' . $mime);
+ header('Content-Length: ' . filesize($real));
+ readfile($real);
+ exit;
+ }
+
+ /** 读取配置 config('app.name') */
+ /**
+ * 生成站内 URL(兼容子目录部署)。
+ * 用法:App::url('PSI/reminders') -> https://host/base/PSI/reminders
+ */
+ public static function url(string $path = ''): string
+ {
+ return site_url($path);
+ }
+
+ public static function config(string $key, $default = null)
+ {
+ if (empty(self::$config)) {
+ self::$config = require BASE_PATH . '/config/config.php';
+ }
+ $keys = explode('.', $key);
+ $v = self::$config;
+ foreach ($keys as $k) {
+ if (!is_array($v) || !array_key_exists($k, $v)) return $default;
+ $v = $v[$k];
+ }
+ return $v;
+ }
+
+ /** 动态生成 Sitemap XML(Google/Bing/Baidu 自动抓取) */
+ private static function outputSitemap(): void
+ {
+ header('Content-Type: application/xml; charset=utf-8');
+ echo '' . "\n";
+ echo '' . "\n";
+
+ // 首页
+ echo '' . e(site_url()) . '1.0daily' . "\n";
+
+ // 静态页面
+ $staticPages = [
+ ['url' => site_url('products'), 'prio' => '0.9', 'freq' => 'weekly'],
+ ['url' => site_url('news'), 'prio' => '0.8', 'freq' => 'daily'],
+ ['url' => site_url('cases'), 'prio' => '0.8', 'freq' => 'weekly'],
+ ['url' => site_url('page/about'), 'prio' => '0.7', 'freq' => 'monthly'],
+ ['url' => site_url('contact'), 'prio' => '0.7', 'freq' => 'monthly'],
+ ];
+ foreach ($staticPages as $p) {
+ echo '' . e($p['url']) . '' . $p['prio'] . '' . $p['freq'] . '' . "\n";
+ }
+
+ // 动态页面列表:产品 / 新闻 / 案例 / 单页
+ $models = [
+ ['class' => 'App\\Models\\Product', 'method' => 'all', 'urlPrefix' => 'product/', 'prio' => '0.85', 'freq' => 'weekly'],
+ ['class' => 'App\\Models\\News', 'method' => 'published', 'urlPrefix' => 'news/', 'prio' => '0.75', 'freq' => 'weekly'],
+ ['class' => 'App\\Models\\CustomerCase', 'method' => 'published', 'urlPrefix' => 'cases/', 'prio' => '0.75', 'freq' => 'weekly'],
+ ['class' => 'App\\Models\\Page', 'method' => 'all', 'urlPrefix' => 'page/', 'prio' => '0.6', 'freq' => 'monthly'],
+ ];
+
+ foreach ($models as $m) {
+ if (!class_exists($m['class'])) continue;
+ try {
+ $instance = new $m['class']();
+ $items = [];
+ if ($m['method'] === 'all') {
+ $items = $instance->all();
+ } elseif ($m['method'] === 'published') {
+ $items = $instance->published(200);
+ }
+ foreach ($items as $item) {
+ $slug = $item['slug'] ?? ($item['id'] ?? '');
+ if (empty($slug)) continue;
+ $url = site_url($m['urlPrefix'] . $slug);
+ $lastmod = '';
+ if (!empty($item['updated_at'])) {
+ $lastmod = '' . e($item['updated_at']) . '';
+ } elseif (!empty($item['created_at'])) {
+ $lastmod = '' . e($item['created_at']) . '';
+ }
+ echo '' . e($url) . '' . $lastmod . '' . $m['prio'] . '' . $m['freq'] . '' . "\n";
+ }
+ } catch (\Throwable $e) {
+ // 静默跳过异常的模型,保证 sitemap 完整性
+ continue;
+ }
+ }
+
+ // 产品分类页(/products?cat=ID)
+ if (class_exists('App\\Models\\Category')) {
+ try {
+ $catM = new \App\Models\Category();
+ foreach ($catM->all() as $c) {
+ $cid = $c['id'] ?? 0;
+ if (!$cid) continue;
+ $url = site_url('products?cat=' . $cid);
+ echo '' . e($url) . '0.7weekly' . "\n";
+ }
+ } catch (\Throwable $e) {
+ // 忽略
+ }
+ }
+
+ echo '';
+ exit;
+ }
+}
diff --git a/app/Core/Db.php b/app/Core/Db.php
new file mode 100644
index 0000000..5c89da1
--- /dev/null
+++ b/app/Core/Db.php
@@ -0,0 +1,43 @@
+ \PDO::ERRMODE_EXCEPTION,
+ \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
+ \PDO::ATTR_EMULATE_PREPARES => false,
+ ]);
+ }
+ return self::$pdo;
+ }
+
+ public static function fileDir(): string
+ {
+ $dir = App::config('db.file.dir');
+ if (!is_dir($dir)) mkdir($dir, 0755, true);
+ return $dir;
+ }
+
+ public static function query(string $sql, array $params = []): \PDOStatement
+ {
+ $st = self::pdo()->prepare($sql);
+ $st->execute($params);
+ return $st;
+ }
+}
diff --git a/app/Core/Helper.php b/app/Core/Helper.php
new file mode 100644
index 0000000..23f2945
--- /dev/null
+++ b/app/Core/Helper.php
@@ -0,0 +1,849 @@
+';
+ }
+ function csrf_check(): bool
+ {
+ $token = $_POST['_csrf'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
+ return isset($_SESSION['_csrf']) && hash_equals($_SESSION['_csrf'], $token);
+ }
+ /** 当前管理员是否已登录 */
+ function is_admin(): bool
+ {
+ return !empty($_SESSION['admin_logged']);
+ }
+ function admin_required()
+ {
+ if (!is_admin()) {
+ header('Location: ' . site_url('admin/login'));
+ exit;
+ }
+ }
+ /** 当前登录管理员的角色:super_admin | admin | user | none */
+ function admin_role(): string
+ {
+ return $_SESSION['admin_role'] ?? 'user';
+ }
+ /** 当前登录管理员 ID(配置文件兜底登录时为 0) */
+ function admin_uid(): ?int
+ {
+ return $_SESSION['admin_id'] ?? null;
+ }
+ /** 角色 -> 能力映射(可访问的模块/操作) */
+ function admin_role_map(): array
+ {
+ return [
+ 'super_admin' => ['dashboard', 'products', 'categories', 'news', 'cases', 'pages', 'banners', 'settings', 'theme', 'users', 'system', 'orders', 'payments', 'password'],
+ 'admin' => ['dashboard', 'products', 'categories', 'news', 'cases', 'pages', 'banners', 'orders', 'password'],
+ // user 为受限角色:默认仅仪表盘权限,不预开 products/news/cases 等后台模块。
+ // 进入 CRM/PSI 后左导据此严格收敛——CRM 操作员(后台角色=user)只看到仪表盘 + 当前子系统功能,
+ // 其余后台模块由其是否拥有对应 admin 能力决定;后台管理员/超管不受影响(见 admin / super_admin 行)。
+ 'user' => ['dashboard', 'password'],
+ // none = 无后台主角色:该账号仅作为 CRM/PSI 子系统账号存在,不拥有任何后台模块权限。
+ // 进入子系统后左导仍按 subsys_role 严格收敛,避免与「用户」混淆导致角色分配混乱。
+ 'none' => ['password'],
+ ];
+ }
+ /** 当前登录管理员是否具备某项能力 */
+ function admin_can(string $cap): bool
+ {
+ $role = admin_role();
+ return in_array($cap, admin_role_map()[$role] ?? [], true);
+ }
+ /** 角色中文名 */
+ function admin_role_label(string $role): string
+ {
+ return ['super_admin' => '超级管理员', 'admin' => '管理员', 'user' => '用户', 'none' => '无'][$role] ?? '用户';
+ }
+ /** 必须是指定角色,否则拦截(用于控制器构造函数) */
+ function role_required(string $role): void
+ {
+ if (!is_admin()) {
+ header('Location: ' . site_url('admin/login'));
+ exit;
+ }
+ if (admin_role() !== $role) {
+ \Core\App::forbidden('需要 ' . admin_role_label($role) . ' 权限');
+ exit;
+ }
+ }
+ /** 根据种子生成品牌渐变(用于占位图) */
+ function gradient($seed = 0): string
+ {
+ $angle = 110 + (intval($seed) * 37) % 160;
+ return "linear-gradient({$angle}deg,var(--c-primary),var(--c-secondary))";
+ }
+
+ /* ---------- 分系统权限(CRM / 进销存 PSI) ---------- */
+ /**
+ * 用户在某业务系统中的角色。super_admin 在任意系统都视为最高权限管理员。
+ * @param string $sys crm | psi
+ */
+ function subsys_role(string $sys): string
+ {
+ if (admin_role() === 'super_admin') return 'super_admin';
+ $key = $sys . '_role';
+ return $_SESSION[$key] ?? 'none';
+ }
+ /** 是否为某系统的管理员(含超管) */
+ function subsys_admin(string $sys): bool
+ {
+ return in_array(subsys_role($sys), ['super_admin', 'admin'], true);
+ }
+ /** 是否为某系统的用户(含管理员、超管) */
+ function subsys_user(string $sys): bool
+ {
+ return subsys_role($sys) !== 'none';
+ }
+ /** 是否能进入某业务系统(拥有该系统任意角色即可:超管/管理员/用户) */
+ function subsys_can_enter(string $sys): bool
+ {
+ return subsys_user($sys);
+ }
+
+ /** 分系统页面清单(key 与子系统 nav 的 k 对应;dashboard 始终可见) */
+ function subsys_pages(string $sys): array
+ {
+ return $sys === 'psi'
+ ? ['dashboard', 'materials', 'products', 'suppliers', 'purchases', 'sales', 'stock', 'orders',
+ 'sales_orders', 'purchase_orders', 'outbounds', 'reports', 'reminders']
+ : ['dashboard', 'customers', 'leads', 'followups', 'contacts'];
+ }
+
+ /** 当前 PSI 用户未读的紧急事件数量(用于导航铃铛徽标) */
+ function psi_unread_events(): int
+ {
+ if (!subsys_user('psi')) return 0;
+ $uid = (int) ($_SESSION['admin_uid'] ?? 0);
+ if ($uid <= 0) return 0;
+ try {
+ $events = (new \App\Models\PSI\Event())->all();
+ } catch (\Throwable $e) {
+ return 0;
+ }
+ $n = 0;
+ foreach ($events as $ev) {
+ $read = json_decode($ev['read_by'] ?? '[]', true) ?: [];
+ if (!in_array($uid, $read, true)) $n++;
+ }
+ return $n;
+ }
+ /**
+ * 当前登录用户在 $sys 系统各页面的「可见」权限数组。
+ * 子系统管理员(含超管,subsys_admin)拥有该系统全部页面;
+ * 仅“用户”角色读 session 中的 {sys}_perms(登录时写入)做细粒度控制;
+ * 旧账号无 perms 记录则默认全部可见(向后兼容,不会突然锁死)。
+ */
+ function subsys_page_perms(string $sys): array
+ {
+ // 关键修复:以“分系统角色”判定管理员,而非仅看主角色是否为 super_admin。
+ // 否则 CRM/PSI 管理员(crm_role=admin、主角色为“管理员”)会被误判为普通用户,
+ // 一旦 {sys}_perms 受限就只剩仪表盘,导致左导菜单残缺、子页面 403。
+ if (subsys_admin($sys)) {
+ return array_fill_keys(subsys_pages($sys), true) + ['dashboard' => true];
+ }
+ $perms = $_SESSION[$sys . '_perms'] ?? null;
+ $out = ['dashboard' => true];
+ foreach (subsys_pages($sys) as $p) {
+ if ($p === 'dashboard') continue;
+ $out[$p] = ($perms === null) ? true : !empty($perms[$p]);
+ }
+ return $out;
+ }
+ /** 当前用户能否进入 $sys 系统的某页面 */
+ function subsys_page_can(string $sys, string $page): bool
+ {
+ return !empty(subsys_page_perms($sys)[$page]);
+ }
+ /** 过滤子系统侧边导航:隐藏无权限页面项(dashboard 永留) */
+ function subsys_filter_nav(string $sys, array $nav): array
+ {
+ return array_values(array_filter($nav, function ($n) use ($sys) {
+ if (($n['k'] ?? '') === 'dashboard') return true;
+ return subsys_page_can($sys, $n['k']);
+ }));
+ }
+ /**
+ * 登录后落地页:依据子系统权限优先进入对应子系统仪表盘。
+ * 设计目标:拥有 CRM / PSI 权限的账号登录后直达「CRM / PSI 仪表盘」,
+ * 而非总后台仪表盘;总后台仪表盘仅留给「无任何子系统权限」的纯后台账号。
+ * - 仅拥有 CRM:进入 CRM 仪表盘
+ * - 仅拥有 PSI:进入 PSI 仪表盘
+ * - 同时拥有 CRM+PSI:默认进入 CRM 仪表盘(左侧导航可切换 PSI)
+ * - 无任何子系统权限(纯内容管理员 / 编辑):进入总后台仪表盘
+ */
+ function login_landing(): string
+ {
+ $crm = subsys_user('crm');
+ $psi = subsys_user('psi');
+ if ($crm && !$psi) return 'CRM';
+ if ($psi && !$crm) return 'PSI';
+ if ($crm && $psi) return 'CRM';
+ return 'admin';
+ }
+
+ /**
+ * 后台(admin)左侧导航全量项。admin 主后台与 CRM / PSI 子系统布局共用,
+ * 保证「后台框架一致」:进入 CRM / PSI 后左侧仍是同一套完整后台菜单(当前子系统主项高亮)。
+ * 各页面项按角色能力(admin_can)过滤,子系统入口按 subsys_user 显隐,
+ * 与 App 路由层的 capMap / 权限拦截保持一致。
+ */
+ function admin_nav_items(): array
+ {
+ $baseItems = [
+ ['k' => '', 'label' => '仪表盘', 'ic' => 'dashboard', 'url' => 'admin'],
+ ['k' => 'products', 'label' => '产品管理', 'ic' => 'snowflake', 'url' => 'admin/products'],
+ ['k' => 'categories', 'label' => '分类管理', 'ic' => 'folders', 'url' => 'admin/categories'],
+ ['k' => 'news', 'label' => '新闻管理', 'ic' => 'newspaper', 'url' => 'admin/news'],
+ ['k' => 'cases', 'label' => '客户案例', 'ic' => 'handshake', 'url' => 'admin/cases'],
+ ['k' => 'pages', 'label' => '单页管理', 'ic' => 'file-text', 'url' => 'admin/pages'],
+ ['k' => 'banners', 'label' => '轮播管理', 'ic' => 'images', 'url' => 'admin/banners'],
+ ['k' => 'settings', 'label' => '站点设置', 'ic' => 'settings', 'url' => 'admin/settings'],
+ ['k' => 'theme', 'label' => '风格设置', 'ic' => 'palette', 'url' => 'admin/theme'],
+ ];
+ $nav = [];
+ foreach ($baseItems as $it) {
+ // 仪表盘始终可见;其余按角色能力 admin_can 过滤(无权限则隐藏且不可直访)
+ if ($it['k'] === '' || admin_can($it['k'])) $nav[] = $it;
+ }
+ // 子系统入口:拥有对应系统角色的管理员可见(点击进入 CRM / PSI)
+ if (subsys_user('crm')) $nav[] = ['k' => 'crm', 'label' => '客户管理 CRM', 'ic' => 'handshake', 'url' => 'CRM'];
+ if (subsys_user('psi')) $nav[] = ['k' => 'psi', 'label' => '进销存 PSI', 'ic' => 'package', 'url' => 'PSI'];
+ // 仅超级管理员可见「用户管理 / 订单管理 / 支付设置 / 系统设置 / 数据库管理 / 数据库升级」
+ if (admin_role() === 'super_admin') {
+ $nav[] = ['k' => 'users', 'label' => '用户管理', 'ic' => 'users', 'url' => 'admin/users'];
+ $nav[] = ['k' => 'orders', 'label' => '订单管理', 'ic' => 'receipt', 'url' => 'admin/orders'];
+ $nav[] = ['k' => 'payments', 'label' => '支付设置', 'ic' => 'wallet', 'url' => 'admin/payments'];
+ $nav[] = ['k' => 'system', 'label' => '系统设置', 'ic' => 'settings', 'url' => 'admin/system'];
+ $nav[] = ['k' => 'db', 'label' => '数据库管理', 'ic' => 'database', 'url' => 'admin/db'];
+ $nav[] = ['k' => 'upgrade', 'label' => '数据库升级', 'ic' => 'upload', 'url' => 'admin/upgrade',
+ 'badge' => (\Core\Db::driver() === 'mysql' ? db_pending_upgrades() : 0) ?: null];
+ } elseif (admin_role() === 'admin') {
+ $nav[] = ['k' => 'orders', 'label' => '订单管理', 'ic' => 'receipt', 'url' => 'admin/orders'];
+ }
+ return $nav;
+ }
+
+ /**
+ * 分系统(CRM/PSI)侧边导航:统一来源,含「用户管理」(仅该系统管理员可见)。
+ * 与 layouts/subsys.php 共用,避免逐个控制器重复维护导航数组。
+ */
+ function subsys_nav(string $sys): array
+ {
+ $pages = $sys === 'psi'
+ ? [
+ ['k' => 'dashboard', 'label' => '仪表盘', 'url' => 'PSI'],
+ ['k' => 'materials', 'label' => '物料管理', 'url' => 'PSI/materials'],
+ ['k' => 'products', 'label' => '成品管理', 'url' => 'PSI/products'],
+ ['k' => 'suppliers', 'label' => '供应商', 'url' => 'PSI/suppliers'],
+ ['k' => 'purchases', 'label' => '采购入库', 'url' => 'PSI/purchases'],
+ ['k' => 'sales', 'label' => '销售出库', 'url' => 'PSI/sales'],
+ ['k' => 'stock', 'label' => '库存流水', 'url' => 'PSI/stock'],
+ ['k' => 'orders', 'label' => '订单管理', 'url' => 'PSI/orders'],
+ ['k' => 'sales_orders', 'label' => '销售订单', 'url' => 'PSI/sales_orders'],
+ ['k' => 'purchase_orders', 'label' => '采购订单', 'url' => 'PSI/purchase_orders'],
+ ['k' => 'outbounds', 'label' => '出库单', 'url' => 'PSI/outbounds'],
+ ['k' => 'reports', 'label' => '报表中心', 'url' => 'PSI/reports'],
+ ['k' => 'reminders', 'label' => '紧急提醒', 'url' => 'PSI/reminders'],
+ ]
+ : [
+ ['k' => 'dashboard', 'label' => '仪表盘', 'url' => 'CRM'],
+ ['k' => 'customers', 'label' => '客户管理', 'url' => 'CRM/customers'],
+ ['k' => 'leads', 'label' => '商机线索', 'url' => 'CRM/leads'],
+ ['k' => 'followups', 'label' => '跟进记录', 'url' => 'CRM/followups'],
+ ['k' => 'contacts', 'label' => '客户联系人', 'url' => 'CRM/contacts'],
+ ];
+ // 仅该系统管理员可管理本系统用户
+ if (subsys_admin($sys)) {
+ $pages[] = ['k' => 'users', 'label' => '用户管理', 'url' => strtoupper($sys) . '/users'];
+ if ($sys === 'psi') {
+ $pages[] = ['k' => 'notifications', 'label' => '通知设置', 'url' => 'PSI/notifications'];
+ }
+ }
+ // 仅主角色为超管/管理员时显示「管理后台」入口
+ if (in_array(admin_role(), ['super_admin', 'admin'], true)) {
+ $pages[] = ['k' => 'admin', 'label' => '管理后台', 'url' => 'admin'];
+ }
+ return $pages;
+ }
+
+ /** PSI 系统完整导航(含订单/出库/报表,所有控制器统一调用) */
+ function psi_nav(): array
+ {
+ return [
+ ['k' => 'dashboard', 'label' => '仪表盘', 'icon' => admin_icon('home', 16) ?: '📊', 'url' => 'PSI'],
+ ['k' => 'materials', 'label' => '物料管理', 'icon' => admin_icon('box', 16) ?: '🧵', 'url' => 'PSI/materials'],
+ ['k' => 'products', 'label' => '成品管理', 'icon' => admin_icon('shirt', 16) ?: '👕', 'url' => 'PSI/products'],
+ ['k' => 'suppliers', 'label' => '供应商', 'icon' => admin_icon('buildings', 16) ?: '🏭', 'url' => 'PSI/suppliers'],
+ ['k' => 'purchases', 'label' => '采购入库', 'icon' => admin_icon('download', 16) ?: '📥', 'url' => 'PSI/purchases'],
+ ['k' => 'sales', 'label' => '销售出库', 'icon' => admin_icon('upload', 16) ?: '📤', 'url' => 'PSI/sales'],
+ ['k' => 'stock', 'label' => '库存流水', 'icon' => admin_icon('package', 16) ?: '📦', 'url' => 'PSI/stock'],
+ ['k' => 'orders', 'label' => '订单管理', 'icon' => admin_icon('receipt', 16) ?: '🧾', 'url' => 'PSI/orders'],
+ ['k' => 'sales_orders', 'label' => '销售订单', 'icon' => admin_icon('file-text', 16) ?: '📝', 'url' => 'PSI/sales_orders'],
+ ['k' => 'purchase_orders', 'label' => '采购订单', 'icon' => admin_icon('file-text', 16) ?: '📋', 'url' => 'PSI/purchase_orders'],
+ ['k' => 'outbounds', 'label' => '出库单', 'icon' => admin_icon('truck', 16) ?: '🚚', 'url' => 'PSI/outbounds'],
+ ['k' => 'reports', 'label' => '报表中心', 'icon' => admin_icon('chart-bar', 16) ?: '📊', 'url' => 'PSI/reports'],
+ ];
+ }
+
+ /** 生成单据号:前缀 + 秒级时间 + 进程内计数器 + 随机,保证唯一 */
+ function psi_gen_no(string $prefix): string
+ {
+ static $c = 0;
+ $c++;
+ return strtoupper($prefix) . date('YmdHis') . str_pad($c, 4, '0', STR_PAD_LEFT) . mt_rand(10, 99);
+ }
+
+ /** 根据已交付数量重算销售订单状态(pending/partial/delivered) */
+ function psi_recompute_so(int $soId): void
+ {
+ $items = (new \App\Models\PSI\SalesOrderItem())->whereAll('so_id', $soId);
+ $total = 0; $delivered = 0;
+ foreach ($items as $it) {
+ $total += (int)($it['qty'] ?? 0);
+ $delivered += (int)($it['delivered_qty'] ?? 0);
+ }
+ $status = $total <= 0 ? 'pending' : ($delivered >= $total ? 'delivered' : ($delivered > 0 ? 'partial' : 'pending'));
+ (new \App\Models\PSI\SalesOrder())->update($soId, ['status' => $status]);
+ }
+
+ /** 打印页外壳:独立 HTML + A4 样式 + 自动打印 */
+ function psi_print_shell(string $title, string $body): string
+ {
+ $css = 'body{font-family:-apple-system,"Microsoft YaHei",sans-serif;color:#111;margin:0;padding:24px;background:#fff;}'
+ . '.doc{width:210mm;max-width:100%;margin:0 auto;}'
+ . '@media print{body{padding:0;}.no-print{display:none!important;}@page{margin:12mm;}}'
+ . '.doc h2{text-align:center;margin:0 0 4px;font-size:20px;}'
+ . '.doc .sub{text-align:center;color:#666;margin-bottom:16px;font-size:13px;}'
+ . '.doc .meta{display:flex;flex-wrap:wrap;gap:6px 28px;font-size:13px;margin:12px 0;border-bottom:1px dashed #ccc;padding-bottom:10px;}'
+ . '.doc .meta b{color:#374151;}'
+ . '.doc table{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px;}'
+ . '.doc th,.doc td{border:1px solid #bbb;padding:7px 9px;}'
+ . '.doc th{background:#f3f4f6;}'
+ . '.doc .total{text-align:right;font-weight:700;margin-top:12px;font-size:14px;}'
+ . '.doc .sign{display:flex;justify-content:space-between;margin-top:40px;font-size:13px;color:#374151;}'
+ . '.btn-print{position:fixed;top:16px;right:16px;padding:10px 18px;border-radius:8px;border:1px solid #0ea5e9;background:#0ea5e9;color:#fff;cursor:pointer;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,.15);}';
+ return '' . e($title) . ''
+ . ''
+ . ''
+ . '' . $body . '
'
+ . ''
+ . '';
+ }
+
+ /** 一次性提示消息(跨重定向,取值后清空) */
+ function flash(string $msg, string $type = 'ok'): void
+ {
+ $_SESSION['_flash'] = ['msg' => $msg, 'type' => $type];
+ }
+ function flash_html(): string
+ {
+ if (empty($_SESSION['_flash'])) return '';
+ $f = $_SESSION['_flash'];
+ unset($_SESSION['_flash']);
+ $cls = $f['type'] === 'err' ? 'alert alert-err' : 'alert alert-ok';
+ return '' . e($f['msg']) . '
';
+ }
+
+ /* ---------- mbstring 兼容层:远端 PHP 未启用 mbstring 扩展时提供兜底实现 ---------- */
+ if (!function_exists('mb_substr')) {
+ function mb_substr(string $str, int $start, ?int $length = null, string $encoding = 'UTF-8'): string
+ {
+ $chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY);
+ if ($chars === false) { $chars = preg_split('//', $str, -1, PREG_SPLIT_NO_EMPTY) ?: []; }
+ $len = $length ?? count($chars);
+ return implode('', array_slice($chars, $start, $len));
+ }
+ }
+ if (!function_exists('mb_strlen')) {
+ function mb_strlen(string $str, string $encoding = 'UTF-8'): int
+ {
+ $chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY);
+ return $chars === false ? strlen($str) : count($chars);
+ }
+ }
+ if (!function_exists('mb_strtolower')) {
+ function mb_strtolower(string $str, string $encoding = 'UTF-8'): string
+ {
+ return strtolower($str);
+ }
+ }
+ if (!function_exists('mb_strtoupper')) {
+ function mb_strtoupper(string $str, string $encoding = 'UTF-8'): string
+ {
+ return strtoupper($str);
+ }
+ }
+ if (!function_exists('mb_check_encoding')) {
+ function mb_check_encoding($var, ?string $encoding = null): bool
+ {
+ if (is_array($var) || is_object($var)) return false;
+ $str = (string)$var;
+ if ($encoding === null || strtoupper((string)$encoding) === 'UTF-8') {
+ return (bool) preg_match('/\A(?: [\x00-\x7F] | [\xC2-\xDF][\x80-\xBF] | \xE0[\xA0-\xBF][\x80-\xBF] | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2} | \xED[\x80-\x9F][\x80-\xBF] | \xF0[\x90-\xBF][\x80-\xBF]{2} | [\xF1-\xF3][\x80-\xBF]{3} | \xF4[\x80-\x8F][\x80-\xBF]{2} )*\z/x', $str);
+ }
+ return true;
+ }
+ }
+ if (!function_exists('mb_convert_encoding')) {
+ function mb_convert_encoding(string $str, string $to, ?string $from = null): string
+ {
+ if (function_exists('iconv')) {
+ $conv = @iconv($from ?? 'UTF-8', $to . '//IGNORE', $str);
+ if ($conv !== false) return $conv;
+ }
+ return $str;
+ }
+ }
+
+ /**
+ * 内联 SVG 图标(Lucide 线性风格,stroke=currentColor)。
+ * 取代后台原有 emoji 图标:清晰、随主题变色、跨平台一致。
+ */
+ function admin_icon(string $name, int $size = 20): string
+ {
+ static $paths = null;
+ if ($paths === null) {
+ $paths = [
+ 'dashboard' => '',
+ 'snowflake' => '',
+ 'folders' => '',
+ 'newspaper' => '',
+ 'handshake' => '',
+ 'file-text' => '',
+ 'images' => '',
+ 'settings' => '',
+ 'palette' => '',
+ 'users' => '',
+ 'receipt' => '',
+ 'wallet' => '',
+ 'upload' => '',
+ 'database' => '',
+ 'package' => '',
+ 'logout' => '',
+ 'key' => '',
+ 'globe' => '',
+ 'menu' => '',
+ 'plus' => '',
+ 'user' => '',
+ 'pencil' => '',
+ 'shield' => '',
+ 'lightbulb' => '',
+ 'phone' => '',
+ 'shopping-bag' => '',
+ 'truck' => '',
+ 'inbox' => '',
+ 'cart' => '',
+ 'user-plus' => '',
+ 'key' => '',
+ 'lock' => '',
+ 'bar-chart' => '',
+ 'printer' => '',
+ 'check' => '',
+ 'clipboard' => '',
+ 'trending-up' => '',
+ 'alert' => '',
+ ];
+ }
+ $p = $paths[$name] ?? $paths['file-text'];
+ return '';
+ }
+
+ /* ---------- 数据库升级:升级包目录与待升级计数 ---------- */
+
+ /** 升级包目录(放置 *.sql 后,后台「数据库升级」即提示可升级) */
+ function db_upgrade_dir(): string
+ {
+ return BASE_PATH . '/install/upgrades';
+ }
+
+ /** 将字节数格式化为人类可读大小 */
+ function human_size(int $bytes): string
+ {
+ if ($bytes < 1024) return $bytes . ' B';
+ $units = ['KB', 'MB', 'GB', 'TB'];
+ $i = -1;
+ do { $bytes /= 1024; $i++; } while ($bytes >= 1024 && $i < count($units) - 1);
+ return round($bytes, 2) . ' ' . $units[$i];
+ }
+
+ /**
+ * 统计待升级的 SQL 文件数量(未记录或内容已变更)。
+ * 用于在导航上提示「可升级」。失败安全:任何异常都返回 0。
+ */
+ function db_pending_upgrades(): int
+ {
+ try {
+ if (\Core\Db::driver() !== 'mysql') return 0;
+ $dir = db_upgrade_dir();
+ if (!is_dir($dir)) return 0;
+ $files = glob($dir . '/*.sql') ?: [];
+ if (!$files) return 0;
+ \Core\Installer::ensureUpgradeLog();
+ $applied = \Core\Db::query("SELECT file, hash FROM db_upgrades")->fetchAll(\PDO::FETCH_KEY_PAIR);
+ $n = 0;
+ foreach ($files as $f) {
+ $name = basename($f);
+ $h = md5_file($f);
+ if (!isset($applied[$name]) || $applied[$name] !== $h) {
+ $n++;
+ }
+ }
+ return $n;
+ } catch (\Throwable $e) {
+ return 0;
+ }
+ }
+
+ /* ---------- 安全响应头(质量红线:所有后台/API 统一应用) ---------- */
+ /** 生成每次请求唯一的 CSP nonce(同请求内多次调用返回同一值,并去除 base64 填充符以兼容 CSP) */
+ function csp_nonce(): string
+ {
+ static $n;
+ if ($n === null) {
+ $n = rtrim(base64_encode(random_bytes(16)), '=');
+ }
+ return $n;
+ }
+
+ function apply_security_headers(): void
+ {
+ if (headers_sent()) return;
+ $nonce = csp_nonce();
+ // 通用安全响应头从 PHP 兜底补齐:即便 Nginx 层未下发也不会缺失(防配置漂移)。
+ // 与审计整改要求一致:补充 X-Content-Type-Options / Referrer-Policy / Permissions-Policy,
+ // 并将 HSTS 升级为含 includeSubDomains + preload。若 Nginx 也下发 HSTS,重复为无害,
+ // 浏览器取更严格项(max-age 取最大值并合并指令)。
+ header("X-Content-Type-Options: nosniff");
+ header("Referrer-Policy: strict-origin-when-cross-origin");
+ header("Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()");
+ header("Strict-Transport-Security: max-age=63072000; includeSubDomains; preload");
+ // 严格 CSP(nonce 每次请求不同,必须走 PHP)
+ header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
+ }
+
+ /** 当前请求的完整绝对 URL(用于 canonical 规范链接等) */
+ function absolute_url(): string
+ {
+ $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+ $host = $_SERVER['HTTP_HOST'] ?? ($_SERVER['SERVER_NAME'] ?? 'localhost');
+ $uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
+ return $scheme . '://' . $host . $uri;
+ }
+
+ /* ---------- 图片验证码(GD 库,无第三方依赖,防机器人暴力/垃圾提交) ---------- */
+
+ /** 生成验证码字符串并存入 session,返回图片 URL */
+ function captcha_make(): array
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) @session_start();
+ // 排除易混淆字符:0/O、1/I/L、2/Z
+ $pool = '3456789ABCDEFGHJKMNPQRSTUVWXY';
+ $code = '';
+ for ($i = 0; $i < 4; $i++) {
+ $code .= $pool[random_int(0, strlen($pool) - 1)];
+ }
+ $_SESSION['captcha_code'] = $code;
+ // 添加随机参数防浏览器缓存同名图片
+ return ['url' => site_url('captcha/image') . '?_t=' . dechex(time() . random_int(1000, 9999))];
+ }
+
+ function captcha_check($input): bool
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) @session_start();
+ $ok = isset($_SESSION['captcha_code'])
+ && is_string($input)
+ && strtoupper(trim($input)) === strtoupper($_SESSION['captcha_code']);
+ unset($_SESSION['captcha_code']); // 一次性,防重放
+ return $ok;
+ }
+
+ /** 输出验证码图片(由 App 路由调用) */
+ function captcha_image(): void
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) @session_start();
+ $code = $_SESSION['captcha_code'] ?? '';
+ if (empty($code)) {
+ // 无有效 code 时生成一个默认的,避免空白图
+ $code = 'XXXX';
+ }
+
+ $w = 130;
+ $h = 44;
+ $img = imagecreatetruecolor($w, $h);
+ if (!$img) {
+ http_response_code(500);
+ exit('验证码图片生成失败');
+ }
+
+ // ── 背景 ──
+ $bg = imagecolorallocate($img, 248, 250, 252);
+ imagefilledrectangle($img, 0, 0, $w, $h, $bg);
+
+ // ── 干扰线(5 条随机弧线)────
+ $colors = [];
+ for ($i = 0; $i < 8; $i++) {
+ $colors[] = imagecolorallocate($img,
+ random_int(140, 210),
+ random_int(140, 210),
+ random_int(160, 220)
+ );
+ }
+ for ($i = 0; $i < 5; $i++) {
+ $c = $colors[random_int(0, count($colors) - 1)];
+ imageline($img,
+ random_int(0, $w), random_int(0, $h),
+ random_int(0, $w), random_int(0, $h),
+ $c
+ );
+ }
+
+ // ── 干扰像素点 ──
+ for ($i = 0; $i < 80; $i++) {
+ $c = $colors[random_int(0, count($colors) - 1)];
+ imagesetpixel($img, random_int(0, $w), random_int(0, $h), $c);
+ }
+
+ // ── 文字(每个字符独立颜色、角度、位置)────
+ $len = strlen($code);
+ $cx = 15;
+ $cy = 30;
+ $fontFile = BASE_PATH . '/public/assets/arial.ttf'; // 可选 TTF,若无则 fallback
+
+ $hasTtf = is_file($fontFile);
+ $dark = imagecolorallocate($img, 25, 55, 100);
+
+ for ($i = 0; $i < $len; $i++) {
+ $char = $code[$i];
+ $textColor = imagecolorallocate($img,
+ random_int(20, 80),
+ random_int(40, 100),
+ random_int(80, 160)
+ );
+
+ if ($hasTtf) {
+ $size = random_int(18, 22);
+ $angle = random_int(-15, 15);
+ $x = $cx + ($i * ($w - 20) / $len);
+ $y = $cy + random_int(-4, 6);
+ imagettftext($img, $size, $angle, (int)$x, (int)$y, $textColor, $fontFile, $char);
+ } else {
+ // 无 TTF 字体时用内置字体(效果较差但仍可工作)
+ $fontSize = 5;
+ $x = $cx + ($i * ($w - 20) / $len) + random_int(-2, 2);
+ $y = 12 + random_int(-3, 3);
+ imagestring($img, $fontSize, (int)$x, (int)$y, $char, $textColor);
+ }
+ }
+
+ // ── 输出 ──
+ if (ob_get_level() > 0) ob_clean();
+ header('Content-Type: image/png');
+ header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
+ header('Pragma: no-cache');
+ header('Expires: 0');
+ imagepng($img);
+ imagedestroy($img);
+ exit;
+ }
+
+ /* ---------- IP 级登录限速(fail2ban 式,文件缓存,越会话更抗爆破) ----------
+ * 双窗口独立限速(按需求定制):
+ * · 失败登录:任意 10 分钟内最多 5 次;超出即封锁 30 分钟
+ * · 成功登录:任意 30 分钟内最多 5 次;超出即限制(封锁至最早成功滑出 30 分钟窗口)
+ * 数据文件:storage/login_ip.json —— 每个 IP 记失败/成功时间戳列表 + 封锁截止时间
+ * --------------------------------------------------------------------- */
+ defined('LOGIN_FAIL_WINDOW') or define('LOGIN_FAIL_WINDOW', 600); // 失败计数窗口:10 分钟
+ defined('LOGIN_FAIL_LIMIT') or define('LOGIN_FAIL_LIMIT', 5); // 失败次数上限
+ defined('LOGIN_OK_WINDOW') or define('LOGIN_OK_WINDOW', 1800); // 成功计数窗口:30 分钟
+ defined('LOGIN_OK_LIMIT') or define('LOGIN_OK_LIMIT', 5); // 成功次数上限
+ defined('LOGIN_BLOCK_SECS') or define('LOGIN_BLOCK_SECS', 1800); // 超限后封锁时长:30 分钟
+
+ function _ip_login_load(): array
+ {
+ $file = BASE_PATH . '/storage/login_ip.json';
+ return is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : [];
+ }
+ function _ip_login_save(array $data): void
+ {
+ $file = BASE_PATH . '/storage/login_ip.json';
+ if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true);
+ @file_put_contents($file, json_encode($data));
+ }
+ /** 裁剪过期时间戳并按规则重算封锁截止时间(就地修改 $st) */
+ function _ip_login_prune(array &$st, int $now): void
+ {
+ $st['fail'] = array_values(array_filter((array)($st['fail'] ?? []), fn($t) => ($now - (int)$t) < LOGIN_FAIL_WINDOW));
+ $st['ok'] = array_values(array_filter((array)($st['ok'] ?? []), fn($t) => ($now - (int)$t) < LOGIN_OK_WINDOW));
+ if (!isset($st['block_until']) || !is_numeric($st['block_until'])) $st['block_until'] = 0;
+ if ($st['block_until'] <= $now) {
+ if (count($st['fail']) >= LOGIN_FAIL_LIMIT) {
+ // 失败 5 次 / 10 分钟 → 锁 30 分钟
+ $st['block_until'] = $now + LOGIN_BLOCK_SECS;
+ } elseif (count($st['ok']) >= LOGIN_OK_LIMIT) {
+ // 成功 5 次 / 30 分钟 → 锁到最早一次成功滑出窗口
+ $oldest = min($st['ok']);
+ $st['block_until'] = max($now + 60, $oldest + LOGIN_OK_WINDOW);
+ }
+ }
+ }
+ function ip_login_blocked(string $ip): bool
+ {
+ $now = time();
+ $st = _ip_login_load()[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
+ _ip_login_prune($st, $now);
+ return ($st['block_until'] ?? 0) > $now;
+ }
+ /** 返回剩余封锁秒数(已解封为 0),供 Retry-After 使用 */
+ function ip_login_remaining(string $ip): int
+ {
+ $now = time();
+ $st = _ip_login_load()[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
+ _ip_login_prune($st, $now);
+ return max(0, (int)($st['block_until'] ?? 0) - $now);
+ }
+ function ip_login_register_fail(string $ip): void
+ {
+ $now = time();
+ $data = _ip_login_load();
+ $st = $data[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
+ _ip_login_prune($st, $now);
+ $st['fail'][] = $now;
+ _ip_login_prune($st, $now); // 追加后重新评估是否触发封锁
+ $data[$ip] = $st;
+ _ip_login_save($data);
+ }
+ function ip_login_register_success(string $ip): void
+ {
+ $now = time();
+ $data = _ip_login_load();
+ $st = $data[$ip] ?? ['fail' => [], 'ok' => [], 'block_until' => 0];
+ _ip_login_prune($st, $now);
+ $st['fail'] = []; // 成功登录重置失败计数(防爆破计数器归零)
+ $st['ok'][] = $now; // 记录一次成功,纳入「30 分钟 5 次」上限
+ _ip_login_prune($st, $now);
+ $data[$ip] = $st;
+ _ip_login_save($data);
+ }
+ function ip_login_clear(string $ip): void
+ {
+ $data = _ip_login_load();
+ unset($data[$ip]);
+ _ip_login_save($data);
+ }
+
+ /* ---------- 通用 IP 级限速(可用于任意提交场景,如联系表单) ---------- */
+ function ip_rate_blocked(string $ip, string $bucket, int $limit, int $window): bool
+ {
+ $file = BASE_PATH . '/storage/rate_' . $bucket . '.json';
+ if (!is_file($file)) return false;
+ $data = json_decode(@file_get_contents($file), true) ?: [];
+ if (!isset($data[$ip])) return false;
+ return $data[$ip]['count'] >= $limit;
+ }
+ function ip_rate_register(string $ip, string $bucket, int $window): void
+ {
+ $file = BASE_PATH . '/storage/rate_' . $bucket . '.json';
+ if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true);
+ $data = is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : [];
+ $now = time();
+ if (!isset($data[$ip]) || ($data[$ip]['time'] + $window) < $now) {
+ $data[$ip] = ['count' => 0, 'time' => $now];
+ }
+ $data[$ip]['count']++;
+ @file_put_contents($file, json_encode($data));
+ }
+}
+
+if (!function_exists('page_seo')) {
+ /**
+ * 取页面 SEO(标题/描述/关键词/OG/规范链接/收录开关)。
+ * 优先读 page_seo 表;无记录或字段缺失时退回控制器传入的默认值。
+ * @param string $key page_key(home/products/news/cases/about/contact...)
+ * @param array $default 默认 SEO 数组(title/description/keywords/og_type)
+ * @return array {title,description,keywords,og_type,og_image,canonical,noindex}
+ */
+ function page_seo(string $key, array $default = []): array
+ {
+ $def = array_merge([
+ 'title' => '',
+ 'description' => '',
+ 'keywords' => '',
+ 'og_type' => 'website',
+ 'og_image' => '',
+ 'canonical' => '',
+ 'noindex' => 0,
+ ], $default);
+
+ try {
+ $row = (new \App\Models\PageSeo())->getByKey($key);
+ } catch (\Throwable $e) {
+ $row = null;
+ }
+
+ if (!$row) {
+ return $def;
+ }
+
+ return [
+ 'title' => $row['title'] ?? $def['title'],
+ 'description' => $row['description'] ?? $def['description'],
+ 'keywords' => $row['keywords'] ?? $def['keywords'],
+ 'og_type' => $row['og_type'] ?? $def['og_type'],
+ 'og_image' => $row['og_image'] ?? $def['og_image'],
+ 'canonical' => $row['canonical'] ?? $def['canonical'],
+ 'noindex' => $row['noindex'] ?? $def['noindex'],
+ ];
+ }
+}
diff --git a/app/Core/Installer.php b/app/Core/Installer.php
new file mode 100644
index 0000000..db826be
--- /dev/null
+++ b/app/Core/Installer.php
@@ -0,0 +1,365 @@
+ 1,
+ 'username' => $super['username'],
+ 'password' => password_hash($super['password'], PASSWORD_DEFAULT),
+ 'name' => $super['name'] ?? '超级管理员',
+ 'role' => 'super_admin',
+ 'crm_role' => 'admin',
+ 'psi_role' => 'admin',
+ 'status' => 1,
+ 'created_at' => date('Y-m-d'),
+ ]];
+ }
+ $driver = Db::driver();
+ $msgs = [];
+ if ($driver === 'file') {
+ $dir = Db::fileDir();
+ foreach ($seed as $table => $rows) {
+ $i = 1;
+ foreach ($rows as &$r) { if (!isset($r['id'])) { $r['id'] = $i; } $i++; }
+ unset($r);
+ file_put_contents($dir . "/{$table}.json", json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+ $msgs[] = "写入 {$table}.json (" . count($rows) . " 条)";
+ }
+ foreach (['crm_contacts'] as $t) {
+ $f = $dir . "/{$t}.json";
+ if (!is_file($f)) { file_put_contents($f, '[]'); $msgs[] = "创建 {$t}.json"; }
+ }
+ } else {
+ $pdo = Db::pdo();
+ $pdo->exec(file_get_contents(BASE_PATH . '/install/schema.sql'));
+ $msgs[] = "数据表已创建/更新(基础 + CRM + PSI)";
+ foreach (['categories', 'products', 'news', 'cases'] as $t) {
+ try { $pdo->exec("ALTER TABLE `{$t}` ADD COLUMN `layout` TEXT"); } catch (\Throwable $e) {}
+ }
+ self::ensureColumns($pdo, $msgs);
+ $map = self::modelMap();
+ foreach ($seed as $table => $rows) {
+ $m = $map[$table] ?? null;
+ if (!$m) continue;
+ foreach ($rows as $r) { $m->insert($r); }
+ $msgs[] = "插入 {$table} (" . count($rows) . " 条)";
+ }
+ }
+ try { Theme::regenerate(); $msgs[] = "主题样式 theme.css 已生成"; } catch (\Throwable $e) {}
+ @file_put_contents(BASE_PATH . '/storage/installed.lock', date('Y-m-d H:i:s') . " installed\n");
+ return $msgs;
+ }
+
+ /** 数据升级(后台按钮 / 已安装系统):补齐新模块表/列,按 id 补齐缺失种子,保留客户数据 */
+ public static function upgrade(): array
+ {
+ $seed = require BASE_PATH . '/install/seed.php';
+ $driver = Db::driver();
+ $msgs = [];
+ $content = [
+ 'categories', 'products', 'news', 'cases', 'pages', 'banners', 'settings',
+ 'crm_customers', 'crm_leads', 'crm_followups', 'crm_contacts',
+ 'psi_materials', 'psi_products', 'psi_suppliers', 'psi_purchases', 'psi_sales',
+ ];
+ if ($driver === 'file') {
+ $dir = Db::fileDir();
+ foreach ($seed as $table => $rows) {
+ if (!in_array($table, $content, true)) continue;
+ $ef = $dir . "/{$table}.json";
+ $existing = [];
+ if (is_file($ef)) {
+ $ed = @json_decode(file_get_contents($ef), true);
+ if (is_array($ed)) $existing = $ed;
+ }
+ if ($table === 'settings') {
+ $keys = [];
+ foreach ($existing as $er) { if (isset($er['skey'])) $keys[$er['skey']] = true; }
+ foreach ($rows as $r) { if (!isset($keys[$r['skey']])) $existing[] = $r; }
+ } else {
+ $ids = [];
+ foreach ($existing as $er) { if (isset($er['id'])) $ids[$er['id']] = true; }
+ foreach ($rows as $r) {
+ $rid = $r['id'] ?? null;
+ if ($rid !== null && !isset($ids[$rid])) $existing[] = $r;
+ }
+ }
+ file_put_contents($ef, json_encode($existing, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+ $msgs[] = "刷新 {$table}.json (" . count($existing) . " 条,保留客户数据)";
+ }
+ foreach (['orders', 'payments', 'crm_contacts'] as $t) {
+ $f = $dir . "/{$t}.json";
+ if (!is_file($f)) { file_put_contents($f, '[]'); $msgs[] = "创建 {$t}.json"; }
+ }
+ } else {
+ $pdo = Db::pdo();
+ $pdo->exec(file_get_contents(BASE_PATH . '/install/schema.sql'));
+ $msgs[] = "数据表已创建/更新(补齐新增模块表)";
+ foreach (['categories', 'products', 'news', 'cases'] as $t) {
+ try { $pdo->exec("ALTER TABLE `{$t}` ADD COLUMN `layout` TEXT"); } catch (\Throwable $e) {}
+ }
+ self::ensureColumns($pdo, $msgs);
+ $map = self::modelMap();
+ foreach ($seed as $table => $rows) {
+ if (!in_array($table, $content, true)) continue;
+ $m = $map[$table] ?? null;
+ if (!$m) continue;
+ if ($table === 'settings') {
+ $keys = [];
+ try { $rs = Db::query("SELECT skey FROM `settings`"); foreach ($rs->fetchAll() as $er) $keys[$er['skey']] = true; } catch (\Throwable $e) {}
+ $added = 0;
+ foreach ($rows as $r) { if (!isset($keys[$r['skey']])) { $m->insert($r); $added++; } }
+ $msgs[] = "补齐 settings (" . $added . " 条)";
+ continue;
+ }
+ $ids = [];
+ try { $rs = Db::query("SELECT id FROM `{$table}`"); foreach ($rs->fetchAll() as $er) $ids[$er['id']] = true; } catch (\Throwable $e) {}
+ $added = 0;
+ foreach ($rows as $r) {
+ $rid = $r['id'] ?? null;
+ if ($rid !== null && !isset($ids[$rid])) { $m->insert($r); $added++; }
+ }
+ $msgs[] = "补齐 {$table} 缺失种子 (" . $added . " 条,已有 " . count($ids) . " 条保留)";
+ }
+ }
+ try { Theme::regenerate(); $msgs[] = "主题样式 theme.css 已生成"; } catch (\Throwable $e) {}
+ return $msgs;
+ }
+
+ /** 执行单个 SQL 文件(用于「数据库升级」的升级包)。失败会抛出异常由调用方捕获。 */
+ public static function applySqlFile(string $path): void
+ {
+ if (!is_file($path)) {
+ throw new \RuntimeException("升级文件不存在:{$path}");
+ }
+ $sql = file_get_contents($path);
+ if ($sql === false || trim($sql) === '') return;
+ self::dbExecute($sql);
+ }
+
+ /**
+ * 分段执行 SQL(支持 DELIMITER 命令,兼容 PDO 不支持的客户端语法)。
+ * @param string $sql 原始 SQL 文本(含 / 不含 DELIMITER 均可)
+ * @throws \Throwable
+ */
+ private static function dbExecute(string $sql): void
+ {
+ $pdo = Db::pdo();
+ // 逐行解析 DELIMITER 与多语句拆分
+ $lines = explode("\n", $sql);
+ $delimiter = ';';
+ $buffer = '';
+ foreach ($lines as $raw) {
+ $line = trim($raw);
+ // 跳过空行与单行注释(兼容 PHP 7,不用 str_starts_with)
+ if ($line === '' || strpos($line, '--') === 0 || strpos($line, '#') === 0) continue;
+ // 检测 DELIMITER 命令(客户端命令,不进入 SQL 执行)
+ if (strtoupper(substr($line, 0, 10)) === 'DELIMITER ') {
+ // 积压的 SQL 遇到 DELIMITER 修改时先执行
+ $stmt = trim($buffer);
+ if ($stmt !== '') {
+ if ($pdo->exec($stmt) === false) {
+ $err = $pdo->errorInfo();
+ throw new \RuntimeException("SQL 执行错误:{$err[2]} (SQL: " . substr($stmt, 0, 120) . ')');
+ }
+ }
+ $buffer = '';
+ $delimiter = trim(substr($line, 10));
+ continue;
+ }
+ $buffer .= $raw . "\n";
+ // 检查 buffer 是否以当前分隔符结尾(忽略末尾空白与行末注释)
+ $trimmed = rtrim($buffer, " \t\r\n");
+ if (substr($trimmed, -strlen($delimiter)) === $delimiter) {
+ $stmt = rtrim(substr($trimmed, 0, -strlen($delimiter)));
+ $stmt = trim($stmt);
+ if ($stmt !== '') {
+ if ($pdo->exec($stmt) === false) {
+ $err = $pdo->errorInfo();
+ throw new \RuntimeException("SQL 执行错误:{$err[2]} (SQL: " . substr($stmt, 0, 120) . ')');
+ }
+ }
+ $buffer = '';
+ }
+ }
+ // 最后一段(无结束分隔符的纯 SQL)
+ $stmt = trim($buffer);
+ if ($stmt !== '') {
+ if ($pdo->exec($stmt) === false) {
+ $err = $pdo->errorInfo();
+ throw new \RuntimeException("SQL 执行错误:{$err[2]} (SQL: " . substr($stmt, 0, 120) . ')');
+ }
+ }
+ }
+
+ /** 确保升级记录表存在(幂等) */
+ public static function ensureUpgradeLog(): void
+ {
+ if (Db::driver() !== 'mysql') return;
+ try {
+ Db::pdo()->exec("CREATE TABLE IF NOT EXISTS db_upgrades (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ file VARCHAR(255) NOT NULL COMMENT '升级包文件名',
+ hash CHAR(32) NOT NULL COMMENT '文件 MD5,用于识别内容变更',
+ applied_at DATETIME NOT NULL COMMENT '执行时间',
+ applied_by VARCHAR(64) DEFAULT '' COMMENT '操作人',
+ note TEXT COMMENT '备注'
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+ } catch (\Throwable $e) {}
+ }
+ public static function isInstalled(): bool
+ {
+ if (Db::driver() !== 'mysql') return true; // file 模式无「安装」概念
+ try {
+ $cnt = (new \App\Models\AdminUser())->count();
+ return $cnt > 0;
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+
+ /** 返回每张预期表的存在状态(mysql 模式) */
+ public static function tableStatus(): array
+ {
+ if (Db::driver() !== 'mysql') return [];
+ $pdo = Db::pdo();
+ $exist = $pdo->query("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()")->fetchAll(\PDO::FETCH_COLUMN);
+ $all = [
+ 'categories', 'products', 'news', 'cases', 'pages', 'banners', 'admin_users',
+ 'crm_customers', 'crm_leads', 'crm_followups', 'crm_contacts',
+ 'psi_materials', 'psi_products', 'psi_suppliers', 'psi_purchases', 'psi_sales', 'psi_stock_moves',
+ 'settings', 'orders', 'payments',
+ ];
+ $out = [];
+ foreach ($all as $t) { $out[$t] = in_array($t, $exist, true); }
+ return $out;
+ }
+
+ /** 补齐各表新增列(防御性,schema.sql 已含,此处兜底供「数据升级」使用,幂等) */
+ private static function ensureColumns($pdo, array &$msgs): void
+ {
+ $map = [
+ 'pages' => [
+ 'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
+ ],
+ 'products' => [
+ 'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
+ ],
+ 'news' => [
+ 'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
+ ],
+ 'cases' => [
+ 'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
+ ],
+ 'categories' => [
+ 'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
+ ],
+ 'admin_users' => [
+ 'crm_role' => "VARCHAR(20) DEFAULT 'none'",
+ 'psi_role' => "VARCHAR(20) DEFAULT 'none'",
+ 'crm_perms' => "TEXT",
+ 'psi_perms' => "TEXT",
+ ],
+ 'crm_customers' => [
+ 'customer_no' => "VARCHAR(40) DEFAULT ''",
+ 'industry' => "VARCHAR(20) DEFAULT ''",
+ 'region' => "VARCHAR(40) DEFAULT ''",
+ 'credit_limit'=> "DECIMAL(12,2) DEFAULT 0",
+ 'status' => "VARCHAR(20) DEFAULT 'lead'",
+ ],
+ 'crm_leads' => [
+ 'source' => "VARCHAR(30) DEFAULT ''",
+ 'probability' => "TINYINT DEFAULT 0",
+ ],
+ 'crm_followups' => [
+ 'way' => "VARCHAR(20) DEFAULT ''",
+ 'result' => "VARCHAR(60) DEFAULT ''",
+ ],
+ 'psi_materials' => [
+ 'composition' => "VARCHAR(60) DEFAULT ''",
+ 'weight_gsm' => "DECIMAL(8,2) DEFAULT 0",
+ 'width_cm' => "DECIMAL(8,2) DEFAULT 0",
+ 'color' => "VARCHAR(40) DEFAULT ''",
+ 'batch_no' => "VARCHAR(40) DEFAULT ''",
+ ],
+ 'psi_products' => [
+ 'style_no' => "VARCHAR(40) DEFAULT ''",
+ 'color' => "VARCHAR(40) DEFAULT ''",
+ 'size_run' => "VARCHAR(60) DEFAULT ''",
+ 'season' => "VARCHAR(20) DEFAULT ''",
+ 'year' => "VARCHAR(10) DEFAULT ''",
+ ],
+ 'psi_suppliers' => [
+ 'type' => "VARCHAR(20) DEFAULT ''",
+ 'grade' => "VARCHAR(20) DEFAULT ''",
+ 'ontime_rate'=> "DECIMAL(5,2) DEFAULT 0",
+ 'qc_rate' => "DECIMAL(5,2) DEFAULT 0",
+ ],
+ 'psi_purchases' => [
+ 'batch_no' => "VARCHAR(40) DEFAULT ''",
+ 'expected_at' => "VARCHAR(20) DEFAULT ''",
+ ],
+ 'psi_sales' => [
+ 'region' => "VARCHAR(40) DEFAULT ''",
+ 'batch_no' => "VARCHAR(40) DEFAULT ''",
+ ],
+ 'psi_stock_moves' => [
+ 'batch_no' => "VARCHAR(40) DEFAULT ''",
+ ],
+ ];
+ foreach ($map as $table => $cols) {
+ try {
+ $have = $pdo->query("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{$table}'")->fetchAll(\PDO::FETCH_COLUMN);
+ } catch (\Throwable $e) {
+ continue;
+ }
+ foreach ($cols as $c => $def) {
+ if (!in_array($c, $have, true)) {
+ try {
+ $pdo->exec("ALTER TABLE `{$table}` ADD COLUMN `{$c}` {$def}");
+ $msgs[] = "已添加 {$table}.{$c}";
+ } catch (\Throwable $e) {}
+ }
+ }
+ }
+ }
+
+ private static function modelMap(): array
+ {
+ return [
+ 'categories' => new \App\Models\Category(),
+ 'products' => new \App\Models\Product(),
+ 'news' => new \App\Models\News(),
+ 'cases' => new \App\Models\CustomerCase(),
+ 'pages' => new \App\Models\Page(),
+ 'banners' => new \App\Models\Banner(),
+ 'admin_users'=> new \App\Models\AdminUser(),
+ 'settings' => new \App\Models\Setting(),
+ 'orders' => new \App\Models\Order(),
+ 'payments' => new \App\Models\Payment(),
+ 'crm_customers' => new \App\Models\CRM\Customer(),
+ 'crm_leads' => new \App\Models\CRM\Lead(),
+ 'crm_followups' => new \App\Models\CRM\FollowUp(),
+ 'crm_contacts' => new \App\Models\CRM\Contact(),
+ 'psi_suppliers' => new \App\Models\PSI\Supplier(),
+ 'psi_materials' => new \App\Models\PSI\Material(),
+ 'psi_products' => new \App\Models\PSI\Product(),
+ 'psi_purchases' => new \App\Models\PSI\Purchase(),
+ 'psi_sales' => new \App\Models\PSI\Sales(),
+ 'psi_stock_moves'=> new \App\Models\PSI\StockMove(),
+ ];
+ }
+}
diff --git a/app/Core/Model.php b/app/Core/Model.php
new file mode 100644
index 0000000..c263041
--- /dev/null
+++ b/app/Core/Model.php
@@ -0,0 +1,162 @@
+table . '.json';
+ }
+ private function read(): array
+ {
+ $f = $this->file();
+ if (!is_file($f)) return [];
+ $d = json_decode(file_get_contents($f), true);
+ return is_array($d) ? $d : [];
+ }
+ private function write(array $rows): void
+ {
+ $rows = $this->sanitizeUtf8($rows);
+ // JSON_INVALID_UTF8_SUBSTITUTE (PHP 7.2+) 保证即使存在非法 UTF-8 也不会让 json_encode 返回 false,
+ // 避免 file_put_contents(false) 把整个数据文件清空为 0 字节(灾难性数据丢失)。
+ $json = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE);
+ if ($json === false || $json === '') {
+ error_log('[Model::write] json_encode failed for ' . $this->table . '; aborting write to avoid data loss');
+ return;
+ }
+ $f = $this->file();
+ $dir = dirname($f);
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0755, true);
+ }
+ // 写入失败(最常见:服务器目录/文件权限不足,PHP 进程无写权限)必须记录日志,
+ // 否则会“假成功”——页面跳转回去、用户以为保存了,数据却没变。
+ $bytes = @file_put_contents($f, $json);
+ if ($bytes === false) {
+ $err = error_get_last();
+ error_log('[Model::write] FAILED to write ' . $f . ' — ' . ($err['message'] ?? 'unknown error') .
+ ' | 请检查目录/文件所有者是否为 PHP 运行用户(宝塔通常是 www)并赋予写权限');
+ }
+ }
+
+ /** 递归将数组中的字符串修复为合法 UTF-8,剔除非法字节序列 */
+ private function sanitizeUtf8($v)
+ {
+ if (is_array($v)) {
+ return array_map([$this, 'sanitizeUtf8'], $v);
+ }
+ if (is_string($v) && !mb_check_encoding($v, 'UTF-8')) {
+ return mb_convert_encoding($v, 'UTF-8', 'UTF-8');
+ }
+ return $v;
+ }
+ private function sort(array &$rows): void
+ {
+ $col = $this->orderBy;
+ usort($rows, function ($a, $b) use ($col) {
+ $va = $a[$col] ?? 0; $vb = $b[$col] ?? 0;
+ return $va <=> $vb;
+ });
+ }
+
+ /* ---------- 通用 CRUD ---------- */
+ public function all(): array
+ {
+ if (Db::driver() === 'mysql') {
+ return Db::query("SELECT * FROM `{$this->table}` ORDER BY `{$this->orderBy}` ASC")->fetchAll();
+ }
+ $rows = $this->read(); $this->sort($rows); return $rows;
+ }
+
+ public function find($id)
+ {
+ if (Db::driver() === 'mysql') {
+ return Db::query("SELECT * FROM `{$this->table}` WHERE `{$this->pk}`=?", [$id])->fetch() ?: null;
+ }
+ foreach ($this->read() as $r) if (($r[$this->pk] ?? null) == $id) return $r;
+ return null;
+ }
+
+ public function where(string $col, $val)
+ {
+ if (Db::driver() === 'mysql') {
+ return Db::query("SELECT * FROM `{$this->table}` WHERE `{$col}`=?", [$val])->fetch() ?: null;
+ }
+ foreach ($this->read() as $r) if (($r[$col] ?? null) == $val) return $r;
+ return null;
+ }
+
+ public function whereAll(string $col, $val): array
+ {
+ if (Db::driver() === 'mysql') {
+ return Db::query("SELECT * FROM `{$this->table}` WHERE `{$col}`=? ORDER BY `{$this->orderBy}` ASC", [$val])->fetchAll();
+ }
+ $out = []; foreach ($this->read() as $r) if (($r[$col] ?? null) == $val) $out[] = $r;
+ $this->sort($out); return $out;
+ }
+
+ public function insert(array $data)
+ {
+ if (Db::driver() === 'mysql') {
+ $cols = array_keys($data);
+ $sql = "INSERT INTO `{$this->table}` (`" . implode('`,`', $cols) . "`) VALUES (" . implode(',', array_fill(0, count($cols), '?')) . ")";
+ Db::query($sql, array_values($data));
+ return Db::pdo()->lastInsertId();
+ }
+ $rows = $this->read();
+ $id = $rows ? (max(array_column($rows, $this->pk)) + 1) : 1;
+ $data[$this->pk] = $id;
+ $rows[] = $data; $this->write($rows);
+ return $id;
+ }
+
+ public function update($id, array $data): void
+ {
+ if (Db::driver() === 'mysql') {
+ $cols = array_keys($data);
+ $sql = "UPDATE `{$this->table}` SET `" . implode('`=?,`', $cols) . "`=? WHERE `{$this->pk}`=?";
+ Db::query($sql, array_merge(array_values($data), [$id]));
+ return;
+ }
+ $rows = $this->read();
+ foreach ($rows as &$r) {
+ if (($r[$this->pk] ?? null) == $id) { $r = array_merge($r, $data); break; }
+ }
+ $this->write($rows);
+ }
+
+ public function delete($id): void
+ {
+ if (Db::driver() === 'mysql') {
+ Db::query("DELETE FROM `{$this->table}` WHERE `{$this->pk}`=?", [$id]);
+ return;
+ }
+ $rows = array_filter($this->read(), fn($r) => ($r[$this->pk] ?? null) != $id);
+ $this->write(array_values($rows));
+ }
+
+ /** 按任意列批量删除(用于主从表级联删除从表) */
+ public function deleteRaw(string $col, $val): void
+ {
+ if (Db::driver() === 'mysql') {
+ Db::query("DELETE FROM `{$this->table}` WHERE `{$col}`=?", [$val]);
+ return;
+ }
+ $rows = array_filter($this->read(), fn($r) => ($r[$col] ?? null) != $val);
+ $this->write(array_values($rows));
+ }
+
+ public function count(): int
+ {
+ return count($this->all());
+ }
+}
diff --git a/app/Core/Notify.php b/app/Core/Notify.php
new file mode 100644
index 0000000..91fef90
--- /dev/null
+++ b/app/Core/Notify.php
@@ -0,0 +1,326 @@
+insert([
+ 'sys' => $sys,
+ 'type' => $type,
+ 'level' => $level,
+ 'title' => $title,
+ 'body' => $body,
+ 'url' => $url,
+ 'ref_no' => $refNo,
+ 'recipients' => json_encode($recipients, JSON_UNESCAPED_UNICODE),
+ 'channels' => json_encode(array_values(array_unique($channels)), JSON_UNESCAPED_UNICODE),
+ 'read_by' => json_encode([], JSON_UNESCAPED_UNICODE),
+ 'created_at' => date('Y-m-d H:i:s'),
+ ]);
+ } catch (\Throwable $e) {
+ error_log('[Notify] fire failed: ' . $e->getMessage());
+ }
+ }
+
+ /* ============== 业务便捷方法 ============== */
+
+ /** 新销售订单 */
+ public static function newSalesOrder(string $orderNo, string $customer, string $salesman, int $id): void
+ {
+ $title = "【紧急】新销售订单待跟进:{$orderNo}";
+ $body = "客户:{$customer}\n负责人:{$salesman}\n订单号:{$orderNo}\n请尽快处理并安排发货。";
+ self::fire('sales_order', $title, $body, [
+ 'level' => 'urgent', 'ref_no' => $orderNo, 'url' => "PSI/sales_orders/show/{$id}",
+ ]);
+ }
+
+ /** 新采购订单 */
+ public static function newPurchaseOrder(string $orderNo, string $supplier, string $buyer, int $id): void
+ {
+ $title = "【紧急】新采购订单待处理:{$orderNo}";
+ $body = "供应商:{$supplier}\n采购人:{$buyer}\n订单号:{$orderNo}\n请尽快审核并安排收货。";
+ self::fire('purchase_order', $title, $body, [
+ 'level' => 'urgent', 'ref_no' => $orderNo, 'url' => "PSI/purchase_orders/show/{$id}",
+ ]);
+ }
+
+ /** 新客户订单(来自前台网站下单) */
+ public static function newCustomerOrder(string $orderNo, string $customer, string $phone, int $id): void
+ {
+ $title = "【紧急】收到新客户订单:{$orderNo}";
+ $body = "客户:{$customer}\n电话:{$phone}\n订单号:{$orderNo}\n请尽快联系客户并安排发货。";
+ self::fire('customer_order', $title, $body, [
+ 'level' => 'urgent', 'ref_no' => $orderNo, 'url' => "PSI/orders/show/{$id}",
+ ]);
+ }
+
+ /** 低库存预警(库存跌破阈值时触发) */
+ public static function lowStockEvent(string $itemType, string $name, float $stock, float $threshold, int $itemId): void
+ {
+ $kind = $itemType === 'product' ? '成品' : '物料';
+ $title = "【紧急】{$kind}库存不足:{$name}";
+ $body = "{$kind}:{$name}\n当前库存:{$stock}\n预警阈值:{$threshold}\n请及时补货。";
+ self::fire('low_stock', $title, $body, [
+ 'level' => 'urgent', 'ref_no' => $name, 'url' => "PSI/stock",
+ ]);
+ }
+
+ /* ============== 收件人与开关 ============== */
+
+ private static function recipients(): array
+ {
+ $s = new Setting();
+ $emailTo = trim((string) $s->get('notify_email_to', ''), " \t\n\r,");
+ $wechat = trim((string) $s->get('notify_wechat_mention', ''), " \t\n\r,");
+ return [
+ 'email' => $emailTo === '' ? [] : array_filter(array_map('trim', explode(',', $emailTo))),
+ 'wechat' => $wechat === '' ? [] : array_filter(array_map('trim', explode(',', $wechat))),
+ ];
+ }
+
+ private static function masterEnabled(): bool
+ {
+ return (int) (new Setting())->get('notify_enabled', 0) === 1;
+ }
+
+ private static function emailEnabled(): bool
+ {
+ if (!self::masterEnabled()) return false;
+ return (int) (new Setting())->get('notify_email_enabled', 0) === 1;
+ }
+
+ private static function wechatEnabled(): bool
+ {
+ if (!self::masterEnabled()) return false;
+ return (int) (new Setting())->get('notify_wechat_enabled', 0) === 1
+ && trim((string) (new Setting())->get('notify_wechat_webhook', '')) !== '';
+ }
+
+ public static function lowStockEnabled(): bool
+ {
+ return (int) (new Setting())->get('notify_lowstock_enabled', 1) === 1;
+ }
+
+ public static function lowStockThreshold(): float
+ {
+ return (float) (new Setting())->get('notify_lowstock_threshold', 20);
+ }
+
+ /* ============== 邮件发送 ============== */
+
+ private static function sendEmail(array $to, string $subject, string $body, string $url, string $level): bool
+ {
+ $s = new Setting();
+ $host = trim((string) $s->get('notify_email_smtp_host', ''));
+ $port = (int) $s->get('notify_email_smtp_port', 465);
+ $user = trim((string) $s->get('notify_email_smtp_user', ''));
+ $pass = trim((string) $s->get('notify_email_smtp_pass', ''));
+ $from = trim((string) $s->get('notify_email_from', ''));
+ if ($from === '') $from = $user;
+ $html = self::emailHtml($subject, $body, $url, $level);
+
+ if ($host !== '' && $user !== '') {
+ $scheme = ($port === 465) ? 'ssl' : 'tls';
+ return self::smtpSend($host, $port, $scheme, $user, $pass, $from, $to, $subject, $html);
+ }
+
+ // 回退:PHP 内置 mail()
+ $headers = "MIME-Version: 1.0\r\n";
+ $headers .= "Content-Type: text/html; charset=UTF-8\r\n";
+ $headers .= "From: {$from}\r\n";
+ $ok = true;
+ foreach ($to as $t) {
+ if (!@mail($t, '=?UTF-8?B?' . base64_encode($subject) . '?=', $html, $headers)) $ok = false;
+ }
+ return $ok;
+ }
+
+ private static function emailHtml(string $subject, string $body, string $url, string $level): string
+ {
+ $lines = nl2br(htmlspecialchars($body, ENT_QUOTES, 'UTF-8'));
+ $link = $url ? App::url($url) : '';
+ $urgent = $level === 'urgent' ? '紧急事件' : '通知';
+ return <<
+
+
酷冰甲 · PSI 进销存 {$urgent}
+
+
{$subject}
+
{$lines}
+ {$link}
+
+
+
本邮件由系统自动发出,请勿直接回复。
+
+HTML;
+ }
+
+ private static function smtpSend(string $host, int $port, string $scheme, string $user, string $pass, string $from, array $to, string $subject, string $html): bool
+ {
+ $timeout = 15;
+ $ctx = $scheme === 'ssl'
+ ? stream_context_create(['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]])
+ : null;
+ $prefix = $scheme === 'ssl' ? 'ssl://' : '';
+ $fp = @stream_socket_client($prefix . $host . ':' . $port, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx);
+ if (!$fp) return false;
+
+ $talk = function ($cmd = null) use ($fp) {
+ if ($cmd !== null) fwrite($fp, $cmd . "\r\n");
+ $res = '';
+ while (($line = fgets($fp, 600)) !== false) {
+ $res .= $line;
+ if (isset($line[3]) && $line[3] === ' ') break; // 单行响应(响应码后的第4个字符是空格)
+ if ($line === '') break;
+ }
+ return $res;
+ };
+
+ $talk(null); // 欢迎语
+ $talk('EHLO ' . (gethostname() ?: 'localhost'));
+ if ($scheme === 'tls' || $port === 587 || $port === 25) {
+ $r = $talk('STARTTLS');
+ if (strpos($r, '220') === 0) {
+ if (!@stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { fclose($fp); return false; }
+ $talk('EHLO ' . (gethostname() ?: 'localhost'));
+ }
+ }
+ if ($user !== '') {
+ $talk('AUTH LOGIN');
+ $talk(base64_encode($user));
+ $talk(base64_encode($pass));
+ }
+ $talk('MAIL FROM:<' . $from . '>');
+ foreach ($to as $t) $talk('RCPT TO:<' . $t . '>');
+ $talk('DATA');
+ $headers = "From: {$from}\r\n";
+ $headers .= "To: " . implode(', ', $to) . "\r\n";
+ $headers .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n";
+ $headers .= "MIME-Version: 1.0\r\n";
+ $headers .= "Content-Type: text/html; charset=UTF-8\r\n";
+ $talk($headers . "\r\n" . $html . "\r\n.");
+ $talk('QUIT');
+ fclose($fp);
+ return true;
+ }
+
+ /* ============== 企业微信(群机器人 webhook) ============== */
+
+ private static function sendWeChat(string $title, string $body, string $url, array $mention): bool
+ {
+ $webhook = trim((string) (new Setting())->get('notify_wechat_webhook', ''));
+ if ($webhook === '') return false;
+ $content = "**{$title}**\n> " . str_replace("\n", "\n> ", $body);
+ if ($url) $content .= "\n\n[查看详情](" . App::url($url) . ")";
+ $payload = ['msgtype' => 'markdown', 'markdown' => ['content' => $content]];
+ if ($mention) $payload['markdown']['mentioned_mobile_list'] = array_values($mention);
+ return self::httpPostJson($webhook, $payload);
+ }
+
+ private static function httpPostJson(string $url, array $payload): bool
+ {
+ $json = json_encode($payload, JSON_UNESCAPED_UNICODE);
+ if (function_exists('curl_init')) {
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'],
+ CURLOPT_POSTFIELDS => $json,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 10,
+ CURLOPT_SSL_VERIFYPEER => false,
+ CURLOPT_SSL_VERIFYHOST => 0,
+ ]);
+ $res = curl_exec($ch);
+ curl_close($ch);
+ return $res !== false;
+ }
+ $ctx = stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => "Content-Type: application/json; charset=utf-8\r\n",
+ 'content' => $json,
+ 'timeout' => 10,
+ ],
+ ]);
+ $res = @file_get_contents($url, false, $ctx);
+ return $res !== false;
+ }
+
+ /* ============== 事件表(按需创建,兼容 MySQL / json 两种存储) ============== */
+
+ private static function ensureTable(): void
+ {
+ if (Db::driver() !== 'mysql') return; // json 模式由 Model 自动建文件
+ $sql = "CREATE TABLE IF NOT EXISTS `psi_events` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ `sys` VARCHAR(20) NOT NULL DEFAULT 'psi',
+ `type` VARCHAR(40) NOT NULL DEFAULT '',
+ `level` VARCHAR(20) NOT NULL DEFAULT 'urgent',
+ `title` VARCHAR(255) NOT NULL DEFAULT '',
+ `body` TEXT,
+ `url` VARCHAR(255) NOT NULL DEFAULT '',
+ `ref_no` VARCHAR(64) NOT NULL DEFAULT '',
+ `recipients` TEXT,
+ `channels` VARCHAR(255) NOT NULL DEFAULT '[\"inapp\"]',
+ `read_by` TEXT,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
+ Db::query($sql);
+ }
+}
diff --git a/app/Core/Payment/AlipayGateway.php b/app/Core/Payment/AlipayGateway.php
new file mode 100644
index 0000000..a47e9e5
--- /dev/null
+++ b/app/Core/Payment/AlipayGateway.php
@@ -0,0 +1,82 @@
+config['appid']) && !empty($this->config['private_key']);
+
+ if ($this->isDemo() || !$hasCred) {
+ return [
+ 'mode' => 'demo',
+ 'channel' => 'alipay',
+ 'simulate' => $simulate,
+ 'config_missing' => !$hasCred,
+ ];
+ }
+
+ $biz = [
+ 'out_trade_no' => $order['order_no'],
+ 'product_code' => 'FAST_INSTANT_TRADE_PAY',
+ 'total_amount' => $this->money($order['amount']),
+ 'subject' => $order['product_name'] ?: '商品购买',
+ ];
+ $params = [
+ 'app_id' => $this->config['appid'],
+ 'method' => 'alipay.trade.page.pay',
+ 'format' => 'JSON',
+ 'charset' => 'utf-8',
+ 'sign_type' => 'RSA2',
+ 'timestamp' => date('Y-m-d H:i:s'),
+ 'version' => '1.0',
+ 'notify_url' => site_url('pay/notify/alipay'),
+ 'return_url' => site_url('order/success/' . $order['order_no']),
+ 'biz_content' => json_encode($biz, JSON_UNESCAPED_UNICODE),
+ ];
+ $params['sign'] = $this->sign($params);
+ $gateway = $this->config['gateway'] ?: 'https://openapi.alipay.com/gateway.do';
+ return ['mode' => 'redirect', 'channel' => 'alipay', 'url' => $gateway . '?' . http_build_query($params)];
+ }
+
+ /** RSA2 签名 */
+ private function sign(array $params): string
+ {
+ ksort($params);
+ $str = '';
+ foreach ($params as $k => $v) {
+ if ($v === '' || $v === null) continue;
+ $str .= $k . '=' . $v . '&';
+ }
+ $str = rtrim($str, '&');
+ $key = $this->normalizeKey($this->config['private_key'] ?? '', false);
+ openssl_sign($str, $sign, $key, OPENSSL_ALGO_SHA256);
+ return base64_encode($sign);
+ }
+
+ private function normalizeKey(string $key, bool $isPublic): string
+ {
+ $key = trim($key);
+ if (strpos($key, '-----BEGIN') === 0) return $key;
+ $head = $isPublic ? "-----BEGIN PUBLIC KEY-----\n" : "-----BEGIN RSA PRIVATE KEY-----\n";
+ $foot = $isPublic ? "\n-----END PUBLIC KEY-----" : "\n-----END RSA PRIVATE KEY-----";
+ return $head . chunk_split($key, 64, "\n") . $foot;
+ }
+
+ public function verifyNotify(array $data): ?string
+ {
+ if (empty($data['out_trade_no'])) return null;
+ $status = $data['trade_status'] ?? '';
+ if ($status === 'TRADE_SUCCESS' || $status === 'TRADE_FINISHED') {
+ // 生产环境应使用支付宝公钥对签名做严格验签后再返回
+ return $data['out_trade_no'];
+ }
+ return null;
+ }
+}
diff --git a/app/Core/Payment/Gateway.php b/app/Core/Payment/Gateway.php
new file mode 100644
index 0000000..027f3de
--- /dev/null
+++ b/app/Core/Payment/Gateway.php
@@ -0,0 +1,40 @@
+config = $config;
+ }
+
+ /** 发起支付,返回渲染数据
+ * ['mode'=>'demo','channel'=>?,'simulate'=>url,'config_missing'=>bool]
+ * ['mode'=>'redirect','channel'=>'alipay','url'=>?]
+ * ['mode'=>'qrcode','channel'=>'wechat','qr'=>?]
+ */
+ abstract public function pay(array $order): array;
+
+ /** 验证异步通知,成功返回订单号,否则返回 null */
+ abstract public function verifyNotify(array $data): ?string;
+
+ protected function isDemo(): bool
+ {
+ return ($this->config['mode'] ?? 'demo') === 'demo';
+ }
+
+ protected function money($v): string
+ {
+ return number_format((float) $v, 2, '.', '');
+ }
+}
diff --git a/app/Core/Payment/GatewayFactory.php b/app/Core/Payment/GatewayFactory.php
new file mode 100644
index 0000000..17361ed
--- /dev/null
+++ b/app/Core/Payment/GatewayFactory.php
@@ -0,0 +1,35 @@
+get('pay_mode', 'demo');
+ $enabled = $s->get('pay_enabled', '1');
+
+ if ($channel === 'alipay') {
+ return new AlipayGateway([
+ 'mode' => $mode,
+ 'enabled' => $enabled,
+ 'appid' => $s->get('pay_alipay_appid', ''),
+ 'private_key' => $s->get('pay_alipay_private_key', ''),
+ 'public_key' => $s->get('pay_alipay_public_key', ''),
+ 'gateway' => $s->get('pay_alipay_gateway', 'https://openapi.alipay.com/gateway.do'),
+ ]);
+ }
+ return new WechatGateway([
+ 'mode' => $mode,
+ 'enabled' => $enabled,
+ 'mchid' => $s->get('pay_wechat_mchid', ''),
+ 'appid' => $s->get('pay_wechat_appid', ''),
+ 'key' => $s->get('pay_wechat_key', ''),
+ ]);
+ }
+}
diff --git a/app/Core/Payment/OrderService.php b/app/Core/Payment/OrderService.php
new file mode 100644
index 0000000..730c7b2
--- /dev/null
+++ b/app/Core/Payment/OrderService.php
@@ -0,0 +1,32 @@
+where('order_no', $orderNo);
+ if (!$o || $o['status'] === 'paid') return false;
+ $order->update($o['id'], [
+ 'status' => 'paid',
+ 'paid_at' => date('Y-m-d H:i:s'),
+ 'gateway_trade_no' => $tradeNo,
+ ]);
+ (new Payment())->insert([
+ 'order_id' => $o['id'],
+ 'order_no' => $orderNo,
+ 'channel' => $channel,
+ 'amount' => $o['amount'],
+ 'trade_no' => $tradeNo,
+ 'status' => 'paid',
+ 'created_at' => date('Y-m-d H:i:s'),
+ 'paid_at' => date('Y-m-d H:i:s'),
+ ]);
+ return true;
+ }
+}
diff --git a/app/Core/Payment/WechatGateway.php b/app/Core/Payment/WechatGateway.php
new file mode 100644
index 0000000..134a925
--- /dev/null
+++ b/app/Core/Payment/WechatGateway.php
@@ -0,0 +1,89 @@
+config['mchid']) && !empty($this->config['appid']) && !empty($this->config['key']);
+
+ if ($this->isDemo() || !$hasCred) {
+ return [
+ 'mode' => 'demo',
+ 'channel' => 'wechat',
+ 'simulate' => $simulate,
+ 'config_missing' => !$hasCred,
+ ];
+ }
+
+ $params = [
+ 'appid' => $this->config['appid'],
+ 'mch_id' => $this->config['mchid'],
+ 'nonce_str' => bin2hex(random_bytes(16)),
+ 'body' => $order['product_name'] ?: '商品购买',
+ 'out_trade_no' => $order['order_no'],
+ 'total_fee' => (int) round((float) $order['amount'] * 100), // 分
+ 'spbill_create_ip' => $_SERVER['SERVER_ADDR'] ?? '127.0.0.1',
+ 'notify_url' => site_url('pay/notify/wechat'),
+ 'trade_type' => 'NATIVE',
+ ];
+ $params['sign'] = $this->sign($params);
+ $xml = $this->toXml($params);
+
+ $resp = @file_get_contents('https://api.mch.weixin.qq.com/pay/unifiedorder', false, stream_context_create([
+ 'http' => ['method' => 'POST', 'header' => 'Content-Type: text/xml', 'content' => $xml, 'timeout' => 8],
+ ]));
+ $res = $resp ? $this->fromXml($resp) : [];
+
+ if (!empty($res['code_url'])) {
+ return ['mode' => 'qrcode', 'channel' => 'wechat', 'qr' => $res['code_url']];
+ }
+ // 调用失败则回退演示,避免卡死
+ return ['mode' => 'demo', 'channel' => 'wechat', 'simulate' => $simulate, 'config_missing' => false, 'api_error' => true];
+ }
+
+ /** HMAC-SHA256 签名 */
+ private function sign(array $params): string
+ {
+ ksort($params);
+ $str = '';
+ foreach ($params as $k => $v) {
+ if ($v === '' || $v === null) continue;
+ $str .= $k . '=' . $v . '&';
+ }
+ $str .= 'key=' . ($this->config['key'] ?? '');
+ return strtoupper(hash_hmac('sha256', $str, $this->config['key'] ?? ''));
+ }
+
+ private function toXml(array $params): string
+ {
+ $xml = '';
+ foreach ($params as $k => $v) {
+ $xml .= "<{$k}>" . htmlspecialchars($v, ENT_XML1) . "{$k}>";
+ }
+ $xml .= '';
+ return $xml;
+ }
+
+ private function fromXml(string $xml): array
+ {
+ $r = @simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
+ return $r ? json_decode(json_encode($r), true) : [];
+ }
+
+ public function verifyNotify(array $data): ?string
+ {
+ if (empty($data['out_trade_no'])) return null;
+ if (($data['result_code'] ?? '') === 'SUCCESS' && ($data['return_code'] ?? '') === 'SUCCESS') {
+ // 生产环境应重新按 key 验签后返回
+ return $data['out_trade_no'];
+ }
+ return null;
+ }
+}
diff --git a/app/Core/Theme.php b/app/Core/Theme.php
new file mode 100644
index 0000000..f57dd84
--- /dev/null
+++ b/app/Core/Theme.php
@@ -0,0 +1,118 @@
+ '酷冰甲 · 降温服',
+ 'site_slogan' => '科技降温 · 清凉一夏',
+ 'site_logo' => 'assets/img/logo.png',
+ 'contact_phone' => '400-1783-998',
+ 'contact_email' => 'service@st-joyapparel.com',
+ 'contact_address'=> '江苏省苏州市工业园区',
+ 'icp' => '',
+ 'gongan' => '', // 公安备案号(网安备),如 京公网安备11010802012345号
+ 'seo_title' => '酷冰甲降温服 - 科技降温服装定制',
+ 'seo_keywords' => '降温服, cooling clothing, 降温工作服, 清凉服定制',
+ 'seo_description'=> '酷冰甲专注降温服研发与定制,采用相变蓄冷与循环水冷技术,为高温作业人群提供清凉解决方案。',
+ // 主题风格
+ 'preset' => 'ocean',
+ 'primary' => '#0ea5e9',
+ 'primary_600' => '#0284c7',
+ 'secondary' => '#14b8a6',
+ 'accent' => '#f59e0b',
+ 'bg' => '#ffffff',
+ 'surface' => '#f8fafc',
+ 'text' => '#0f172a',
+ 'muted' => '#64748b',
+ 'border' => '#e2e8f0',
+ 'nav_bg' => 'rgba(255,255,255,0.72)',
+ 'font' => "'Noto Sans SC', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
+ 'radius' => '16',
+ 'container' => '1200',
+ 'header' => 'center', // center | left | transparent
+ 'default_mode' => 'light', // light | dark
+ 'custom_css' => '',
+ ];
+ }
+
+ /** 合并默认值与数据库设置 */
+ public static function all(): array
+ {
+ if (self::$cache !== null) return self::$cache;
+ $def = self::defaults();
+ $setting = new \App\Models\Setting();
+ $saved = $setting->allKV();
+ self::$cache = array_merge($def, $saved);
+ return self::$cache;
+ }
+
+ public static function get(string $key, $default = '')
+ {
+ $all = self::all();
+ return $all[$key] ?? $default;
+ }
+
+ public static function clearCache(): void
+ {
+ self::$cache = null;
+ }
+
+ /** 预设主题(后台一键套用) */
+ public static function presets(): array
+ {
+ return [
+ 'ocean' => ['label' => '海洋蓝', 'vars' => ['primary'=>'#0ea5e9','primary_600'=>'#0284c7','secondary'=>'#14b8a6','accent'=>'#f59e0b','bg'=>'#ffffff','surface'=>'#f8fafc','text'=>'#0f172a','muted'=>'#64748b','border'=>'#e2e8f0','nav_bg'=>'rgba(255,255,255,0.72)']],
+ 'forest' => ['label' => '森野绿', 'vars' => ['primary'=>'#16a34a','primary_600'=>'#15803d','secondary'=>'#0d9488','accent'=>'#f97316','bg'=>'#ffffff','surface'=>'#f6fdf8','text'=>'#0f172a','muted'=>'#5b7065','border'=>'#dcefe2','nav_bg'=>'rgba(255,255,255,0.72)']],
+ 'aurora' => ['label' => '极光紫', 'vars' => ['primary'=>'#8b5cf6','primary_600'=>'#7c3aed','secondary'=>'#06b6d4','accent'=>'#ec4899','bg'=>'#ffffff','surface'=>'#faf7ff','text'=>'#1e1b2e','muted'=>'#6b6480','border'=>'#ece6f7','nav_bg'=>'rgba(255,255,255,0.72)']],
+ 'sunset' => ['label' => '日落橙', 'vars' => ['primary'=>'#f97316','primary_600'=>'#ea580c','secondary'=>'#ef4444','accent'=>'#facc15','bg'=>'#ffffff','surface'=>'#fffaf3','text'=>'#1c1917','muted'=>'#78716c','border'=>'#faead7','nav_bg'=>'rgba(255,255,255,0.72)']],
+ 'mono' => ['label' => '极简黑金', 'vars' => ['primary'=>'#111827','primary_600'=>'#000000','secondary'=>'#ca8a04','accent'=>'#ca8a04','bg'=>'#ffffff','surface'=>'#fafafa','text'=>'#111827','muted'=>'#6b7280','border'=>'#e5e7eb','nav_bg'=>'rgba(255,255,255,0.75)']],
+ 'ice' => ['label' => '冰晶青', 'vars' => ['primary'=>'#06b6d4','primary_600'=>'#0891b2','secondary'=>'#3b82f6','accent'=>'#22d3ee','bg'=>'#ffffff','surface'=>'#f0fbfd','text'=>'#0c1a24','muted'=>'#5b7686','border'=>'#d3eef5','nav_bg'=>'rgba(255,255,255,0.72)']],
+ ];
+ }
+
+ /** 生成 theme.css 文本 */
+ public static function buildCss(): string
+ {
+ $t = self::all();
+ $v = function ($k) use ($t) { return $t[$k] ?? ''; };
+ $css = ":root{\n";
+ $css .= " --c-primary:{$v('primary')};\n";
+ $css .= " --c-primary-600:{$v('primary_600')};\n";
+ $css .= " --c-secondary:{$v('secondary')};\n";
+ $css .= " --c-accent:{$v('accent')};\n";
+ $css .= " --c-bg:{$v('bg')};\n";
+ $css .= " --c-surface:{$v('surface')};\n";
+ $css .= " --c-text:{$v('text')};\n";
+ $css .= " --c-muted:{$v('muted')};\n";
+ $css .= " --c-border:{$v('border')};\n";
+ $css .= " --nav-bg:{$v('nav_bg')};\n";
+ $css .= " --font-base:{$v('font')};\n";
+ $css .= " --radius:{$v('radius')}px;\n";
+ $css .= " --container:{$v('container')}px;\n";
+ $css .= "}\n";
+ $css .= "[data-theme=\"dark\"]{\n";
+ $css .= " --c-bg:#0b1120;--c-surface:#111827;--c-text:#e5e7eb;--c-muted:#94a3b8;--c-border:#1f2937;--nav-bg:rgba(11,17,32,0.72);\n";
+ $css .= "}\n";
+ $css .= $v('custom_css') . "\n";
+ return $css;
+ }
+
+ /** 重新生成主题 CSS 文件 */
+ public static function regenerate(): bool
+ {
+ $dir = dirname(self::$cssPath);
+ if (!is_dir($dir)) mkdir($dir, 0755, true);
+ return (bool) file_put_contents(self::$cssPath, self::buildCss());
+ }
+}
diff --git a/app/Core/View.php b/app/Core/View.php
new file mode 100644
index 0000000..e078bae
--- /dev/null
+++ b/app/Core/View.php
@@ -0,0 +1,26 @@
+ $content]));
+ }
+ return $content;
+ }
+}
diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php
new file mode 100644
index 0000000..6c61722
--- /dev/null
+++ b/app/Models/AdminUser.php
@@ -0,0 +1,15 @@
+where('username', $u);
+ }
+}
diff --git a/app/Models/Banner.php b/app/Models/Banner.php
new file mode 100644
index 0000000..9e9e605
--- /dev/null
+++ b/app/Models/Banner.php
@@ -0,0 +1,10 @@
+all(), fn($c) => ($c['status'] ?? 1) == 1);
+ return array_slice($all, 0, $limit);
+ }
+}
diff --git a/app/Models/News.php b/app/Models/News.php
new file mode 100644
index 0000000..0d002e1
--- /dev/null
+++ b/app/Models/News.php
@@ -0,0 +1,16 @@
+all(), fn($n) => ($n['status'] ?? 1) == 1);
+ return array_slice($all, 0, $limit);
+ }
+}
diff --git a/app/Models/Order.php b/app/Models/Order.php
new file mode 100644
index 0000000..1f13067
--- /dev/null
+++ b/app/Models/Order.php
@@ -0,0 +1,26 @@
+where('order_no', $no);
+ }
+
+ /** 某客户的订单列表(按手机号或邮箱匹配) */
+ public function byCustomer(string $phone): array
+ {
+ $out = [];
+ foreach ($this->all() as $r) {
+ if (($r['phone'] ?? '') === $phone || ($r['email'] ?? '') === $phone) $out[] = $r;
+ }
+ return $out;
+ }
+}
diff --git a/app/Models/PSI/Event.php b/app/Models/PSI/Event.php
new file mode 100644
index 0000000..5eb6248
--- /dev/null
+++ b/app/Models/PSI/Event.php
@@ -0,0 +1,13 @@
+where('slug', $slug);
+ }
+}
diff --git a/app/Models/PageSeo.php b/app/Models/PageSeo.php
new file mode 100644
index 0000000..8f028e5
--- /dev/null
+++ b/app/Models/PageSeo.php
@@ -0,0 +1,57 @@
+table} WHERE page_key = ?", [$key])->fetch();
+ return $row ?: null;
+ }
+
+ /** 全部以 page_key 为索引返回 */
+ public function allIndexed(): array
+ {
+ $rows = Db::query("SELECT * FROM {$this->table} ORDER BY sort ASC, id ASC")->fetchAll();
+ $out = [];
+ foreach ($rows as $r) {
+ $out[$r['page_key']] = $r;
+ }
+ return $out;
+ }
+
+ /** 存在则更新,不存在则插入 */
+ public function saveRow(string $key, array $data): void
+ {
+ $exists = Db::query("SELECT 1 FROM {$this->table} WHERE page_key = ?", [$key])->fetch();
+ if ($exists) {
+ $sets = [];
+ $params = [];
+ foreach ($data as $k => $v) {
+ $sets[] = "`{$k}` = ?";
+ $params[] = $v;
+ }
+ $params[] = $key;
+ Db::query("UPDATE {$this->table} SET " . implode(', ', $sets) . " WHERE page_key = ?", $params);
+ } else {
+ $cols = array_keys($data);
+ $ph = array_fill(0, count($cols), '?');
+ Db::query(
+ "INSERT INTO {$this->table} (`page_key`, `" . implode('`,`', $cols) . "`) VALUES (?, " . implode(',', $ph) . ")",
+ array_merge([$key], array_values($data))
+ );
+ }
+ }
+}
diff --git a/app/Models/Payment.php b/app/Models/Payment.php
new file mode 100644
index 0000000..2e20a12
--- /dev/null
+++ b/app/Models/Payment.php
@@ -0,0 +1,10 @@
+whereAll('category_id', $catId);
+ }
+ public function featured(int $limit = 6): array
+ {
+ return array_slice(array_filter($this->all(), fn($p) => ($p['status'] ?? 1) == 1), 0, $limit);
+ }
+ public function specsArray($p): array
+ {
+ $s = $p['specs'] ?? '';
+ if (is_array($s)) return $s;
+ $dec = json_decode((string)$s, true);
+ return is_array($dec) ? $dec : [];
+ }
+ public function galleryArray($p): array
+ {
+ $g = $p['gallery'] ?? '';
+ if (is_array($g)) return $g;
+ $dec = json_decode((string)$g, true);
+ return is_array($dec) ? $g : [];
+ }
+}
diff --git a/app/Models/Setting.php b/app/Models/Setting.php
new file mode 100644
index 0000000..df5a37c
--- /dev/null
+++ b/app/Models/Setting.php
@@ -0,0 +1,42 @@
+where('skey', $key);
+ return $row ? $row['sval'] : $default;
+ }
+
+ /** 批量读取为 [skey => sval] */
+ public function allKV(): array
+ {
+ $out = [];
+ foreach ($this->all() as $r) $out[$r['skey']] = $r['sval'];
+ return $out;
+ }
+
+ /** 设置(不存在则新增) */
+ public function set(string $key, $val, string $group = 'site'): void
+ {
+ $row = $this->where('skey', $key);
+ if ($row) {
+ $this->update($row['id'], ['sval' => $val, 'sgroup' => $group]);
+ } else {
+ $this->insert(['skey' => $key, 'sval' => $val, 'sgroup' => $group]);
+ }
+ }
+
+ /** 批量保存 */
+ public function saveMany(array $pairs, string $group = 'site'): void
+ {
+ foreach ($pairs as $k => $v) $this->set($k, $v, $group);
+ }
+}
diff --git a/app/Views/admin/banner_form.php b/app/Views/admin/banner_form.php
new file mode 100644
index 0000000..7faaf53
--- /dev/null
+++ b/app/Views/admin/banner_form.php
@@ -0,0 +1,30 @@
+
+
+
diff --git a/app/Views/admin/banners.php b/app/Views/admin/banners.php
new file mode 100644
index 0000000..a242d48
--- /dev/null
+++ b/app/Views/admin/banners.php
@@ -0,0 +1,23 @@
+
+
+
+ | 背景 | 标题 | 副标题 | 链接 | 操作 |
+
+
+ 🖼 |
+ |
+ |
+ |
+
+
+ |
+
+
+
+
diff --git a/app/Views/admin/cases.php b/app/Views/admin/cases.php
new file mode 100644
index 0000000..27e4d47
--- /dev/null
+++ b/app/Views/admin/cases.php
@@ -0,0 +1,32 @@
+
+
+
+ | 封面 | 案例标题 | 模式 | 客户 | 行业 | 日期 | 状态 | 操作 |
+
+
+
+ 🤝 |
+ |
+ |
+ |
+ |
+ |
+ 展示中' : '隐藏'; ?> |
+
+
+ |
+
+
+
+
+
diff --git a/app/Views/admin/cases_builder.php b/app/Views/admin/cases_builder.php
new file mode 100644
index 0000000..2771a8c
--- /dev/null
+++ b/app/Views/admin/cases_builder.php
@@ -0,0 +1,92 @@
+
+
+
+
可视化编辑
+
可视化编辑:拖拽文字 / 图片 / 按钮自由排版,保存后前台按此布局整页展示。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/admin/cases_form.php b/app/Views/admin/cases_form.php
new file mode 100644
index 0000000..e0b67dd
--- /dev/null
+++ b/app/Views/admin/cases_form.php
@@ -0,0 +1,167 @@
+
+
+
+
固定版面
+
固定版面:填写案例信息 / 封面,详情使用富文本编辑器排版
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/admin/categories.php b/app/Views/admin/categories.php
new file mode 100644
index 0000000..ed1217e
--- /dev/null
+++ b/app/Views/admin/categories.php
@@ -0,0 +1,30 @@
+
+
+
+ | 图标 | 名称 | 标识 | 模式 | 描述 | 操作 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+ |
+
+
+
+
+
diff --git a/app/Views/admin/category_builder.php b/app/Views/admin/category_builder.php
new file mode 100644
index 0000000..884f887
--- /dev/null
+++ b/app/Views/admin/category_builder.php
@@ -0,0 +1,83 @@
+
+
+
+
可视化编辑
+
可视化编辑:拖拽文字 / 图片 / 按钮自由排版分类内容,保存后可用于前台展示或后续扩展。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/admin/category_form.php b/app/Views/admin/category_form.php
new file mode 100644
index 0000000..22d63b8
--- /dev/null
+++ b/app/Views/admin/category_form.php
@@ -0,0 +1,101 @@
+
+
+
+
固定版面
+
固定版面:填写分类名称 / 图标 / 描述等基本信息
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/admin/dashboard.php b/app/Views/admin/dashboard.php
new file mode 100644
index 0000000..8ff162e
--- /dev/null
+++ b/app/Views/admin/dashboard.php
@@ -0,0 +1,42 @@
+
+
+
+
产品数量
+
分类数量
+
新闻数量
+
轮播数量
+
+
+
+
最近新闻
+
暂无新闻
+
+
+ | 标题 | 发布日期 | 操作 |
+
+
+ |
+ |
+ 编辑 |
+
+
+
+
+
+
+
diff --git a/app/Views/admin/db_browse.php b/app/Views/admin/db_browse.php
new file mode 100644
index 0000000..898ed37
--- /dev/null
+++ b/app/Views/admin/db_browse.php
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+ |
+ 操作 |
+
+
+
+
+
+
+ |
+
+
+ 编辑
+ 删除
+ |
+
+
+ | 无记录 |
+
+
+
+
+ 1): ?>
+
+
+
diff --git a/app/Views/admin/db_form.php b/app/Views/admin/db_form.php
new file mode 100644
index 0000000..21444df
--- /dev/null
+++ b/app/Views/admin/db_form.php
@@ -0,0 +1,52 @@
+
+
+
+
diff --git a/app/Views/admin/db_tables.php b/app/Views/admin/db_tables.php
new file mode 100644
index 0000000..d866d7d
--- /dev/null
+++ b/app/Views/admin/db_tables.php
@@ -0,0 +1,49 @@
+
+
+
+
数据库管理
+
查看并维护数据库中所有表的数据,可进行浏览、编辑、新增与删除。
+
+
数据库升级
+
+
+
+
+
当前为 文件存储模式,不支持在线数据库管理。可用数据文件如下(只读):
+
+ | 数据文件 | 大小 |
+
+
+ | B |
+
+ | 无数据文件 |
+
+
+
+
+
+
共 张表
+
+
+
+
+ | 表名 | 引擎 | 记录数 | 大小 | 操作 |
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+ 浏览数据
+ |
+
+
+
+
+
+
+
diff --git a/app/Views/admin/login.php b/app/Views/admin/login.php
new file mode 100644
index 0000000..1820757
--- /dev/null
+++ b/app/Views/admin/login.php
@@ -0,0 +1,25 @@
+
+
酷冰甲 · 降温服
+
后台登录
+
请输入管理员账号密码
+
+
+
+
请使用分配的账号登录;忘记密码请联系超级管理员重置
+
diff --git a/app/Views/admin/news.php b/app/Views/admin/news.php
new file mode 100644
index 0000000..eeebdd4
--- /dev/null
+++ b/app/Views/admin/news.php
@@ -0,0 +1,31 @@
+
+
+
+ | 封面 | 标题 | 模式 | 作者 | 日期 | 状态 | 操作 |
+
+
+
+ 📰 |
+ |
+ |
+ |
+ |
+ 已发布' : '草稿'; ?> |
+
+
+ |
+
+
+
+
+
diff --git a/app/Views/admin/news_builder.php b/app/Views/admin/news_builder.php
new file mode 100644
index 0000000..da4d85a
--- /dev/null
+++ b/app/Views/admin/news_builder.php
@@ -0,0 +1,88 @@
+
+
+
+
可视化编辑
+
可视化编辑:拖拽文字 / 图片 / 按钮自由排版,保存后前台按此布局整页展示。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/admin/news_form.php b/app/Views/admin/news_form.php
new file mode 100644
index 0000000..fd55c40
--- /dev/null
+++ b/app/Views/admin/news_form.php
@@ -0,0 +1,163 @@
+
+
+
+
固定版面
+
固定版面:填写标题 / 摘要 / 封面,正文使用富文本编辑器排版
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/admin/order_show.php b/app/Views/admin/order_show.php
new file mode 100644
index 0000000..9647209
--- /dev/null
+++ b/app/Views/admin/order_show.php
@@ -0,0 +1,33 @@
+
+
+
+
+ | 商品 | |
+ | 客户 | (/ ) |
+ | 数量 | 套 |
+ | 金额 | ¥ |
+ | 支付通道 | |
+ | 状态 | |
+ | 网关交易号 | |
+ | 下单时间 | |
+
+
+
+
+
+
付款记录
+
+ | 通道 | 交易号 | 金额 | 状态 | 付款时间 |
+
+
+ | | ¥ | | |
+
+
+
+
+
diff --git a/app/Views/admin/orders.php b/app/Views/admin/orders.php
new file mode 100644
index 0000000..82a58ed
--- /dev/null
+++ b/app/Views/admin/orders.php
@@ -0,0 +1,37 @@
+
+订单管理
查看客户下单与付款状态(超级管理员 / 管理员可见)
+
+
+ | 订单号 | 商品 | 客户 | 数量 | 金额 | 通道 | 状态 | 下单时间 | 操作 |
+
+
+
+ |
+ |
+
|
+ |
+ ¥ |
+ |
+ 已支付'
+ : '待支付'; ?> |
+ |
+
+
+ 详情
+
+
+
+
+
+ |
+
+
+
+
+
暂无订单
+
diff --git a/app/Views/admin/page_builder.php b/app/Views/admin/page_builder.php
new file mode 100644
index 0000000..4a6bc06
--- /dev/null
+++ b/app/Views/admin/page_builder.php
@@ -0,0 +1,120 @@
+
+
+
+
+
← 返回
+
可视化编辑:
+
可视化编辑
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/admin/page_form.php b/app/Views/admin/page_form.php
new file mode 100644
index 0000000..b14f6c0
--- /dev/null
+++ b/app/Views/admin/page_form.php
@@ -0,0 +1,103 @@
+
+
+
+
编辑单页:固定版面
+
固定版面:页面套用统一模板(标题区 + 正文 + 联系按钮),您只需编辑正文内容。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/admin/pages.php b/app/Views/admin/pages.php
new file mode 100644
index 0000000..ed1a629
--- /dev/null
+++ b/app/Views/admin/pages.php
@@ -0,0 +1,23 @@
+
+
单页管理
关于我们 / 服务优势 / 客户案例 / 荣誉资质
+
+
+
+ | 标识 | 标题 | 模式 | 更新时间 | 操作 |
+
+
+
+ |
+ |
+ |
+ |
+ 编辑 |
+
+
+
+
+
diff --git a/app/Views/admin/parts/builder.php b/app/Views/admin/parts/builder.php
new file mode 100644
index 0000000..75b4b14
--- /dev/null
+++ b/app/Views/admin/parts/builder.php
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/admin/password.php b/app/Views/admin/password.php
new file mode 100644
index 0000000..dcb14e5
--- /dev/null
+++ b/app/Views/admin/password.php
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/app/Views/admin/payment.php b/app/Views/admin/payment.php
new file mode 100644
index 0000000..2f1d003
--- /dev/null
+++ b/app/Views/admin/payment.php
@@ -0,0 +1,34 @@
+
+支付设置
配置支付宝 / 微信支付通道;未配置商户号时默认「演示模式」,整条支付链路可正常走通。
+
diff --git a/app/Views/admin/product_builder.php b/app/Views/admin/product_builder.php
new file mode 100644
index 0000000..ef9f645
--- /dev/null
+++ b/app/Views/admin/product_builder.php
@@ -0,0 +1,93 @@
+
+
+
+
可视化编辑
+
可视化编辑:拖拽文字 / 图片 / 按钮自由排版,可放置「购买 / 价格 / 规格」专用块(自动读取本产品数据)。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/admin/product_form.php b/app/Views/admin/product_form.php
new file mode 100644
index 0000000..4a27121
--- /dev/null
+++ b/app/Views/admin/product_form.php
@@ -0,0 +1,199 @@
+
+
+
+
固定版面
+
固定版面:填写资料并上传封面 / 图集,图片自适应展示
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/admin/products.php b/app/Views/admin/products.php
new file mode 100644
index 0000000..08fa064
--- /dev/null
+++ b/app/Views/admin/products.php
@@ -0,0 +1,36 @@
+all() as $c) $cmap[$c['id']] = $c['name'];
+?>
+
+
+
+
+ | 封面 | 名称 | 分类 | 价格 | 模式 | 状态 | 操作 |
+
+
+
+ ❄ |
+
|
+ |
+ ¥ |
+ |
+ 已上线' : '草稿'; ?> |
+
+
+ |
+
+
+
+
+
diff --git a/app/Views/admin/seo.php b/app/Views/admin/seo.php
new file mode 100644
index 0000000..391af68
--- /dev/null
+++ b/app/Views/admin/seo.php
@@ -0,0 +1,76 @@
+' . $emptyTxt . '';
+ $cls = $n >= $min ? 'badge-ok' : 'badge-warn';
+ return '' . $n . ($min > 0 ? '(≥' . $min . ')' : '') . '';
+}
+?>
+
+
+
SEO 设置
+
逐页维护搜索结果展示信息。点击任一页面进入独立编辑页,带实时字数提示。
+
+
+
+
+= flash_html() ?>
+
+
+
+
diff --git a/app/Views/admin/seo_edit.php b/app/Views/admin/seo_edit.php
new file mode 100644
index 0000000..4da2188
--- /dev/null
+++ b/app/Views/admin/seo_edit.php
@@ -0,0 +1,128 @@
+ 'website(普通页)', 'article' => 'article(文章/资讯)', 'product' => 'product(商品)'];
+$idp = preg_replace('/[^a-z0-9]/', '', $key);
+?>
+
+
+
SEO 设置 · = e($meta['label'] ?? $key) ?>
+
+ 页面标识 = e($key) ?>
+ · = e($meta['note']) ?>
+
+
+
+
+
+= flash_html() ?>
+
+
+
+
diff --git a/app/Views/admin/settings.php b/app/Views/admin/settings.php
new file mode 100644
index 0000000..0259f90
--- /dev/null
+++ b/app/Views/admin/settings.php
@@ -0,0 +1,30 @@
+
+
diff --git a/app/Views/admin/system.php b/app/Views/admin/system.php
new file mode 100644
index 0000000..4983baf
--- /dev/null
+++ b/app/Views/admin/system.php
@@ -0,0 +1,48 @@
+query("SELECT VERSION()")->fetchColumn() : '—';
+$tz = date_default_timezone_get();
+$storageWritable = is_writable(BASE_PATH . '/storage');
+$uploadWritable = is_writable(BASE_PATH . '/public/uploads');
+$installed = \Core\Installer::isInstalled();
+$info = [
+ 'PHP 版本' => $phpVer,
+ '运行模式' => $sapi,
+ '数据库驱动' => $dbDriver === 'mysql' ? 'MySQL' : $dbDriver,
+ '数据库版本' => $dbVer,
+ '系统时区' => $tz,
+ '站点已安装' => $installed ? '是' : '否',
+ '根目录' => BASE_PATH,
+ '存储目录可写' => $storageWritable ? '✓ 可写' : '✗ 不可写',
+ '上传目录可写' => $uploadWritable ? '✓ 可写' : '✗ 不可写',
+];
+?>
+
+
+
+
+
diff --git a/app/Views/admin/theme.php b/app/Views/admin/theme.php
new file mode 100644
index 0000000..154bbed
--- /dev/null
+++ b/app/Views/admin/theme.php
@@ -0,0 +1,90 @@
+
+
🎨 风格设置
一键设置任意网页风格:配色 / 字体 / 圆角 / 导航 / 明暗 / 自定义 CSS,保存后立即生效
+
+
+
+
+
+
+
diff --git a/app/Views/admin/upgrade.php b/app/Views/admin/upgrade.php
new file mode 100644
index 0000000..99011bd
--- /dev/null
+++ b/app/Views/admin/upgrade.php
@@ -0,0 +1,105 @@
+
+
+
+
数据库升级
+
将升级包(*.sql)放入 install/upgrades/ 目录后,系统自动提示可升级;每次升级均留痕记录。
+
+
数据库管理
+
+
+
+
+
当前为文件存储模式,不支持 SQL 升级。请切换到 MySQL 后使用本功能。
+
+
+
+ 0): ?>
+
+
+
+
+
发现 个可升级数据库脚本
+
执行后将在「升级记录」中留痕,可随时追溯。
+
+
+
⬆️ 立即全部升级
+
+
+ ✓ 数据库已是最新,没有待升级的脚本。
+
+
+
+
升级包(install/upgrades/)
+
+
暂无升级包。将 *.sql 文件放入 install/upgrades/ 目录即可在此处升级。
+
+
+
+
+ | 文件名 | 大小 | 修改时间 | 状态 | 操作 |
+
+
+
+
+ |
+ B |
+ |
+
+ 已应用
+ 内容已变更
+ 待升级
+ |
+
+
+ 升级
+ —
+ |
+
+
+
+
+
+
+
+
+
+
升级记录(db_upgrades)
+
+
暂无升级记录。
+
+
+
+
+ | 文件名 | 操作人 | 执行时间 | 文件指纹(MD5) |
+
+
+
+
+ |
+ |
+ |
+ … |
+
+
+
+
+
+
+
+
+
+
基础初始化工具
+
+ 重新执行基础表结构与种子数据补齐(install/schema.sql / install/seed.php)。
+ 该操作会补齐缺失的新模块表 / 列,并保留已有订单与管理员账号,但不会覆盖客户已编辑的内容数据。
+
+
+
+
+
diff --git a/app/Views/admin/user_form.php b/app/Views/admin/user_form.php
new file mode 100644
index 0000000..2321f30
--- /dev/null
+++ b/app/Views/admin/user_form.php
@@ -0,0 +1,88 @@
+
+
+
+
+
+ '客户管理', 'leads' => '商机线索', 'followups' => '跟进记录'];
+$psiPages = ['materials' => '物料管理', 'products' => '成品管理', 'suppliers' => '供应商', 'purchases' => '采购入库', 'sales' => '销售出库', 'stock' => '库存流水'];
+?>
+
+
+
diff --git a/app/Views/admin/users.php b/app/Views/admin/users.php
new file mode 100644
index 0000000..02c1741
--- /dev/null
+++ b/app/Views/admin/users.php
@@ -0,0 +1,31 @@
+
+
+
用户管理
管理后台账号与权限:超级管理员 / 管理员 / 用户 / 无(纯子系统账号),支持多人协同编辑
+
+ 新增用户
+
+
+
+
+
+
+
+ | 账号 | 姓名 | 角色 | 状态 | 操作 |
+
+
+ |
+ |
+ |
+ |
+
+
+ |
+
+
+
+
diff --git a/app/Views/cases/index.php b/app/Views/cases/index.php
new file mode 100644
index 0000000..fdb2a90
--- /dev/null
+++ b/app/Views/cases/index.php
@@ -0,0 +1,23 @@
+
+
+
客户案例
+
从需求沟通到成衣交付,为各行业提供定制化降温解决方案
+
+
+
diff --git a/app/Views/cases/show.php b/app/Views/cases/show.php
new file mode 100644
index 0000000..e7a05e8
--- /dev/null
+++ b/app/Views/cases/show.php
@@ -0,0 +1,36 @@
+
+
+
+
+
+ $layoutArr, 'item' => $c, 'module' => 'case']); ?>
+
+
+
+
+
diff --git a/app/Views/contact/index.php b/app/Views/contact/index.php
new file mode 100644
index 0000000..5f8b6f1
--- /dev/null
+++ b/app/Views/contact/index.php
@@ -0,0 +1,60 @@
+
+
+
+
联系我们
+
提交需求,专属顾问 1 对 1 为您设计降温解决方案
+
+
+
+
+
+
+
联系方式
+
+
+
+
我们支持企业 LOGO 绣字、一人一码量体、柔性化小单定制,10 套起订。
+
+
+
+
+
diff --git a/app/Views/crm/contact_form.php b/app/Views/crm/contact_form.php
new file mode 100644
index 0000000..7865b29
--- /dev/null
+++ b/app/Views/crm/contact_form.php
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/app/Views/crm/contacts.php b/app/Views/crm/contacts.php
new file mode 100644
index 0000000..fd4e5dd
--- /dev/null
+++ b/app/Views/crm/contacts.php
@@ -0,0 +1,42 @@
+
+
+
+
+ ← 返回客户列表
+
+
+
+
+
+ | ID | 客户 |
+ 姓名 | 职位 | 电话 | 邮箱 | 微信 | 主联系人 | 操作 |
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+ 编辑
+ 删除
+ |
+
+
+ | 暂无联系人,点击右上角新增 |
+
+
+
diff --git a/app/Views/crm/customer_form.php b/app/Views/crm/customer_form.php
new file mode 100644
index 0000000..a5e4847
--- /dev/null
+++ b/app/Views/crm/customer_form.php
@@ -0,0 +1,81 @@
+ '品牌方',
+ 'dealer1' => '一级经销商',
+ 'dealer2' => '二级经销商',
+ 'retail' => '直营门店',
+ 'ecom' => '电商',
+ 'export' => '外贸出口',
+ 'oem' => '生产代工',
+];
+$levelOpts = ['A' => 'A(重点)', 'B' => 'B(普通)', 'C' => 'C(潜在)'];
+$industryOpts = ['服装' => '服装', '鞋帽' => '鞋帽', '家纺' => '家纺', '其他' => '其他'];
+$statusOpts = [
+ 'lead' => '潜在客户',
+ 'intent' => '意向客户',
+ 'quote' => '报价中',
+ 'cooperating' => '合作中',
+ 'won' => '已成交',
+ 'lost' => '已流失',
+ 'dormant' => '休眠',
+];
+$curType = $c['type'] ?? 'brand';
+$curLevel = $c['level'] ?? 'C';
+$curIndustry = $c['industry'] ?? '服装';
+$curStatus = $c['status'] ?? 'lead';
+?>
+
+
+
+
diff --git a/app/Views/crm/customers.php b/app/Views/crm/customers.php
new file mode 100644
index 0000000..b4aa2c6
--- /dev/null
+++ b/app/Views/crm/customers.php
@@ -0,0 +1,72 @@
+ '品牌方',
+ 'dealer1' => '一级经销商',
+ 'dealer2' => '二级经销商',
+ 'retail' => '直营门店',
+ 'ecom' => '电商',
+ 'export' => '外贸出口',
+ 'oem' => '生产代工',
+];
+$levelLabel = ['A' => 'A(重点)', 'B' => 'B(普通)', 'C' => 'C(潜在)'];
+$statusLabel = [
+ 'lead' => '潜在客户',
+ 'intent' => '意向客户',
+ 'quote' => '报价中',
+ 'cooperating' => '合作中',
+ 'won' => '已成交',
+ 'lost' => '已流失',
+ 'dormant' => '休眠',
+];
+$statusClass = [
+ 'lead' => 'st-grey',
+ 'intent' => 'st-blue',
+ 'quote' => 'st-purple',
+ 'cooperating' => 'st-green',
+ 'won' => 'st-gold',
+ 'lost' => 'st-red',
+ 'dormant' => 'st-grey',
+];
+?>
+
+
+
diff --git a/app/Views/crm/dashboard.php b/app/Views/crm/dashboard.php
new file mode 100644
index 0000000..f1f4720
--- /dev/null
+++ b/app/Views/crm/dashboard.php
@@ -0,0 +1,112 @@
+ '新线索', 'contact' => '已联系', 'quote' => '报价中', 'won' => '已成交', 'lost' => '已流失'];
+$typeLabel = ['brand' => '品牌方', 'dealer1' => '一级经销商', 'dealer2' => '二级经销商', 'retail' => '直营门店', 'ecom' => '电商', 'export' => '外贸出口', 'oem' => '生产代工'];
+$statusLabel = [
+ 'lead' => '潜在客户', 'intent' => '意向客户', 'quote' => '报价中',
+ 'cooperating' => '合作中', 'won' => '已成交', 'lost' => '已流失', 'dormant' => '休眠',
+];
+$statusClass = [
+ 'lead' => 'st-grey', 'intent' => 'st-blue', 'quote' => 'st-purple',
+ 'cooperating' => 'st-green', 'won' => 'st-gold', 'lost' => 'st-red', 'dormant' => 'st-grey',
+];
+$recentCustomers = $recentCustomers ?? [];
+?>
+
+
CRM 仪表盘
+
客户需求与客户关系总览
+
+
+
+
+
+
+
+
+
商机阶段分布
+
暂无商机数据
+
+ | 阶段 | 数量 |
+
+ $v): ?>
+ | |
+
+
+
+
+
+
+
客户类型分布
+
暂无客户数据
+
+ | 类型 | 数量 |
+
+ $v): ?>
+ | |
+
+
+
+
+
+
+
+
+
最近客户
+
暂无客户,点击上方「录入新客户」开始
+
+ | ID | 客户名称 | 类型 | 状态 | 负责人 | 操作 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+ 编辑
+ 联系人
+ |
+
+
+
+
+
+
+
+
+
最近跟进
+
暂无跟进记录
+
+ | 时间 | 客户ID | 内容 | 下次跟进 |
+
+
+
+ |
+ # |
+ |
+ |
+
+
+
+
+
+
diff --git a/app/Views/crm/followup_form.php b/app/Views/crm/followup_form.php
new file mode 100644
index 0000000..2f60e0b
--- /dev/null
+++ b/app/Views/crm/followup_form.php
@@ -0,0 +1,36 @@
+ '电话', 'wechat' => '微信', 'visit' => '拜访', 'email' => '邮件', 'other' => '其他'];
+$curWay = $f['way'] ?? 'tel';
+?>
+
+
+
+
diff --git a/app/Views/crm/followups.php b/app/Views/crm/followups.php
new file mode 100644
index 0000000..4da2186
--- /dev/null
+++ b/app/Views/crm/followups.php
@@ -0,0 +1,32 @@
+ '电话', 'wechat' => '微信', 'visit' => '拜访', 'email' => '邮件', 'other' => '其他'];
+?>
+
+
+
+ | ID | 时间 | 客户 | 方式 | 内容 | 结果 | 下次跟进 | 负责人 | 操作 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+ 编辑
+ 删除
+ |
+
+
+ | 暂无跟进记录 |
+
+
+
diff --git a/app/Views/crm/lead_form.php b/app/Views/crm/lead_form.php
new file mode 100644
index 0000000..b4b021e
--- /dev/null
+++ b/app/Views/crm/lead_form.php
@@ -0,0 +1,46 @@
+ '新线索', 'contact' => '已联系', 'quote' => '报价中', 'won' => '已成交', 'lost' => '已流失'];
+$srcOpts = ['expo' => '展会', 'referral' => '转介绍', 'online' => '线上', 'web' => '官网', 'tel' => '电话', 'other' => '其他'];
+$curSrc = $l['source'] ?? 'expo';
+?>
+
+
diff --git a/app/Views/crm/leads.php b/app/Views/crm/leads.php
new file mode 100644
index 0000000..7d3e397
--- /dev/null
+++ b/app/Views/crm/leads.php
@@ -0,0 +1,34 @@
+ '新线索', 'contact' => '已联系', 'quote' => '报价中', 'won' => '已成交', 'lost' => '已流失'];
+$sourceLabel = ['expo' => '展会', 'referral' => '转介绍', 'online' => '线上', 'web' => '官网', 'tel' => '电话', 'other' => '其他'];
+?>
+
+
+
+ | ID | 标题 | 客户 | 金额 | 阶段 | 来源 | 成交概率 | 预计成交 | 负责人 | 操作 |
+
+
+
+ |
+ |
+ |
+ ¥ |
+ |
+ |
+ % |
+ |
+ |
+
+ 编辑
+ 删除
+ |
+
+
+ | 暂无商机,点击右上角新增 |
+
+
+
diff --git a/app/Views/home/index.php b/app/Views/home/index.php
new file mode 100644
index 0000000..08e1a7f
--- /dev/null
+++ b/app/Views/home/index.php
@@ -0,0 +1,180 @@
+ '科技降温 · 清凉一夏', 'subtitle' => '为高温作业人群定制', 'link' => '/products'];
+?>
+
+
+
+
+
❄ SQY COOLING APPAREL
+
体感直降 8-12℃
+
。我们提供水冷循环、相变蓄冷、涡扇风冷三大降温方案,覆盖工业、户外、消防等高温场景。
+
+
+
+
+
+
+
+
+
+
+
产品系列
+
六大降温服系列,总有一款适合你
+
从长时间恒温作业到临时户外移动,覆盖不同降温原理与场景。
+
+
+
+
+
+
+
+
+
精品推荐
+
热门降温服
+
外贸级品质,支持企业 LOGO 绣字与一人一码量体。
+
+
+
+
+
+
+
+
+
+
为什么选择我们
+
20 年服装经验,外贸级品质
+
+
+
+
+
+
+
+
+
+
+
+
+
客户案例
+
他们都在用酷冰甲降温服
+
覆盖钢铁、消防、化工、环卫等高温行业,提供定制化降温方案。
+
+
+
+
+
+
+
+
+
+
常见问题
+
关于降温服,您可能想了解
+
高频疑问一站式解答,定制与选型更省心。
+
+
+
+
+
+
+
+
+
免费拿样 · 5 天出设计方案
+
提交您的需求,专属顾问 1 对 1 为您提供降温服装定制方案,支持企业 LOGO 绣字。
+
立即咨询
+
+
+
diff --git a/app/Views/layouts/admin.php b/app/Views/layouts/admin.php
new file mode 100644
index 0000000..8213b03
--- /dev/null
+++ b/app/Views/layouts/admin.php
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+ 后台管理 ·
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/layouts/site.php b/app/Views/layouts/site.php
new file mode 100644
index 0000000..211006d
--- /dev/null
+++ b/app/Views/layouts/site.php
@@ -0,0 +1,202 @@
+view() 或 $data 传入)────
+$pageSeo = $pageSeo ?? [];
+$seoTitle = $pageSeo['title'] ?? $site['seo_title'];
+$seoDesc = $pageSeo['description'] ?? $site['seo_description'];
+$seoKeywords = $pageSeo['keywords'] ?? $site['seo_keywords'];
+$ogImage = $pageSeo['og_image'] ?? (site_url() . 'assets/img/og-default.png');
+$ogType = $pageSeo['og_type'] ?? 'website';
+$jsonLd = $pageSeo['jsonld'] ?? '';
+$breadcrumb = $pageSeo['breadcrumb'] ?? null;
+$noindex = $pageSeo['noindex'] ?? false;
+$canonical = $pageSeo['canonical'] ?? absolute_url();
+
+// 页面级 title 自动追加站点名后缀(如果没包含)
+if (!empty($pageSeo['title']) && mb_strpos($seoTitle, $name) === false) {
+ $seoTitle = $seoTitle . ' - ' . $name;
+}
+
+$current = trim(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), '/');
+$nav = [
+ ['label' => '首页', 'url' => ''],
+ ['label' => '产品中心', 'url' => 'products'],
+ ['label' => '新闻动态', 'url' => 'news'],
+ ['label' => '客户案例', 'url' => 'cases'],
+ ['label' => '关于我们', 'url' => 'page/about'],
+ ['label' => '联系我们', 'url' => 'contact'],
+];
+$isActive = function ($url) use ($current) {
+ if ($url === '') return $current === '';
+ return strpos($current, $url) === 0;
+};
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/layouts/subsys.php b/app/Views/layouts/subsys.php
new file mode 100644
index 0000000..4ec56b9
--- /dev/null
+++ b/app/Views/layouts/subsys.php
@@ -0,0 +1,95 @@
+ 图标(避免逐个控制器改造,统一在此映射)
+$icMap = [
+ 'dashboard' => 'dashboard', 'customers' => 'handshake', 'leads' => 'lightbulb',
+ 'followups' => 'phone', 'contacts' => 'users', 'materials' => 'package',
+ 'products' => 'shopping-bag', 'suppliers' => 'truck', 'purchases' => 'inbox',
+ 'sales' => 'cart', 'stock' => 'database', 'orders' => 'receipt', 'users' => 'users', 'admin' => 'shield',
+ 'sales_orders' => 'shopping-bag', 'purchase_orders' => 'file-text', 'outbounds' => 'package', 'reports' => 'bar-chart',
+ 'reminders' => 'bell', 'notifications' => 'settings',
+];
+?>
+
+
+
+
+
+ ·
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/news/index.php b/app/Views/news/index.php
new file mode 100644
index 0000000..8170ff2
--- /dev/null
+++ b/app/Views/news/index.php
@@ -0,0 +1,23 @@
+
+
+
新闻动态
+
产品发布、技术科普与行业案例
+
+
+
diff --git a/app/Views/news/show.php b/app/Views/news/show.php
new file mode 100644
index 0000000..734b272
--- /dev/null
+++ b/app/Views/news/show.php
@@ -0,0 +1,36 @@
+
+
+
+
+
+ $layoutArr, 'item' => $n, 'module' => 'news']); ?>
+
+
+
+
+
diff --git a/app/Views/order/checkout.php b/app/Views/order/checkout.php
new file mode 100644
index 0000000..c537277
--- /dev/null
+++ b/app/Views/order/checkout.php
@@ -0,0 +1,34 @@
+
+
+
diff --git a/app/Views/order/pay.php b/app/Views/order/pay.php
new file mode 100644
index 0000000..de8fed9
--- /dev/null
+++ b/app/Views/order/pay.php
@@ -0,0 +1,50 @@
+
+
+
diff --git a/app/Views/order/query.php b/app/Views/order/query.php
new file mode 100644
index 0000000..76ecf16
--- /dev/null
+++ b/app/Views/order/query.php
@@ -0,0 +1,49 @@
+ array, 'payments' => array] */
+/** @var string $no */
+/** @var string $name */
+/** @var string $phone */
+/** @var string $err */
+?>
+订单查询
凭 客户名 + 手机号 核验身份,即可查询您的订单与付款信息(也可加订单号精确查单笔)
+
diff --git a/app/Views/order/result.php b/app/Views/order/result.php
new file mode 100644
index 0000000..5d1c83b
--- /dev/null
+++ b/app/Views/order/result.php
@@ -0,0 +1,35 @@
+
+
+
diff --git a/app/Views/page/show.php b/app/Views/page/show.php
new file mode 100644
index 0000000..27fd467
--- /dev/null
+++ b/app/Views/page/show.php
@@ -0,0 +1,24 @@
+
+
+ $layout, 'item' => $p, 'module' => 'page']); ?>
+
+
+
+
diff --git a/app/Views/parts/canvas.php b/app/Views/parts/canvas.php
new file mode 100644
index 0000000..cf00992
--- /dev/null
+++ b/app/Views/parts/canvas.php
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
; ?>)
+
+
+
+
+
立即购买
+
+
¥ 起/套
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/product/index.php b/app/Views/product/index.php
new file mode 100644
index 0000000..971a1cb
--- /dev/null
+++ b/app/Views/product/index.php
@@ -0,0 +1,48 @@
+
+
+
+
diff --git a/app/Views/product/show.php b/app/Views/product/show.php
new file mode 100644
index 0000000..3b18274
--- /dev/null
+++ b/app/Views/product/show.php
@@ -0,0 +1,132 @@
+
+
+
+
+
+ $layoutArr, 'item' => $p, 'module' => 'product']); ?>
+
+
+
+
+
+
+
+
¥ 起 / 套
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/psi/adjust_form.php b/app/Views/psi/adjust_form.php
new file mode 100644
index 0000000..9e74791
--- /dev/null
+++ b/app/Views/psi/adjust_form.php
@@ -0,0 +1,23 @@
+
+调整库存 —
+
+
当前库存:
+
+
diff --git a/app/Views/psi/dashboard.php b/app/Views/psi/dashboard.php
new file mode 100644
index 0000000..2e2a158
--- /dev/null
+++ b/app/Views/psi/dashboard.php
@@ -0,0 +1,144 @@
+
+
+
进销存仪表盘
+
服装生产 · 进出口贸易 · 终端销售 一体化库存
+
+
+
+
+
+
+
+
+
+
暂无销售订单数据
+
+
+ | 业务员 | 订单数 | 销售额 |
+
+
+ | |
+ ¥ |
+
+
+
+
+
+
+
+
+
+
+
+
最近采购入库
+
暂无采购单
+
+ | 单号 | 物料/成品 | 数量 | 金额 | 日期 |
+
+
+
+ |
+ |
+ |
+ ¥ |
+ |
+
+
+
+
+
+
+
+
最近销售出库
+
暂无销售单
+
+ | 单号 | 物料/成品 | 数量 | 金额 | 日期 |
+
+
+
+ |
+ |
+ |
+ ¥ |
+ |
+
+
+
+
+
+
+
+
+
+
库存预警(低于 20)
+
+
当前库存充足 ✅
+
+
+ | 名称 | 类型 | 当前库存 |
+
+
+
+ |
+ |
+ |
+
+
+
+
+
+
diff --git a/app/Views/psi/material_form.php b/app/Views/psi/material_form.php
new file mode 100644
index 0000000..dcd4d10
--- /dev/null
+++ b/app/Views/psi/material_form.php
@@ -0,0 +1,45 @@
+ '面料', '辅料' => '辅料', '纱线' => '纱线', '包装' => '包装', '其他' => '其他'];
+$curCat = $m['category'] ?? '面料';
+if ($curCat && !isset($catOpts[$curCat])) { $catOpts[$curCat] = $curCat; }
+?>
+
+
diff --git a/app/Views/psi/materials.php b/app/Views/psi/materials.php
new file mode 100644
index 0000000..0b36afa
--- /dev/null
+++ b/app/Views/psi/materials.php
@@ -0,0 +1,38 @@
+
+
+
+
+ | ID | 编码 | 名称 | 规格 | 单位 | 分类 | 颜色 | 成分 | 克重(g) | 幅宽(cm) | 缸号 | 库存 | 单价 | 供应商 | 操作 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ ¥ |
+ |
+
+ 调整库存
+ 编辑
+ 删除
+ |
+
+
+ | 暂无物料,点击右上角新增 |
+
+
+
diff --git a/app/Views/psi/notifications.php b/app/Views/psi/notifications.php
new file mode 100644
index 0000000..803adcb
--- /dev/null
+++ b/app/Views/psi/notifications.php
@@ -0,0 +1,46 @@
+ !empty($v[$k]) ? 'checked' : '';
+?>
+
+
通知设置
配置“新订单 / 新事件”的负责人通知渠道(邮件 + 企业微信)。
+
+
+
diff --git a/app/Views/psi/order_show.php b/app/Views/psi/order_show.php
new file mode 100644
index 0000000..d94b124
--- /dev/null
+++ b/app/Views/psi/order_show.php
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+ | 商品 | |
+ | 客户 | (/ ) |
+ | 数量 | 套 |
+ | 金额 | ¥ |
+ | 支付通道 | |
+ | 状态 |
+ 已支付'
+ : '待支付'; ?>
+ ()
+ |
+ | 网关交易号 | |
+ | 下单时间 | |
+
+
+
+
+
+
+
+
付款记录
+
+ | 通道 | 交易号 | 金额 | 状态 | 付款时间 |
+
+
+
+ |
+ |
+ ¥ |
+ |
+ |
+
+
+
+
+
+
diff --git a/app/Views/psi/orders.php b/app/Views/psi/orders.php
new file mode 100644
index 0000000..99ba8e4
--- /dev/null
+++ b/app/Views/psi/orders.php
@@ -0,0 +1,63 @@
+已支付'
+ : '待支付';
+};
+$channelLabel = function ($c) {
+ return $c === 'wechat' ? '微信' : ($c === 'alipay' ? '支付宝' : '—');
+};
+?>
+
+
订单管理
客户下单与付款处理(合并自后台订单,便于在 PSI 内快速处理)
+
+
+
+
+
+
+
订单列表
+ 共 笔
+
+
+
+
+ | 订单号 | 商品 | 客户 | 数量 | 金额 |
+ 通道 | 状态 | 下单时间 | 操作 |
+
+
+
+
+ |
+ |
+
|
+ |
+ ¥ |
+ |
+ |
+ |
+
+ 详情
+
+
+
+
+
+
+ |
+
+
+
+ | 暂无订单 |
+
+
+
+
+
diff --git a/app/Views/psi/outbound_form.php b/app/Views/psi/outbound_form.php
new file mode 100644
index 0000000..6f62386
--- /dev/null
+++ b/app/Views/psi/outbound_form.php
@@ -0,0 +1,131 @@
+ $p['name'], 'spec' => $p['spec'] ?? '', 'unit' => $p['unit'] ?? '', 'price' => (float)($p['price'] ?? 0)]; }
+
+// 行数据来源:编辑=出库明细;新建且带销售订单=销售订单未交明细;否则空行
+$rows = [];
+if ($isEdit) {
+ foreach ($items as $it) $rows[] = ['so_item_id' => $it['so_item_id'], 'product_id' => $it['product_id'], 'name' => $it['name'], 'spec' => $it['spec'], 'unit' => $it['unit'], 'qty' => $it['qty'], 'price' => $it['price']];
+} elseif ($so) {
+ foreach ($soItems as $si) { $remain = max(0, (int)($si['qty'] ?? 0) - (int)($si['delivered_qty'] ?? 0)); if ($remain <= 0) continue;
+ $rows[] = ['so_item_id' => $si['id'], 'product_id' => $si['product_id'], 'name' => $si['name'], 'spec' => $si['spec'], 'unit' => $si['unit'], 'qty' => $remain, 'price' => $si['price']];
+ }
+}
+?>
+
+
+
' . e($so['order_no']) . ' · 已预填未交明细') : '填写出库明细,保存后自动扣减库存'; ?>
+
← 返回
+
+
+
+
+
diff --git a/app/Views/psi/outbound_print.php b/app/Views/psi/outbound_print.php
new file mode 100644
index 0000000..c08b5f6
--- /dev/null
+++ b/app/Views/psi/outbound_print.php
@@ -0,0 +1,32 @@
+ (float)($i['amount'] ?? 0), $items));
+?>
+出库单
+
+
+ 关联销售订单:
+ 客户:
+ 业务员:
+ 仓库:
+ 出库日期:
+
+
+ | 商品 | 规格 | 单位 | 数量 | 单价 | 金额 |
+
+
+
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+
+
+
+ | 合计 | ¥ |
+
+备注:
+发货:收货签字:____________
diff --git a/app/Views/psi/outbound_show.php b/app/Views/psi/outbound_show.php
new file mode 100644
index 0000000..6c1c805
--- /dev/null
+++ b/app/Views/psi/outbound_show.php
@@ -0,0 +1,50 @@
+ ['label' => '已交付', 'cls' => 'tag-green'], 'partial' => ['label' => '部分交付', 'cls' => 'tag-amber']];
+$s = $obStatus[$o['status']] ?? $obStatus['delivered'];
+$amt = array_sum(array_map(fn($i) => (float)($i['amount'] ?? 0), $items));
+?>
+
+
+
+
+ | 关联销售订单 | —'; ?> |
+ | 客户 | |
+ | 业务员 | |
+ | 仓库 | |
+ | 状态 | |
+ | 出库日期 | |
+ | 备注 | |
+
+
+
+
+
出库明细
合计 ¥
+
+
+ | 商品 | 规格 | 单位 | 数量 | 单价 | 金额 |
+
+
+
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+
+
+
+
+
+
+
diff --git a/app/Views/psi/outbounds.php b/app/Views/psi/outbounds.php
new file mode 100644
index 0000000..9f41bf4
--- /dev/null
+++ b/app/Views/psi/outbounds.php
@@ -0,0 +1,46 @@
+ ['label' => '已交付', 'cls' => 'tag-green'], 'partial' => ['label' => '部分交付', 'cls' => 'tag-amber']];
+?>
+
+
出库单
按销售订单发货出库(自动扣减库存并回写已交付量,可直接打印)
+
新建出库单
+
+
+
+
+
+
+ | 出库单号 | 关联销售订单 | 客户 | 业务员 | 仓库 |
+ 金额 | 状态 | 出库日期 | 操作 |
+
+
+ (float)($i['amount'] ?? 0), $o['_items'] ?? []));
+ $s = $obStatus[$o['status']] ?? $obStatus['delivered'];
+ ?>
+
+ |
+ —'; ?> |
+ |
+ |
+ |
+ ¥ |
+ |
+ |
+
+ 详情
+ 打印
+ 编辑
+
+ |
+
+
+ | 暂无出库单 |
+
+
+
+
diff --git a/app/Views/psi/product_form.php b/app/Views/psi/product_form.php
new file mode 100644
index 0000000..6f47528
--- /dev/null
+++ b/app/Views/psi/product_form.php
@@ -0,0 +1,47 @@
+ '降温服', '成衣' => '成衣', '配饰' => '配饰', '其他' => '其他'];
+$curCat = $p['category'] ?? '降温服';
+if ($curCat && !isset($catOpts[$curCat])) { $catOpts[$curCat] = $curCat; }
+$seasonOpts = ['春' => '春', '夏' => '夏', '秋' => '秋', '冬' => '冬', '四季' => '四季'];
+$curSeason = $p['season'] ?? '';
+?>
+
+
diff --git a/app/Views/psi/products.php b/app/Views/psi/products.php
new file mode 100644
index 0000000..3de981b
--- /dev/null
+++ b/app/Views/psi/products.php
@@ -0,0 +1,38 @@
+
+
+
+
+ | ID | 编码 | 款号 | 名称 | 规格 | 单位 | 分类 | 颜色 | 尺码 | 季节 | 年份 | 库存 | 成本 | 售价 | 操作 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+
+ 调整库存
+ 编辑
+ 删除
+ |
+
+
+ | 暂无成品,点击右上角新增 |
+
+
+
diff --git a/app/Views/psi/purchase_form.php b/app/Views/psi/purchase_form.php
new file mode 100644
index 0000000..6a8d06a
--- /dev/null
+++ b/app/Views/psi/purchase_form.php
@@ -0,0 +1,56 @@
+
+新增采购入库
+
+
+
+
diff --git a/app/Views/psi/purchase_order_form.php b/app/Views/psi/purchase_order_form.php
new file mode 100644
index 0000000..843aea0
--- /dev/null
+++ b/app/Views/psi/purchase_order_form.php
@@ -0,0 +1,132 @@
+ [], 'product' => []];
+foreach ($materials as $m) { $cat['material'][(int)$m['id']] = ['name' => $m['name'], 'spec' => $m['spec'] ?? '', 'unit' => $m['unit'] ?? '', 'price' => (float)($m['price'] ?? 0)]; }
+foreach ($products as $p) { $cat['product'][(int)$p['id']] = ['name' => $p['name'], 'spec' => $p['spec'] ?? '', 'unit' => $p['unit'] ?? '', 'price' => (float)($p['price'] ?? 0)]; }
+?>
+
+
选择供应商与采购明细(物料 / 成品),收货后自动入库
+
← 返回
+
+
+
+
+
diff --git a/app/Views/psi/purchase_order_print.php b/app/Views/psi/purchase_order_print.php
new file mode 100644
index 0000000..66f9d5e
--- /dev/null
+++ b/app/Views/psi/purchase_order_print.php
@@ -0,0 +1,32 @@
+ (float)($i['amount'] ?? 0), $items));
+?>
+采购订单
+
+
+ 供应商:
+ 采购员:
+ 期望到货:
+ 下单:
+
+
+ | 类型 | 名称 | 规格 | 单位 | 数量 | 单价 | 金额 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+
+
+
+ | 合计 | ¥ |
+
+备注:
+制单:供应商签字:____________
diff --git a/app/Views/psi/purchase_order_show.php b/app/Views/psi/purchase_order_show.php
new file mode 100644
index 0000000..1fbdc2b
--- /dev/null
+++ b/app/Views/psi/purchase_order_show.php
@@ -0,0 +1,60 @@
+ ['label' => '待处理', 'cls' => 'tag-gray'],
+ 'partial' => ['label' => '部分收货', 'cls' => 'tag-amber'],
+ 'received' => ['label' => '已收货', 'cls' => 'tag-green'],
+ 'closed' => ['label' => '已关闭', 'cls' => 'tag-gray'],
+];
+$s = $poStatus[$o['status']] ?? $poStatus['pending'];
+$amt = array_sum(array_map(fn($i) => (float)($i['amount'] ?? 0), $items));
+?>
+
+
+
+
+ | 供应商 | |
+ | 采购员 | |
+ | 状态 | |
+ | 期望到货 | |
+ | 备注 | |
+
+
+
+
+
采购明细
合计 ¥
+
+
+ | 类型 | 名称 | 规格 | 单位 | 数量 | 单价 | 金额 | 已收 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+ |
+
+
+
+
+
+
+
diff --git a/app/Views/psi/purchase_orders.php b/app/Views/psi/purchase_orders.php
new file mode 100644
index 0000000..455872f
--- /dev/null
+++ b/app/Views/psi/purchase_orders.php
@@ -0,0 +1,55 @@
+ ['label' => '待处理', 'cls' => 'tag-gray'],
+ 'partial' => ['label' => '部分收货', 'cls' => 'tag-amber'],
+ 'received' => ['label' => '已收货', 'cls' => 'tag-green'],
+ 'closed' => ['label' => '已关闭', 'cls' => 'tag-gray'],
+];
+?>
+
+
采购订单
录入对供应商的采购订单,收货后自动入库并增加库存(可打印)
+
新建采购订单
+
+
+
+
+
+
+ | 订单号 | 供应商 | 采购员 | 明细 | 金额 |
+ 状态 | 期望到货 | 操作 |
+
+
+ (float)($i['amount'] ?? 0), $o['_items'] ?? []));
+ $s = $poStatus[$o['status']] ?? $poStatus['pending'];
+ ?>
+
+ |
+ |
+ |
+ 项 |
+ ¥ |
+ |
+ |
+
+ 详情
+ 打印
+
+ 编辑
+
+
+
+ |
+
+
+ | 暂无采购订单 |
+
+
+
+
diff --git a/app/Views/psi/purchases.php b/app/Views/psi/purchases.php
new file mode 100644
index 0000000..72d32f0
--- /dev/null
+++ b/app/Views/psi/purchases.php
@@ -0,0 +1,32 @@
+
+
+
+
提交采购单后系统自动增加对应物料/成品库存,并写入库存流水。
+
+ | 单号 | 供应商 | 类型 | 批次/缸号 | 数量 | 单价 | 金额 | 预计到货 | 日期 | 操作 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+ |
+ |
+
+ 删除
+ |
+
+
+ | 暂无采购记录 |
+
+
+
diff --git a/app/Views/psi/reminder_form.php b/app/Views/psi/reminder_form.php
new file mode 100644
index 0000000..bcb1717
--- /dev/null
+++ b/app/Views/psi/reminder_form.php
@@ -0,0 +1,26 @@
+
+
+
发起紧急提醒
提交后将作为紧急事件通知相关负责人(站内 + 邮件 + 微信,依通知设置)。
+
+
+
+
diff --git a/app/Views/psi/reminders.php b/app/Views/psi/reminders.php
new file mode 100644
index 0000000..ff784e8
--- /dev/null
+++ b/app/Views/psi/reminders.php
@@ -0,0 +1,73 @@
+ '新销售订单',
+ 'purchase_order' => '新采购订单',
+ 'customer_order' => '新客户订单',
+ 'low_stock' => '低库存预警',
+ 'manual' => '手动提醒',
+];
+?>
+
+
+
紧急提醒
+
系统将“新订单 / 新事件”作为紧急事件推送;未读 条。
+
+
+
发起提醒
+ 0): ?>
+
+
+
+
+
+
+ 暂无紧急事件。
+
+
+
diff --git a/app/Views/psi/report_delivery.php b/app/Views/psi/report_delivery.php
new file mode 100644
index 0000000..2c6612c
--- /dev/null
+++ b/app/Views/psi/report_delivery.php
@@ -0,0 +1,59 @@
+'待处理','partial'=>'部分交付','delivered'=>'已交付','closed'=>'已关闭'];
+?>
+
+
交付明细
出库(交付)记录与销售订单交付进度、未交订单
+
← 报表中心
+
+
+
+
销售订单交付进度
应发 / 已发 / 未发
+
+
+ | 销售订单 | 客户 | 业务员 | 状态 | 应发 | 已发 | 未发 | 金额 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ ¥ |
+
+
+ | 暂无销售订单交付数据 |
+
+
+
+
+
+
+
出库(交付)记录
共 单
+
+
+ | 出库单号 | 关联销售订单 | 客户 | 业务员 | 金额 | 出库日期 | 操作 |
+
+ (float)($i['amount'] ?? 0), $o['_items'] ?? []));
+ ?>
+
+ |
+ —'; ?> |
+ |
+ |
+ ¥ |
+ |
+ 详情
+ 打印 |
+
+
+ | 暂无出库记录 |
+
+
+
+
diff --git a/app/Views/psi/report_po_detail.php b/app/Views/psi/report_po_detail.php
new file mode 100644
index 0000000..cbc4654
--- /dev/null
+++ b/app/Views/psi/report_po_detail.php
@@ -0,0 +1,42 @@
+'待处理','partial'=>'部分收货','received'=>'已收货','closed'=>'已关闭'];
+?>
+
+
采购订单明细
每张采购订单的物料/成品明细(共 张)
+
← 报表中心
+
+
+ (float)($i['amount'] ?? 0), $o['_items'] ?? []));
+?>
+
+
+
·
+
+ 采购员 · 合计 ¥
+
+
+
+ | 类型 | 名称 | 规格 | 单位 | 数量 | 单价 | 金额 | 已收 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+ |
+
+
+ | 无明细 |
+
+
+
+
+
+
diff --git a/app/Views/psi/report_so_po.php b/app/Views/psi/report_so_po.php
new file mode 100644
index 0000000..d738885
--- /dev/null
+++ b/app/Views/psi/report_so_po.php
@@ -0,0 +1,66 @@
+'待处理','partial'=>'部分交付','delivered'=>'已交付','closed'=>'已关闭'];
+$poStatus = ['pending'=>'待处理','partial'=>'部分收货','received'=>'已收货','closed'=>'已关闭'];
+?>
+
+
销售 / 采购订单明细
合并查看销售订单与采购订单(含金额)
+
← 报表中心
+
+
+
+
+
+
+
销售订单()
+
+
+ | 订单号 | 客户 | 业务员 | 渠道 | 明细 | 金额 | 状态 |
+
+
+
+ |
+ |
+ |
+ |
+ 项 |
+ ¥ |
+ |
+
+
+ | 无销售订单 |
+
+
+
+
+
+
+
采购订单()
+
+
+ | 订单号 | 供应商 | 采购员 | 明细 | 金额 | 状态 |
+
+
+
+ |
+ |
+ |
+ 项 |
+ ¥ |
+ |
+
+
+ | 无采购订单 |
+
+
+
+
+
diff --git a/app/Views/psi/reports.php b/app/Views/psi/reports.php
new file mode 100644
index 0000000..3d009f3
--- /dev/null
+++ b/app/Views/psi/reports.php
@@ -0,0 +1,25 @@
+ '采购订单明细', 'desc' => '按采购订单查看物料/成品明细', 'url' => 'PSI/reports/poDetail', 'icon' => 'file-text', 'n' => $poCount],
+ ['label' => '销售/采购订单明细', 'desc' => '合并查看两类订单与金额', 'url' => 'PSI/reports/soPo', 'icon' => 'clipboard', 'n' => $soCount + $poCount],
+ ['label' => '交付明细', 'desc' => '出库交付进度与未交订单', 'url' => 'PSI/reports/delivery', 'icon' => 'truck', 'n' => $obCount],
+];
+?>
+
+
+
diff --git a/app/Views/psi/sale_form.php b/app/Views/psi/sale_form.php
new file mode 100644
index 0000000..e27dc4a
--- /dev/null
+++ b/app/Views/psi/sale_form.php
@@ -0,0 +1,56 @@
+
+新增销售出库
+
+
+
+
diff --git a/app/Views/psi/sales.php b/app/Views/psi/sales.php
new file mode 100644
index 0000000..9d52a86
--- /dev/null
+++ b/app/Views/psi/sales.php
@@ -0,0 +1,34 @@
+ '内销', 'export' => '外贸出口'];
+?>
+
+
+
支持内销与外贸出口。提交后自动扣减库存,库存不足将被拦截。
+
+ | 单号 | 客户 | 渠道 | 区域 | 数量 | 单价 | 金额 | 日期 | 操作 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+ |
+
+ 查看
+ 打印
+ 删除
+ |
+
+
+ | 暂无销售记录 |
+
+
+
diff --git a/app/Views/psi/sales_order_form.php b/app/Views/psi/sales_order_form.php
new file mode 100644
index 0000000..73797ba
--- /dev/null
+++ b/app/Views/psi/sales_order_form.php
@@ -0,0 +1,125 @@
+ $p['name'], 'spec' => $p['spec'] ?? '', 'unit' => $p['unit'] ?? '', 'price' => (float)($p['price'] ?? 0)]; }
+?>
+
+
+
+
+
diff --git a/app/Views/psi/sales_order_print.php b/app/Views/psi/sales_order_print.php
new file mode 100644
index 0000000..c6ce091
--- /dev/null
+++ b/app/Views/psi/sales_order_print.php
@@ -0,0 +1,33 @@
+ (float)($i['amount'] ?? 0), $items));
+$chn = $o['channel'] === 'export' ? '外贸' : '内贸';
+?>
+销售订单
+
+
+ 客户:
+ 业务员:
+ 渠道:
+ 交货日期:
+ 下单:
+
+
+ | 商品 | 规格 | 单位 | 数量 | 单价 | 金额 |
+
+
+
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+
+
+
+ | 合计 | ¥ |
+
+备注:
+制单:客户签字:____________
diff --git a/app/Views/psi/sales_order_show.php b/app/Views/psi/sales_order_show.php
new file mode 100644
index 0000000..56aad2b
--- /dev/null
+++ b/app/Views/psi/sales_order_show.php
@@ -0,0 +1,57 @@
+ ['label' => '待处理', 'cls' => 'tag-gray'],
+ 'partial' => ['label' => '部分交付', 'cls' => 'tag-amber'],
+ 'delivered'=> ['label' => '已交付', 'cls' => 'tag-green'],
+ 'closed' => ['label' => '已关闭', 'cls' => 'tag-gray'],
+];
+$s = $soStatus[$o['status']] ?? $soStatus['pending'];
+$amt = array_sum(array_map(fn($i) => (float)($i['amount'] ?? 0), $items));
+?>
+
+
+
+
+ | 客户 | |
+ | 业务员 | |
+ | 渠道 | · |
+ | 状态 | |
+ | 交货日期 | |
+ | 备注 | |
+
+
+
+
+
商品明细
合计 ¥
+
+
+ | 商品 | 规格 | 单位 | 数量 | 单价 | 金额 | 已交付 | 未交付 |
+
+
+
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+ |
+ |
+
+
+
+
+
+
+
diff --git a/app/Views/psi/sales_orders.php b/app/Views/psi/sales_orders.php
new file mode 100644
index 0000000..40a9c64
--- /dev/null
+++ b/app/Views/psi/sales_orders.php
@@ -0,0 +1,52 @@
+ ['label' => '待处理', 'cls' => 'tag-gray'],
+ 'partial' => ['label' => '部分交付', 'cls' => 'tag-amber'],
+ 'delivered'=> ['label' => '已交付', 'cls' => 'tag-green'],
+ 'closed' => ['label' => '已关闭', 'cls' => 'tag-gray'],
+];
+?>
+
+
销售订单
录入客户销售订单,可关联出库单进行交付(直接打印)
+
新建销售订单
+
+
+
+
+
+
+ | 订单号 | 客户 | 业务员 | 渠道 | 明细 |
+ 金额 | 状态 | 交货日期 | 操作 |
+
+
+ (float)($i['amount'] ?? 0), $o['_items'] ?? []));
+ $s = $soStatus[$o['status']] ?? $soStatus['pending'];
+ ?>
+
+ |
+ |
+ |
+ |
+ 项 |
+ ¥ |
+ |
+ |
+
+ 详情
+ 打印
+ 编辑
+ 出库
+
+ |
+
+
+ | 暂无销售订单 |
+
+
+
+
diff --git a/app/Views/psi/sales_print.php b/app/Views/psi/sales_print.php
new file mode 100644
index 0000000..ac57004
--- /dev/null
+++ b/app/Views/psi/sales_print.php
@@ -0,0 +1,30 @@
+
+销售出库单
+
+
+ 客户:
+ 渠道:
+ 批次/缸号:
+ 出库日期:
+
+
+ | 物品 | 规格 | 单位 | 数量 | 单价 | 金额 |
+
+
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+
+
+ | 合计 | ¥ |
+
+备注:
+发货:____________收货签字:____________
diff --git a/app/Views/psi/sales_show.php b/app/Views/psi/sales_show.php
new file mode 100644
index 0000000..5b9e295
--- /dev/null
+++ b/app/Views/psi/sales_show.php
@@ -0,0 +1,43 @@
+
+
+
+
+
+ | 客户 | |
+ | 渠道 | |
+ | 区域 | |
+ | 批次/缸号 | |
+ | 出库日期 | |
+ | 备注 | |
+
+
+
+
+
出库明细
合计 ¥
+
+
+ | 物品 | 规格 | 单位 | 数量 | 单价 | 金额 |
+
+
+ |
+ |
+ |
+ |
+ ¥ |
+ ¥ |
+
+
+
+
+
diff --git a/app/Views/psi/stock.php b/app/Views/psi/stock.php
new file mode 100644
index 0000000..19ba563
--- /dev/null
+++ b/app/Views/psi/stock.php
@@ -0,0 +1,29 @@
+
+
+
库存流水
+
所有采购入库 / 销售出库 / 手动调整 的变动记录
+
+
+
+ | 时间 | 类型 | 名称 | 方向 | 数量 | 批次/缸号 | 关联单号 |
+
+
+
+ |
+ |
+ |
+
+ 入库 +
+ 出库 -
+ |
+ |
+ |
+ |
+
+
+ | 暂无库存流水 |
+
+
+
diff --git a/app/Views/psi/supplier_form.php b/app/Views/psi/supplier_form.php
new file mode 100644
index 0000000..4abf5f2
--- /dev/null
+++ b/app/Views/psi/supplier_form.php
@@ -0,0 +1,42 @@
+ '面料商', '辅料商' => '辅料商', '成衣加工厂' => '成衣加工厂', 'OEM' => 'OEM', '物流' => '物流', '其他' => '其他'];
+$gradeOpts = ['战略' => '战略', '合格' => '合格', '试用' => '试用', '淘汰' => '淘汰'];
+$curType = $s['type'] ?? '';
+$curGrade = $s['grade'] ?? '';
+?>
+
+
diff --git a/app/Views/psi/suppliers.php b/app/Views/psi/suppliers.php
new file mode 100644
index 0000000..790fc6c
--- /dev/null
+++ b/app/Views/psi/suppliers.php
@@ -0,0 +1,33 @@
+
+
+
+
+ | ID | 名称 | 类型 | 等级 | 联系人 | 电话 | 国家/地区 | 交期达成率 | 质检合格率 | 备注 | 操作 |
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+ 编辑
+ 删除
+ |
+
+
+ | 暂无供应商 |
+
+
+
diff --git a/app/Views/subsys/user_form.php b/app/Views/subsys/user_form.php
new file mode 100644
index 0000000..6fe7226
--- /dev/null
+++ b/app/Views/subsys/user_form.php
@@ -0,0 +1,65 @@
+
+
+
+
+
+
diff --git a/app/Views/subsys/user_reset.php b/app/Views/subsys/user_reset.php
new file mode 100644
index 0000000..548b355
--- /dev/null
+++ b/app/Views/subsys/user_reset.php
@@ -0,0 +1,25 @@
+
+
+
+
+
+
diff --git a/app/Views/subsys/users.php b/app/Views/subsys/users.php
new file mode 100644
index 0000000..56edcfd
--- /dev/null
+++ b/app/Views/subsys/users.php
@@ -0,0 +1,72 @@
+ '超级管理员', 'admin' => '管理员', 'user' => '普通用户', 'none' => '无'];
+$upper = strtoupper($sys);
+?>
+
+
用户管理
+
· 管理系统内的用户账号及其页面访问权限
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 用户名 |
+ 姓名 |
+ 角色 |
+ 页面权限 |
+ 状态 |
+ 操作 |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+ 仅仪表盘
+
+
+
+ |
+
+ 启用停用
+ |
+
+ 编辑
+ 重置密码
+
+ |
+
+
+
+ | 暂无用户 |
+
+
+
+
+
diff --git a/cleanup_badzip_residue.sh b/cleanup_badzip_residue.sh
new file mode 100644
index 0000000..7d6eebf
--- /dev/null
+++ b/cleanup_badzip_residue.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# ============================================================
+# 清理「路径错误的旧 zip」解压到网站根目录产生的扁平散落文件
+# 用法:把本脚本放到网站根目录(与 index.php 同级),然后执行:
+# bash cleanup_badzip_residue.sh
+# 说明:仅删除明确属于错误包的文件,不会动 index.php / PATCH-README*.txt
+# / app/ / install/ / public/ / config/ / storage/ 等正常文件。
+# ============================================================
+set -e
+
+# 必须在网站根目录(存在 app/ 目录)运行,防止误删
+if [ ! -d app ]; then
+ echo "✗ 未在当前目录发现 app/ 目录,请在网站根目录运行本脚本"
+ exit 1
+fi
+
+ROOT="$(pwd)"
+echo "==> 网站根目录: $ROOT"
+
+# 错误的扁平 PHP 文件(来自 app/Controllers、app/Core、app/Models、app/Views 被压扁)
+FILES=(
+ AdminController.php AuthController.php UpgradeController.php DashboardController.php
+ ContactController.php OrderController.php MaterialsController.php ProductsController.php
+ PurchasesController.php SalesController.php StockController.php StockHelper.php
+ SuppliersController.php App.php Helper.php Installer.php Model.php
+ DatabaseController.php UsersController.php NotificationsController.php OrdersController.php
+ OutboundsController.php PurchaseOrdersController.php RemindersController.php ReportsController.php
+ SalesOrdersController.php Notify.php Event.php Outbound.php OutboundItem.php
+ PurchaseOrder.php PurchaseOrderItem.php SalesOrder.php SalesOrderItem.php
+ dashboard.php login.php order_show.php orders.php system.php upgrade.php users.php
+ contacts.php customers.php followups.php leads.php admin.php site.php subsys.php
+ materials.php products.php purchases.php sales.php suppliers.php
+ db_browse.php db_form.php db_tables.php notifications.php outbound_form.php
+ outbound_print.php outbound_show.php outbounds.php purchase_order_form.php
+ purchase_order_print.php purchase_order_show.php purchase_orders.php reminder_form.php
+ reminders.php report_delivery.php report_po_detail.php report_so_po.php reports.php
+ sales_order_form.php sales_order_print.php sales_order_show.php sales_orders.php
+ user_form.php user_reset.php
+)
+# 错误包把 install/*.sql、public/* 也压扁到了根目录
+FILES+=( schema.sql seed.php psi_orders.sql admin.css subsys.css admin.js robots.txt )
+
+# 错误包产生的扁平目录
+DIRS=( Subsys subsys upgrades )
+
+DELETED=0
+for f in "${FILES[@]}"; do
+ if [ -f "$f" ]; then rm -f "$f"; echo " 删除文件 $f"; DELETED=$((DELETED+1)); fi
+done
+for d in "${DIRS[@]}"; do
+ if [ -d "$d" ]; then rm -rf "$d"; echo " 删除目录 $d/"; DELETED=$((DELETED+1)); fi
+done
+
+echo ""
+echo "✓ 清理完成,共删除 $DELETED 项。"
+echo " 保留:index.php / PATCH-README*.txt / app/ / install/ / public/ / config/ / storage/ 等。"
+echo " 建议随后用修正版 圣巧依_PSI升级包.zip 重新解压(路径已正确),并到后台执行数据库升级。"
diff --git a/config/config.php b/config/config.php
new file mode 100644
index 0000000..150a346
--- /dev/null
+++ b/config/config.php
@@ -0,0 +1,35 @@
+ [
+ 'name' => '酷冰甲 · 降温服',
+ 'slogan' => '科技降温 · 清凉一夏',
+ 'driver' => 'mysql', // file | mysql —— 本地已切到 MySQL,与远端(宝塔)保持一致
+ 'admin_path' => 'admin',
+ 'timezone' => 'Asia/Shanghai',
+ ],
+ 'db' => [
+ 'mysql' => [
+ 'host' => '127.0.0.1',
+ 'port' => 3306,
+ 'dbname' => 'coolcoth_comf',
+ 'user' => 'coolcoth_comf',
+ 'pass' => 'QDBKzY817hDhTiKk',
+ 'charset' => 'utf8mb4',
+ ],
+ 'file' => [
+ 'dir' => __DIR__ . '/../storage/data',
+ ],
+ ],
+ // 后台登录账号(文件模式 / 首次安装)
+ 'admin' => [
+ 'username' => 'admin',
+ 'password' => 'admin888',
+ // 本地规则红线:生产环境(mysql 模式)必须关闭配置文件兜底账号,禁用默认密码后门。
+ // 仅本地演示(file 模式)可临时置 true。远端已实测默认密码可登录,已置 false 封堵。
+ 'allow_config_fallback' => false,
+ ],
+];
diff --git a/deploy.sh b/deploy.sh
new file mode 100644
index 0000000..0c98bf0
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# ============================================================
+# 酷冰甲官网 - 一键部署脚本(上传文件后运行一次)
+# 用法:
+# bash deploy.sh # 文件模式(默认,无需数据库)
+# DB=1 bash deploy.sh # 先改好 config/config.php 的 mysql 段再跑
+# ============================================================
+set -e
+
+echo "==> 检测 PHP 环境 ..."
+# 优先用 PATH 里的 php;找不到时自动探测宝塔等常见安装路径
+PHP_BIN="$(command -v php || true)"
+if [ -z "$PHP_BIN" ]; then
+ for p in /www/server/php/74/bin/php /www/server/php/80/bin/php \
+ /www/server/php/81/bin/php /www/server/php/82/bin/php \
+ /www/server/php/83/bin/php /usr/local/php/bin/php \
+ /usr/bin/php7.4 /usr/bin/php; do
+ if [ -x "$p" ]; then PHP_BIN="$p"; break; fi
+ done
+fi
+if [ -z "$PHP_BIN" ]; then
+ echo "✗ 未找到 php 命令,请先安装 PHP 7.4+ 并将其加入 PATH"
+ exit 1
+fi
+"$PHP_BIN" -v | head -1
+
+echo "==> 设置目录写权限 ..."
+# 关键点:宝塔/虚拟主机上 PHP 进程通常以 www 用户运行,
+# 上传后的文件若归 root,则 www 无写权限,后台保存会被静默丢弃。
+# 这里把数据目录与生成 CSS 的目录一并 chown 给 www(仅 root 可执行 chown)。
+if [ "$(id -u)" -eq 0 ]; then
+ chown -R www:www storage 2>/dev/null || true
+ chown -R www:www public/assets/css 2>/dev/null || true
+ echo " 已将 storage / public/assets/css 属主改为 www"
+else
+ echo " 当前非 root,跳过 chown(如保存失败请手动: chown -R www:www storage public/assets/css)"
+fi
+chmod -R 755 storage 2>/dev/null || true
+chmod -R 755 storage/data 2>/dev/null || true
+chmod -R 755 public/assets 2>/dev/null || true
+chmod -R 755 public/assets/css 2>/dev/null || true
+
+echo "==> 初始化数据 (install/install.php) ..."
+"$PHP_BIN" install/install.php
+
+echo ""
+echo "✓ 部署完成!"
+echo " 前台: http://你的域名/"
+echo " 后台: http://你的域名/admin (默认账号 admin / admin888)"
+echo ""
+echo "安全建议(生产环境务必执行):"
+echo " 1) 部署成功后删除安装目录: rm -rf install"
+echo " 2) 修改后台密码: 后台 -> 设置 -> 管理员"
+echo " 3) 若用 MySQL: 确认 config/config.php 的 driver=mysql 且账号安全"
diff --git a/deploy/nginx-security-headers.conf b/deploy/nginx-security-headers.conf
new file mode 100644
index 0000000..3260e9c
--- /dev/null
+++ b/deploy/nginx-security-headers.conf
@@ -0,0 +1,29 @@
+# ============================================================
+# 酷冰甲 CMS / coolcoth.com — 宝塔 Nginx 安全响应头片段
+# 用法:宝塔 → 网站 → 设置 → 配置文件,在 server { ... } 块内、
+# location / { ... } 之前(或之内)整段粘贴即可。
+# 说明:always 必须加,否则 4xx/5xx 和静态文件不会带这些头。
+# ============================================================
+
+# 缺失项补齐(审计 P2)
+add_header X-Content-Type-Options "nosniff" always;
+add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), interest-cohort=()" always;
+add_header X-Frame-Options "SAMEORIGIN" always;
+
+# HSTS 升级:含子域 + preload(审计当前线上仅 max-age=31536000)
+# ⚠️ 若 宝塔「SSL」面板已自动下发 HSTS,请删掉那一处,避免重复下发两条。
+add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+
+# CSP(含动态 nonce)无法在 Nginx 静态下发,继续由 PHP 层 apply_security_headers() 输出,勿在此重复。
+
+# ============================================================
+# 源站隐身(curl --resolve 直连 200 的治理,需配合 CDN)
+# 仅当已接入 EdgeOne / Cloudflare 后启用:仅放行 CDN 回源 IP 段。
+# 例(Cloudflare,需替换为你实际接入的 CDN 回源段;EdgeOne 用其官方回源 IP 列表):
+# allow 173.245.48.0/20;
+# allow 103.21.244.0/22;
+# ...(完整段见 CDN 官方文档)
+# deny all;
+# 注:未接入 CDN 前不要写 deny all,否则正常用户也进不来。
+# ============================================================
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..86aeca2
--- /dev/null
+++ b/index.html
@@ -0,0 +1,39 @@
+
+
+
+
+ 恭喜,站点创建成功!
+
+
+
+
+
恭喜, 站点创建成功!
+
这是默认index.html,本页面由系统自动生成
+
+ - 本页面在FTP根目录下的index.html
+ - 您可以修改、删除或覆盖本页面
+ - FTP相关信息,请到“面板系统后台 > FTP” 查看
+
+
+
+
\ No newline at end of file
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..c145dd5
--- /dev/null
+++ b/index.php
@@ -0,0 +1,6 @@
+" . htmlspecialchars($msg) . "\n";
+};
+
+/**
+ * 增量升级模式:仅给已部署的系统补齐新模块的数据表/列,绝不触碰已有业务数据。
+ * 与 install/upgrade_crm_psi.sql 逻辑等价,但改用 PDO 直连(避免 DELIMITER/存储过程在 PHP 侧不兼容)。
+ * 用法:php install/install.php --upgrade 或 Web: /install/install.php?upgrade=1
+ */
+function runUpgrade(): void
+{
+ global $out;
+ $driver = Core\Db::driver();
+ if ($driver !== 'mysql') {
+ $out("⚠ 当前为 file 模式,CRM/PSI 在文件模式下自动生成 JSON,无需数据库升级。");
+ return;
+ }
+ $pdo = Core\Db::pdo();
+ $pdo->exec("SET NAMES utf8mb4");
+
+ $stmts = [
+ // ===== CRM 客户管理 =====
+ "CREATE TABLE IF NOT EXISTS `crm_customers` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(120) NOT NULL, `company` VARCHAR(160) DEFAULT '',
+ `contact` VARCHAR(60) DEFAULT '', `phone` VARCHAR(40) DEFAULT '', `email` VARCHAR(120) DEFAULT '',
+ `country` VARCHAR(60) DEFAULT '', `type` VARCHAR(20) DEFAULT 'trade', `source` VARCHAR(40) DEFAULT '',
+ `level` VARCHAR(20) DEFAULT 'C', `remark` TEXT, `owner` VARCHAR(60) DEFAULT '', `created_at` VARCHAR(20) DEFAULT ''
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+ "CREATE TABLE IF NOT EXISTS `crm_leads` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY, `customer_id` INT DEFAULT 0, `title` VARCHAR(200) NOT NULL,
+ `amount` DECIMAL(12,2) DEFAULT 0, `stage` VARCHAR(20) DEFAULT 'new', `expected_close` VARCHAR(20) DEFAULT '',
+ `owner` VARCHAR(60) DEFAULT '', `remark` TEXT, `created_at` VARCHAR(20) DEFAULT ''
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+ "CREATE TABLE IF NOT EXISTS `crm_followups` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY, `customer_id` INT DEFAULT 0, `lead_id` INT DEFAULT 0,
+ `content` TEXT, `next_at` VARCHAR(20) DEFAULT '', `owner` VARCHAR(60) DEFAULT '', `created_at` VARCHAR(20) DEFAULT ''
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+ // ===== 进销存 PSI =====
+ "CREATE TABLE IF NOT EXISTS `psi_materials` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY, `code` VARCHAR(40) DEFAULT '', `name` VARCHAR(160) NOT NULL,
+ `spec` VARCHAR(120) DEFAULT '', `unit` VARCHAR(10) DEFAULT '个', `category` VARCHAR(40) DEFAULT '',
+ `stock` DECIMAL(12,2) DEFAULT 0, `price` DECIMAL(10,2) DEFAULT 0, `supplier_id` INT DEFAULT 0,
+ `remark` TEXT, `created_at` VARCHAR(20) DEFAULT ''
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+ "CREATE TABLE IF NOT EXISTS `psi_products` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY, `code` VARCHAR(40) DEFAULT '', `name` VARCHAR(160) NOT NULL,
+ `spec` VARCHAR(120) DEFAULT '', `unit` VARCHAR(10) DEFAULT '件', `category` VARCHAR(40) DEFAULT '',
+ `stock` DECIMAL(12,2) DEFAULT 0, `cost` DECIMAL(10,2) DEFAULT 0, `price` DECIMAL(10,2) DEFAULT 0,
+ `remark` TEXT, `created_at` VARCHAR(20) DEFAULT ''
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+ "CREATE TABLE IF NOT EXISTS `psi_suppliers` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(160) NOT NULL, `contact` VARCHAR(60) DEFAULT '',
+ `phone` VARCHAR(40) DEFAULT '', `country` VARCHAR(60) DEFAULT '', `remark` TEXT, `created_at` VARCHAR(20) DEFAULT ''
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+ "CREATE TABLE IF NOT EXISTS `psi_purchases` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY, `order_no` VARCHAR(40) NOT NULL, `supplier_id` INT DEFAULT 0,
+ `item_type` VARCHAR(10) DEFAULT 'material', `item_id` INT DEFAULT 0, `qty` DECIMAL(12,2) DEFAULT 0,
+ `price` DECIMAL(10,2) DEFAULT 0, `amount` DECIMAL(12,2) DEFAULT 0, `status` VARCHAR(20) DEFAULT 'pending',
+ `remark` TEXT, `created_at` VARCHAR(20) DEFAULT ''
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+ "CREATE TABLE IF NOT EXISTS `psi_sales` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY, `order_no` VARCHAR(40) NOT NULL, `customer` VARCHAR(160) DEFAULT '',
+ `channel` VARCHAR(20) DEFAULT 'domestic', `item_id` INT DEFAULT 0, `qty` DECIMAL(12,2) DEFAULT 0,
+ `price` DECIMAL(10,2) DEFAULT 0, `amount` DECIMAL(12,2) DEFAULT 0, `status` VARCHAR(20) DEFAULT 'pending',
+ `remark` TEXT, `created_at` VARCHAR(20) DEFAULT ''
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+ "CREATE TABLE IF NOT EXISTS `psi_stock_moves` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY, `item_type` VARCHAR(10) DEFAULT 'product', `item_id` INT DEFAULT 0,
+ `direction` VARCHAR(10) DEFAULT 'in', `qty` DECIMAL(12,2) DEFAULT 0, `ref_no` VARCHAR(40) DEFAULT '',
+ `remark` TEXT, `created_at` VARCHAR(20) DEFAULT ''
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+ ];
+ foreach ($stmts as $s) { $pdo->exec($s); }
+ $out("✓ CRM/PSI 数据表已就绪(CREATE TABLE IF NOT EXISTS,幂等可重复执行)");
+
+ // 为 admin_users 补齐分系统角色列(先查 information_schema,存在则跳过,兼容 5.7/8.0)
+ $cols = $pdo->query("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_users'")->fetchAll(PDO::FETCH_COLUMN);
+ foreach (['crm_role' => "VARCHAR(20) DEFAULT 'none'", 'psi_role' => "VARCHAR(20) DEFAULT 'none'"] as $c => $def) {
+ if (!in_array($c, $cols, true)) {
+ $pdo->exec("ALTER TABLE `admin_users` ADD COLUMN `{$c}` {$def}");
+ $out("✓ 已添加 admin_users.{$c}");
+ } else {
+ $out("⊘ admin_users.{$c} 已存在,跳过");
+ }
+ }
+ $out("✓ 数据升级完成。请登录后台 → 用户管理,将相关账号 crm_role / psi_role 设为 admin。");
+}
+
+// 升级模式:绕过 installed.lock,仅增量建表/补列
+$isUpgrade = ($cli && in_array('--upgrade', $argv ?? [], true))
+ || (!$cli && trim((string)($_GET['upgrade'] ?? '')) === '1');
+if ($isUpgrade) {
+ runUpgrade();
+ exit;
+}
+
+// 质量红线:生产环境二次运行保护。已安装后写入 installed.lock,再次访问直接拒绝,避免数据被清空。
+$lock = BASE_PATH . '/storage/installed.lock';
+if (is_file($lock) && !($cli && in_array('--force', $argv ?? [], true))) {
+ $out("⛔ 检测到 installed.lock,系统已安装。如需重置请删除该文件或执行:php install/install.php --force");
+ exit;
+}
+
+$seed = require BASE_PATH . '/install/seed.php';
+$driver = Core\Db::driver();
+
+if ($driver === 'file') {
+ $dir = Core\Db::fileDir();
+ foreach ($seed as $table => $rows) {
+ // 确保每条记录带 id(如 settings 种子未含 id),否则文件模式无法按主键更新
+ $i = 1;
+ foreach ($rows as &$r) { if (!isset($r['id'])) { $r['id'] = $i; } $i++; }
+ unset($r);
+ file_put_contents($dir . "/{$table}.json", json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+ $out("✓ 写入 {$table}.json (" . count($rows) . " 条)");
+ }
+} else {
+ // MySQL:建表 + 插入
+ $sql = file_get_contents(BASE_PATH . '/install/schema.sql');
+ $pdo = Core\Db::pdo();
+ $pdo->exec($sql);
+ $out("✓ 数据表已创建");
+ // 幂等升级:为已存在的 admin_users 补齐分系统角色列(已有则忽略)
+ foreach (['crm_role VARCHAR(20) DEFAULT \'none\'', 'psi_role VARCHAR(20) DEFAULT \'none\''] as $col) {
+ try { $pdo->exec("ALTER TABLE `admin_users` ADD COLUMN IF NOT EXISTS `{$col}`"); }
+ catch (\Throwable $e) { /* 5.7 不支持 IF NOT EXISTS 时忽略已存在错误 */ }
+ }
+ $out("✓ admin_users 分系统角色列已补齐");
+ $map = [
+ 'categories' => new App\Models\Category(),
+ 'products' => new App\Models\Product(),
+ 'news' => new App\Models\News(),
+ 'pages' => new App\Models\Page(),
+ 'banners' => new App\Models\Banner(),
+ 'cases' => new App\Models\CustomerCase(),
+ 'admin_users'=> new App\Models\AdminUser(),
+ 'settings' => new App\Models\Setting(),
+ 'crm_customers' => new App\Models\CRM\Customer(),
+ 'crm_leads' => new App\Models\CRM\Lead(),
+ 'crm_followups' => new App\Models\CRM\FollowUp(),
+ 'psi_suppliers' => new App\Models\PSI\Supplier(),
+ 'psi_materials' => new App\Models\PSI\Material(),
+ 'psi_products' => new App\Models\PSI\Product(),
+ 'psi_purchases' => new App\Models\PSI\Purchase(),
+ 'psi_sales' => new App\Models\PSI\Sales(),
+ 'psi_stock_moves'=> new App\Models\PSI\StockMove(),
+ 'orders' => new App\Models\Order(),
+ 'payments' => new App\Models\Payment(),
+ ];
+ foreach ($seed as $table => $rows) {
+ if (!isset($map[$table])) { $out("⊘ 跳过未映射表 {$table}(无对应 Model)"); continue; }
+ $m = $map[$table];
+ foreach ($rows as $r) { $m->insert($r); }
+ $out("✓ 插入 {$table} (" . count($rows) . " 条)");
+ }
+}
+
+// 生成主题 CSS
+Core\Theme::regenerate();
+$out("✓ 主题样式 theme.css 已生成");
+
+// 写安装锁(Web 模式自动写;CLI 不写,便于二次部署时手动控制)
+if (!$cli) {
+ @file_put_contents($lock, date('Y-m-d H:i:s') . " installed by " . ($_SERVER['REMOTE_ADDR'] ?? 'unknown') . "\n");
+ $out("✓ 已写入 installed.lock(再次运行 install 将被拒绝)");
+}
+
+$out("");
+$out($cli ? "安装完成 ✓" : "安装完成 ✓ 访问前台 | 进入后台
");
diff --git a/install/psi_orders.sql b/install/psi_orders.sql
new file mode 100644
index 0000000..cb37c4a
--- /dev/null
+++ b/install/psi_orders.sql
@@ -0,0 +1,108 @@
+-- =============================================================
+-- 酷冰甲 CMS · PSI 进销存:销售订单 / 采购订单 / 出库单 模块
+-- 在已有库上执行一次即可(已存在则忽略)。
+-- 说明:单据与明细分离,出库单通过 so_no / so_item_id 关联销售订单,
+-- 采购订单收货时写入 psi_purchases 并增加库存,出库单扣减库存。
+-- =============================================================
+
+CREATE TABLE IF NOT EXISTS `psi_sales_orders` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `order_no` VARCHAR(40) NOT NULL,
+ `customer` VARCHAR(120) NOT NULL DEFAULT '',
+ `salesman` VARCHAR(60) NOT NULL DEFAULT '',
+ `channel` ENUM('domestic','export') NOT NULL DEFAULT 'domestic',
+ `region` VARCHAR(60) NOT NULL DEFAULT '',
+ `status` ENUM('pending','partial','delivered','closed') NOT NULL DEFAULT 'pending',
+ `delivery_date` DATE NULL,
+ `remark` TEXT,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_so_no` (`order_no`),
+ KEY `idx_so_customer` (`customer`),
+ KEY `idx_so_salesman` (`salesman`),
+ KEY `idx_so_status` (`status`),
+ KEY `idx_so_created` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_sales_order_items` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `so_id` INT UNSIGNED NOT NULL,
+ `product_id` INT UNSIGNED NOT NULL DEFAULT 0,
+ `name` VARCHAR(160) NOT NULL DEFAULT '',
+ `spec` VARCHAR(120) NOT NULL DEFAULT '',
+ `unit` VARCHAR(20) NOT NULL DEFAULT '',
+ `qty` INT NOT NULL DEFAULT 0,
+ `price` DECIMAL(12,2) NOT NULL DEFAULT 0,
+ `amount` DECIMAL(12,2) NOT NULL DEFAULT 0,
+ `delivered_qty` INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ KEY `idx_so_item_so` (`so_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_purchase_orders` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `order_no` VARCHAR(40) NOT NULL,
+ `supplier_id` INT UNSIGNED NOT NULL DEFAULT 0,
+ `supplier_name` VARCHAR(120) NOT NULL DEFAULT '',
+ `salesman` VARCHAR(60) NOT NULL DEFAULT '',
+ `status` ENUM('pending','partial','received','closed') NOT NULL DEFAULT 'pending',
+ `expected_at` DATE NULL,
+ `remark` TEXT,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_po_no` (`order_no`),
+ KEY `idx_po_supplier` (`supplier_id`),
+ KEY `idx_po_salesman` (`salesman`),
+ KEY `idx_po_status` (`status`),
+ KEY `idx_po_created` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_purchase_order_items` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `po_id` INT UNSIGNED NOT NULL,
+ `item_type` ENUM('material','product') NOT NULL DEFAULT 'material',
+ `item_id` INT UNSIGNED NOT NULL DEFAULT 0,
+ `name` VARCHAR(160) NOT NULL DEFAULT '',
+ `spec` VARCHAR(120) NOT NULL DEFAULT '',
+ `unit` VARCHAR(20) NOT NULL DEFAULT '',
+ `qty` INT NOT NULL DEFAULT 0,
+ `price` DECIMAL(12,2) NOT NULL DEFAULT 0,
+ `amount` DECIMAL(12,2) NOT NULL DEFAULT 0,
+ `received_qty` INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ KEY `idx_po_item_po` (`po_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_outbounds` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `order_no` VARCHAR(40) NOT NULL,
+ `so_no` VARCHAR(40) NOT NULL DEFAULT '',
+ `customer` VARCHAR(120) NOT NULL DEFAULT '',
+ `salesman` VARCHAR(60) NOT NULL DEFAULT '',
+ `warehouse` VARCHAR(60) NOT NULL DEFAULT '',
+ `status` ENUM('delivered','partial') NOT NULL DEFAULT 'delivered',
+ `delivery_date` DATE NULL,
+ `remark` TEXT,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_ob_no` (`order_no`),
+ KEY `idx_ob_so` (`so_no`),
+ KEY `idx_ob_customer` (`customer`),
+ KEY `idx_ob_salesman` (`salesman`),
+ KEY `idx_ob_created` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_outbound_items` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `ob_id` INT UNSIGNED NOT NULL,
+ `so_item_id` INT UNSIGNED NOT NULL DEFAULT 0,
+ `product_id` INT UNSIGNED NOT NULL DEFAULT 0,
+ `name` VARCHAR(160) NOT NULL DEFAULT '',
+ `spec` VARCHAR(120) NOT NULL DEFAULT '',
+ `unit` VARCHAR(20) NOT NULL DEFAULT '',
+ `qty` INT NOT NULL DEFAULT 0,
+ `price` DECIMAL(12,2) NOT NULL DEFAULT 0,
+ `amount` DECIMAL(12,2) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ KEY `idx_ob_item_ob` (`ob_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/install/schema.sql b/install/schema.sql
new file mode 100644
index 0000000..214599e
--- /dev/null
+++ b/install/schema.sql
@@ -0,0 +1,308 @@
+-- 酷冰甲降温服官网 MySQL 结构
+SET NAMES utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `categories` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `name` VARCHAR(120) NOT NULL,
+ `slug` VARCHAR(120) DEFAULT '',
+ `icon` VARCHAR(20) DEFAULT '',
+ `description` TEXT,
+ `sort_order` INT DEFAULT 0,
+ `status` TINYINT DEFAULT 1,
+ `layout` TEXT,
+ `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `products` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `category_id` INT DEFAULT 0,
+ `name` VARCHAR(200) NOT NULL,
+ `slug` VARCHAR(200) DEFAULT '',
+ `cover` VARCHAR(255) DEFAULT '',
+ `summary` TEXT,
+ `description` TEXT,
+ `price` DECIMAL(10,2) DEFAULT 0,
+ `specs` TEXT,
+ `gallery` TEXT,
+ `tags` VARCHAR(255) DEFAULT '',
+ `sort_order` INT DEFAULT 0,
+ `status` TINYINT DEFAULT 1,
+ `created_at` VARCHAR(20) DEFAULT '',
+ `layout` TEXT,
+ `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `news` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `title` VARCHAR(200) NOT NULL,
+ `slug` VARCHAR(200) DEFAULT '',
+ `cover` VARCHAR(255) DEFAULT '',
+ `summary` TEXT,
+ `content` TEXT,
+ `author` VARCHAR(60) DEFAULT '',
+ `published_at` VARCHAR(20) DEFAULT '',
+ `status` TINYINT DEFAULT 1,
+ `views` INT DEFAULT 0,
+ `layout` TEXT,
+ `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `cases` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `title` VARCHAR(200) NOT NULL,
+ `slug` VARCHAR(200) DEFAULT '',
+ `customer` VARCHAR(120) DEFAULT '',
+ `industry` VARCHAR(60) DEFAULT '',
+ `cover` VARCHAR(255) DEFAULT '',
+ `summary` TEXT,
+ `content` TEXT,
+ `published_at` VARCHAR(20) DEFAULT '',
+ `sort_order` INT DEFAULT 0,
+ `status` TINYINT DEFAULT 1,
+ `views` INT DEFAULT 0,
+ `layout` TEXT,
+ `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `pages` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `slug` VARCHAR(120) DEFAULT '',
+ `title` VARCHAR(200) DEFAULT '',
+ `content` TEXT,
+ `layout` TEXT,
+ `mode` VARCHAR(16) NOT NULL DEFAULT 'fixed',
+ `updated_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `banners` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `title` VARCHAR(200) DEFAULT '',
+ `subtitle` VARCHAR(255) DEFAULT '',
+ `image` VARCHAR(255) DEFAULT '',
+ `link` VARCHAR(255) DEFAULT '',
+ `sort_order` INT DEFAULT 0,
+ `status` TINYINT DEFAULT 1
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `admin_users` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `username` VARCHAR(60) NOT NULL,
+ `password` VARCHAR(255) NOT NULL,
+ `name` VARCHAR(60) DEFAULT '',
+ `role` VARCHAR(20) DEFAULT 'user',
+ `crm_role` VARCHAR(20) DEFAULT 'none',
+ `psi_role` VARCHAR(20) DEFAULT 'none',
+ `crm_perms` TEXT,
+ `psi_perms` TEXT,
+ `status` TINYINT DEFAULT 1,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+/* ===================== CRM 客户管理系统 ===================== */
+CREATE TABLE IF NOT EXISTS `crm_customers` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `name` VARCHAR(120) NOT NULL,
+ `company` VARCHAR(160) DEFAULT '',
+ `contact` VARCHAR(60) DEFAULT '',
+ `phone` VARCHAR(40) DEFAULT '',
+ `email` VARCHAR(120) DEFAULT '',
+ `country` VARCHAR(60) DEFAULT '',
+ `type` VARCHAR(20) DEFAULT 'brand',
+ `source` VARCHAR(40) DEFAULT '',
+ `level` VARCHAR(20) DEFAULT 'C',
+ `customer_no` VARCHAR(40) DEFAULT '',
+ `industry` VARCHAR(20) DEFAULT '',
+ `region` VARCHAR(40) DEFAULT '',
+ `credit_limit` DECIMAL(12,2) DEFAULT 0,
+ `status` VARCHAR(20) DEFAULT 'lead',
+ `remark` TEXT,
+ `owner` VARCHAR(60) DEFAULT '',
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `crm_contacts` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `customer_id` INT NOT NULL,
+ `name` VARCHAR(80) NOT NULL,
+ `title` VARCHAR(80) DEFAULT '',
+ `phone` VARCHAR(60) DEFAULT '',
+ `email` VARCHAR(120) DEFAULT '',
+ `wechat` VARCHAR(60) DEFAULT '',
+ `is_primary` TINYINT DEFAULT 0,
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `crm_leads` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `customer_id` INT DEFAULT 0,
+ `title` VARCHAR(200) NOT NULL,
+ `amount` DECIMAL(12,2) DEFAULT 0,
+ `stage` VARCHAR(20) DEFAULT 'new',
+ `expected_close` VARCHAR(20) DEFAULT '',
+ `source` VARCHAR(30) DEFAULT '',
+ `probability` TINYINT DEFAULT 0,
+ `owner` VARCHAR(60) DEFAULT '',
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `crm_followups` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `customer_id` INT DEFAULT 0,
+ `lead_id` INT DEFAULT 0,
+ `content` TEXT,
+ `next_at` VARCHAR(20) DEFAULT '',
+ `way` VARCHAR(20) DEFAULT '',
+ `result` VARCHAR(60) DEFAULT '',
+ `owner` VARCHAR(60) DEFAULT '',
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+/* ===================== 进销存 PSI 系统 ===================== */
+CREATE TABLE IF NOT EXISTS `psi_materials` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `code` VARCHAR(40) DEFAULT '',
+ `name` VARCHAR(160) NOT NULL,
+ `spec` VARCHAR(120) DEFAULT '',
+ `unit` VARCHAR(10) DEFAULT '个',
+ `category` VARCHAR(40) DEFAULT '',
+ `composition` VARCHAR(60) DEFAULT '',
+ `weight_gsm` DECIMAL(8,2) DEFAULT 0,
+ `width_cm` DECIMAL(8,2) DEFAULT 0,
+ `color` VARCHAR(40) DEFAULT '',
+ `batch_no` VARCHAR(40) DEFAULT '',
+ `stock` DECIMAL(12,2) DEFAULT 0,
+ `price` DECIMAL(10,2) DEFAULT 0,
+ `supplier_id` INT DEFAULT 0,
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_products` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `code` VARCHAR(40) DEFAULT '',
+ `name` VARCHAR(160) NOT NULL,
+ `spec` VARCHAR(120) DEFAULT '',
+ `unit` VARCHAR(10) DEFAULT '件',
+ `category` VARCHAR(40) DEFAULT '',
+ `style_no` VARCHAR(40) DEFAULT '',
+ `color` VARCHAR(40) DEFAULT '',
+ `size_run` VARCHAR(60) DEFAULT '',
+ `season` VARCHAR(20) DEFAULT '',
+ `year` VARCHAR(10) DEFAULT '',
+ `stock` DECIMAL(12,2) DEFAULT 0,
+ `cost` DECIMAL(10,2) DEFAULT 0,
+ `price` DECIMAL(10,2) DEFAULT 0,
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_suppliers` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `name` VARCHAR(160) NOT NULL,
+ `contact` VARCHAR(60) DEFAULT '',
+ `phone` VARCHAR(40) DEFAULT '',
+ `country` VARCHAR(60) DEFAULT '',
+ `type` VARCHAR(20) DEFAULT '',
+ `grade` VARCHAR(20) DEFAULT '',
+ `ontime_rate` DECIMAL(5,2) DEFAULT 0,
+ `qc_rate` DECIMAL(5,2) DEFAULT 0,
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_purchases` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `order_no` VARCHAR(40) NOT NULL,
+ `supplier_id` INT DEFAULT 0,
+ `item_type` VARCHAR(10) DEFAULT 'material',
+ `item_id` INT DEFAULT 0,
+ `qty` DECIMAL(12,2) DEFAULT 0,
+ `price` DECIMAL(10,2) DEFAULT 0,
+ `amount` DECIMAL(12,2) DEFAULT 0,
+ `status` VARCHAR(20) DEFAULT 'pending',
+ `batch_no` VARCHAR(40) DEFAULT '',
+ `expected_at` VARCHAR(20) DEFAULT '',
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_sales` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `order_no` VARCHAR(40) NOT NULL,
+ `customer` VARCHAR(160) DEFAULT '',
+ `channel` VARCHAR(20) DEFAULT 'domestic',
+ `item_id` INT DEFAULT 0,
+ `qty` DECIMAL(12,2) DEFAULT 0,
+ `price` DECIMAL(10,2) DEFAULT 0,
+ `amount` DECIMAL(12,2) DEFAULT 0,
+ `status` VARCHAR(20) DEFAULT 'pending',
+ `region` VARCHAR(40) DEFAULT '',
+ `batch_no` VARCHAR(40) DEFAULT '',
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_stock_moves` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `item_type` VARCHAR(10) DEFAULT 'product',
+ `item_id` INT DEFAULT 0,
+ `direction` VARCHAR(10) DEFAULT 'in',
+ `qty` DECIMAL(12,2) DEFAULT 0,
+ `ref_no` VARCHAR(40) DEFAULT '',
+ `batch_no` VARCHAR(40) DEFAULT '',
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `settings` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `skey` VARCHAR(120) NOT NULL,
+ `sval` TEXT,
+ `sgroup` VARCHAR(40) DEFAULT 'site'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `orders` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `order_no` VARCHAR(40) NOT NULL,
+ `product_id` INT DEFAULT 0,
+ `product_name` VARCHAR(200) DEFAULT '',
+ `customer_name` VARCHAR(120) DEFAULT '',
+ `phone` VARCHAR(40) DEFAULT '',
+ `email` VARCHAR(120) DEFAULT '',
+ `qty` INT DEFAULT 1,
+ `amount` DECIMAL(10,2) DEFAULT 0,
+ `channel` VARCHAR(20) DEFAULT '',
+ `status` VARCHAR(20) DEFAULT 'pending',
+ `gateway_trade_no` VARCHAR(120) DEFAULT '',
+ `paid_at` VARCHAR(20) DEFAULT '',
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `payments` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `order_id` INT DEFAULT 0,
+ `order_no` VARCHAR(40) NOT NULL,
+ `channel` VARCHAR(20) DEFAULT '',
+ `amount` DECIMAL(10,2) DEFAULT 0,
+ `trade_no` VARCHAR(120) DEFAULT '',
+ `status` VARCHAR(20) DEFAULT '',
+ `created_at` VARCHAR(20) DEFAULT '',
+ `paid_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_events` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ `sys` VARCHAR(20) NOT NULL DEFAULT 'psi',
+ `type` VARCHAR(40) NOT NULL DEFAULT '',
+ `level` VARCHAR(20) NOT NULL DEFAULT 'urgent',
+ `title` VARCHAR(255) NOT NULL DEFAULT '',
+ `body` TEXT,
+ `url` VARCHAR(255) NOT NULL DEFAULT '',
+ `ref_no` VARCHAR(64) NOT NULL DEFAULT '',
+ `recipients` TEXT,
+ `channels` VARCHAR(255) NOT NULL DEFAULT '["inapp"]',
+ `read_by` TEXT,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/install/seed.php b/install/seed.php
new file mode 100644
index 0000000..5390b6b
--- /dev/null
+++ b/install/seed.php
@@ -0,0 +1,157 @@
+ [
+ ['id'=>1,'name'=>'水冷循环降温服','slug'=>'water-cooling','icon'=>'💧','description'=>'内置微型水泵与循环水管,持续带走体表热量,适合长时间高温作业。','sort_order'=>1,'status'=>1],
+ ['id'=>2,'name'=>'相变蓄冷凝胶服','slug'=>'pcm-gel','icon'=>'🧊','description'=>'相变材料(PCM)蓄冷凝胶贴片,无需电源,即穿即凉,轻便无束缚。','sort_order'=>2,'status'=>1],
+ ['id'=>3,'name'=>'涡扇风冷降温服','slug'=>'air-cooling','icon'=>'🌀','description'=>'双涡扇主动送风,加速汗液蒸发,体感直降 8-12℃。','sort_order'=>3,'status'=>1],
+ ['id'=>4,'name'=>'冰袋降温马甲','slug'=>'ice-vest','icon'=>'❄️','description'=>'可替换冰袋/冰晶盒马甲,低成本高效降温,户外与车间通用。','sort_order'=>4,'status'=>1],
+ ['id'=>5,'name'=>'工业降温工作服','slug'=>'industrial','icon'=>'🏭','description'=>'面向钢铁、消防、化工等高温行业的模块化降温工装。','sort_order'=>5,'status'=>1],
+ ['id'=>6,'name'=>'户外清凉服饰','slug'=>'outdoor','icon'=>'🌿','description'=>'骑行、马拉松、巡检等户外场景的轻量降温装备。','sort_order'=>6,'status'=>1],
+ ],
+ 'products' => [
+ ['id'=>1,'category_id'=>1,'name'=>'SQY- Pro 水冷降温套装','slug'=>'sqy-pro-water','cover'=>'','summary'=>'医用级硅胶水管 + 5000mAh 续航,4 档调速,体感直降 10℃。','description'=>"SQY-Pro 水冷降温套装采用分布式毛细水管贴合躯干大血管区域,微型静音水泵驱动冷却液循环,将体表热量高效导出。\n\n· 4 档风速/水速调节,单次续航 6-8 小时\n· 食品级 TPU 水管,亲肤无异味\n· 外套可拆洗,内胆防水防漏\n· 适用:钢铁、焊接、消防、厨房高温区",'price'=>'1299.00','specs'=>'[{"k":"降温方式","v":"水冷循环"},{"k":"续航","v":"6-8 小时"},{"k":"净重","v":"约 1.2kg"},{"k":"档位","v":"4 档可调"},{"k":"适配温度","v":"≤ 45℃ 环境"}]','gallery'=>'[]','tags'=>'水冷,Pro,长续航','sort_order'=>1,'status'=>1,'created_at'=>'2026-03-12'],
+ ['id'=>2,'category_id'=>2,'name'=>'冰晶凝胶蓄冷降温背心','slug'=>'gel-vest','cover'=>'','summary'=>'30 秒速冻,单片蓄冷 2 小时,免电源即穿即凉。','description'=>"采用高分子相变蓄冷凝胶贴片,放入冰箱/冰柜 30 分钟即可凝固,穿着后缓慢相变吸热,无需电池。\n\n· 前后 8 片凝胶,覆盖核心降温区\n· 单片蓄冷时长约 2 小时,可快速更换\n· 马甲式设计,不影响作业动作\n· 适用:户外巡检、物流分拣、赛事保障",'price'=>'399.00','specs'=>'[{"k":"降温方式","v":"相变蓄冷"},{"k":"蓄冷时长","v":"约 2 小时/片"},{"k":"凝胶片","v":"前后 8 片"},{"k":"电源","v":"无需电源"},{"k":"清洗","v":"马甲可水洗"}]','gallery'=>'[]','tags'=>'凝胶,免电源,轻便','sort_order'=>2,'status'=>1,'created_at'=>'2026-03-15'],
+ ['id'=>3,'category_id'=>3,'name'=>'双涡扇风冷降温马甲','slug'=>'fan-vest','cover'=>'','summary'=>'前后双涡扇主动送风,体感直降 8-12℃,USB 供电。','description'=>"前后布置高能效涡扇,将外部空气过滤后送入服装夹层,加速汗液蒸发与对流换热。\n\n· 双涡扇独立开关,3 档风力\n· USB/充电宝供电,移动便捷\n· 网眼透气里料,久穿不闷\n· 适用:建筑、环卫、骑行、军训",'price'=>'299.00','specs'=>'[{"k":"降温方式","v":"涡扇风冷"},{"k":"风力","v":"3 档可调"},{"k":"供电","v":"USB / 充电宝"},{"k":"体感降温","v":"8-12℃"},{"k":"净重","v":"约 0.6kg"}]','gallery'=>'[]','tags'=>'风冷,USB,轻便','sort_order'=>3,'status'=>1,'created_at'=>'2026-03-18'],
+ ['id'=>4,'category_id'=>4,'name'=>'可换冰袋降温马甲','slug'=>'ice-bag-vest','cover'=>'','summary'=>'6 个独立冰袋仓,低成本高效降温,户外车间通用。','description'=>"经典冰袋式降温马甲,6 个独立密封冰袋仓,放入冰晶盒即可使用,性价比之选。\n\n· 前后 6 仓均匀分布,降温均衡\n· 冰袋可反复冻用,损耗低\n· 反光条设计,夜间作业更安全\n· 适用:仓储、农业、临时户外作业",'price'=>'159.00','specs'=>'[{"k":"降温方式","v":"冰袋蓄冷"},{"k":"冰袋仓","v":"6 个独立仓"},{"k":"电源","v":"无需电源"},{"k":"安全","v":"反光条"},{"k":"价格","v":"高性价比"}]','gallery'=>'[]','tags'=>'冰袋,性价比,通用','sort_order'=>4,'status'=>1,'created_at'=>'2026-03-20'],
+ ['id'=>5,'category_id'=>5,'name'=>'工业模块化降温工装','slug'=>'industrial-suit','cover'=>'','summary'=>'为钢铁/消防/化工定制的模块化降温防护服,可集成多种降温芯。','description'=>"面向高温高危行业的模块化降温工装,外层阻燃耐磨,内层可插拔水冷/凝胶/冰袋降温芯,按岗位灵活组合。\n\n· 阻燃防静电外层,符合工装标准\n· 降温芯可插拔替换,一衣多用\n· 多处工具挂点,贴合作业习惯\n· 适用:钢铁、消防、化工、冶炼",'price'=>'1899.00','specs'=>'[{"k":"降温方式","v":"模块化可换芯"},{"k":"外层","v":"阻燃防静电"},{"k":"适配芯","v":"水冷/凝胶/冰袋"},{"k":"定制","v":"支持企业 LOGO 绣字"},{"k":"标准","v":"符合工装规范"}]','gallery'=>'[]','tags'=>'工业,阻燃,定制','sort_order'=>5,'status'=>1,'created_at'=>'2026-03-22'],
+ ['id'=>6,'category_id'=>6,'name'=>'户外轻量降温皮肤衣','slug'=>'skin-coat','cover'=>'','summary'=>'UPF50+ 防晒 + 背部风冷,骑行马拉松巡检必备。','description'=>"超轻防晒皮肤衣集成背部微型风冷模块,UPF50+ 防晒同时主动降温,折叠后仅手掌大小。\n\n· UPF50+ 防晒,透气速干\n· 背部风冷模块,体感更清爽\n· 整衣可收纳,便携出行\n· 适用:骑行、马拉松、户外巡检",'price'=>'259.00','specs'=>'[{"k":"防晒","v":"UPF50+"},{"k":"降温方式","v":"背部风冷"},{"k":"收纳","v":"手掌大小"},{"k":"重量","v":"约 180g"},{"k":"场景","v":"户外轻运动"}]','gallery'=>'[]','tags'=>'户外,防晒,轻量','sort_order'=>6,'status'=>1,'created_at'=>'2026-03-25'],
+ ['id'=>7,'category_id'=>1,'name'=>'SQY-Lite 水冷降温内胆','slug'=>'sqy-lite','cover'=>'','summary'=>'可嵌入现有工装的水冷内胆,升级不加衣。','description'=>"独立水冷内胆,可穿在任意工装内侧,让既有服装秒变降温服,降低整体采购成本。\n\n· 弹性贴合,适配多数尺码工装\n· 微型水泵静音,续航 5 小时\n· 内胆防水,外衣照常清洗\n· 适用:已有工装升级、分批改造",'price'=>'899.00','specs'=>'[{"k":"类型","v":"水冷内胆"},{"k":"续航","v":"约 5 小时"},{"k":"适配","v":"多数工装"},{"k":"噪声","v":"< 30dB"},{"k":"升级","v":"不加衣即可降温"}]','gallery'=>'[]','tags'=>'内胆,升级,静音','sort_order'=>7,'status'=>1,'created_at'=>'2026-03-28'],
+ ['id'=>8,'category_id'=>3,'name'=>'头颈一体风冷降温帽','slug'=>'cool-cap','cover'=>'','summary'=>'头部+颈部环绕送风,快速缓解中暑前兆。','description'=>"针对头部高温聚集设计的头颈一体风冷帽,环绕送风快速带走头颈热量,预防中暑。\n\n· 头颈双区送风,降温更均衡\n· 可拆洗内衬,卫生耐用\n· Type-C 充电,续航 4 小时\n· 适用:交警、巡检、户外指挥",'price'=>'199.00','specs'=>'[{"k":"降温方式","v":"头颈风冷"},{"k":"续航","v":"约 4 小时"},{"k":"充电","v":"Type-C"},{"k":"清洗","v":"内衬可拆洗"},{"k":"场景","v":"高温户外值守"}]','gallery'=>'[]','tags'=>'头颈,防暑,便携','sort_order'=>8,'status'=>1,'created_at'=>'2026-04-01'],
+ ],
+ 'banners' => [
+ ['id'=>1,'title'=>'科技降温 · 清凉一夏','subtitle'=>'相变蓄冷 / 水冷循环 / 涡扇风冷,为高温作业人群定制','image'=>'linear-gradient(135deg,#0ea5e9,#14b8a6)','link'=>'/products','sort_order'=>1,'status'=>1],
+ ['id'=>2,'title'=>'20 年服装定制经验','subtitle'=>'外贸级品质工厂,支持企业 LOGO 绣字与一人一码量体','image'=>'linear-gradient(135deg,#0284c7,#0ea5e9)','link'=>'/page/about','sort_order'=>2,'status'=>1],
+ ['id'=>3,'title'=>'免费拿样 · 5 天出方案','subtitle'=>'提交需求,专属顾问 1 对 1 为您设计降温解决方案','image'=>'linear-gradient(135deg,#14b8a6,#f59e0b)','link'=>'/contact','sort_order'=>3,'status'=>1],
+ ],
+ 'news' => [
+ ['id'=>1,'title'=>'酷冰甲发布新一代水冷降温服 SQY-Pro,体感直降 10℃','slug'=>'sqy-pro-launch','cover'=>'','summary'=>'采用分布式毛细水管与静音水泵,单次续航提升至 8 小时。','content'=>"近日,酷冰甲正式发布新一代水冷降温服 SQY-Pro。该产品采用分布式毛细水管贴合躯干大血管区域,配合微型静音水泵驱动冷却液循环,将体表热量高效导出,体感温度可直降 10℃。\n\n研发团队表示,SQY-Pro 在续航、重量与舒适度上均有显著突破,单次充电可连续工作 8 小时,整机重量控制在 1.2kg 以内,更适合长时间高温作业场景。",'author'=>'酷冰甲','published_at'=>'2026-04-10','status'=>1,'views'=>128],
+ ['id'=>2,'title'=>'夏季高温作业防护指南:如何科学选择降温服','slug'=>'summer-guide','cover'=>'','summary'=>'从降温原理到场景适配,一文读懂水冷、凝胶、风冷怎么选。','content'=>"随着极端高温天气增多,户外与车间作业人员的防暑降温成为关注焦点。本文从降温原理出发,对比水冷循环、相变蓄冷、涡扇风冷三类方案的适用场景,帮助企业根据自身岗位特点科学选型。\n\n· 长时间恒温作业:优先水冷循环\n· 临时/移动作业:凝胶或冰袋更灵活\n· 户外轻运动:风冷皮肤衣更轻便",'author'=>'酷冰甲','published_at'=>'2026-04-05','status'=>1,'views'=>96],
+ ['id'=>3,'title'=>'酷冰甲为某钢铁集团交付模块化降温工装','slug'=>'steel-case','cover'=>'','summary'=>'支持阻燃外层 + 可换降温芯,获一线员工好评。','content'=>"日前,酷冰甲完成对某大型钢铁集团的模块化降温工装交付。该批工装采用阻燃防静电外层,内层可插拔水冷/凝胶降温芯,按岗位灵活组合,获得一线员工与安全管理部分的一致好评。",'author'=>'酷冰甲','published_at'=>'2026-03-30','status'=>1,'views'=>74],
+ ['id'=>4,'title'=>'相变蓄冷技术原理科普:为什么凝胶背心能"自动降温"','slug'=>'pcm-science','cover'=>'','summary'=>'揭秘相变材料(PCM)如何在不耗电的情况下持续吸热。','content'=>"相变材料(PCM)在一定温度区间内会发生固-液相变并吸收大量潜热。凝胶背心正是利用这一特性,在凝固状态下穿着时缓慢相变吸热,从而在不耗电的情况下实现持续降温。本文带你读懂背后的科学。",'author'=>'酷冰甲','published_at'=>'2026-03-22','status'=>1,'views'=>61],
+ ],
+ 'cases' => [
+ ['id'=>1,'title'=>'某大型钢铁集团模块化降温工装交付','slug'=>'steel-group-cases','customer'=>'某大型钢铁集团','industry'=>'钢铁冶炼','cover'=>'','summary'=>'阻燃外层 + 可换降温芯,覆盖高炉、轧线等高温岗位,获一线员工好评。','content'=>"日前,酷冰甲完成对某大型钢铁集团的模块化降温工装批量交付。该批工装采用阻燃防静电外层,内层可插拔水冷 / 凝胶 / 冰袋降温芯,按岗位灵活组合,覆盖高炉、轧线、铸造等核心高温岗位。\n\n交付后,一线员工体感温度明显下降,安全管理部对降温效果与穿着舒适度给予一致好评,并表示将在更多分厂推广使用。",'published_at'=>'2026-03-30','sort_order'=>1,'status'=>1,'views'=>0],
+ ['id'=>2,'title'=>'某消防救援支队头颈一体风冷降温装备','slug'=>'fire-rescue-cases','customer'=>'某地消防救援支队','industry'=>'消防救援','cover'=>'','summary'=>'头颈双区环绕送风,快速缓解中暑前兆,保障高温救援。','content'=>"针对夏季高温救援与训练场景,酷冰甲为某消防救援支队定制头颈一体风冷降温装备。该装备头颈双区环绕送风,Type-C 快充、续航 4 小时,可拆洗内衬卫生耐用,显著缓解指战员中暑前兆,保障长时间高温救援作业安全。",'published_at'=>'2026-04-02','sort_order'=>2,'status'=>1,'views'=>0],
+ ['id'=>3,'title'=>'某化工企业相变蓄冷凝胶背心','slug'=>'chemical-gel-cases','customer'=>'某化工生产企业','industry'=>'化工生产','cover'=>'','summary'=>'免电源即穿即凉,30 秒速冻、单片蓄冷 2 小时,巡检更安心。','content'=>"某化工生产企业为巡检与检修岗位引入酷冰甲相变蓄冷凝胶背心。产品免电源、30 秒速冻、单片蓄冷约 2 小时,可快速更换,马甲式设计不影响作业动作,让一线员工在罐区、管线等高温区域巡检更安心。",'published_at'=>'2026-04-06','sort_order'=>3,'status'=>1,'views'=>0],
+ ['id'=>4,'title'=>'某环卫集团涡扇风冷马甲批量定制','slug'=>'sanitation-fan-cases','customer'=>'某城市环卫集团','industry'=>'环卫保洁','cover'=>'','summary'=>'USB 供电、3 档风力,覆盖道路保洁与垃圾转运岗位。','content'=>"某城市环卫集团批量定制酷冰甲双涡扇风冷降温马甲,USB / 充电宝即可供电,3 档风力可调,覆盖道路保洁、垃圾转运等户外岗位。轻量透气、反光线条提升夜间作业安全,获得环卫工人的普遍认可。",'published_at'=>'2026-04-12','sort_order'=>4,'status'=>1,'views'=>0],
+ ],
+ 'pages' => [
+ ['id'=>1,'slug'=>'about','title'=>'关于酷冰甲','content'=>"苏州酷冰甲服饰有限公司拥有 20 年服装行业经验,从事职业装内销与外贸,专注高温作业人群的降温服装研发与定制。
公司具备完整、科学的质量管理体系,生产工厂具有多年外贸代工经验,引进德国进口设备,按欧美外贸级别标准出货。我们为钢铁、消防、化工、环卫、物流、户外等行业提供整体降温服装解决方案。
我们坚持「柔性化生产、一人一码、量身定制」,10 套起订,交付后提供包换包修与免费保养指导,让客户的权益得到保障。
",'layout'=>'','updated_at'=>'2026-04-01'],
+ ['id'=>2,'slug'=>'service','title'=>'服务与优势','content'=>"柔性化生产:小单亦可定制,10 套起订,灵活补单。
量身打造:设计师结合企业文化与功能需求定向设计,5 天出具方案。
一人一码:资深打版师打板、上门量体,高度还原设计稿,合身合体。
外贸级品质:156 道工序层层把控,欧美出口级标准。
售后无忧:交付 3 个月内包换包修改,2 年内免费返修,免费教授日常保养。
",'layout'=>'','updated_at'=>'2026-04-01'],
+ ['id'=>3,'slug'=>'cases','title'=>'客户案例','content'=>"众多企事业单位选择了酷冰甲降温服装定制服务,覆盖钢铁冶炼、消防救援、化工生产、环卫保洁、物流仓储、户外赛事等场景。
我们提供从需求沟通、上门量体、方案设计、批量生产到成衣交付的全流程服务,并支持企业 LOGO 绣字与个性化定制。
",'layout'=>'','updated_at'=>'2026-04-01'],
+ ['id'=>4,'slug'=>'honor','title'=>'荣誉资质','content'=>"酷冰甲先后获得行业多项荣誉与资质认证,包括质量管理体系认证、外贸级生产资质及多家机构颁发的合作荣誉。
我们持续以品质与客户利益为先,售后及时响应客户反馈,10 秒接通,为客户解决问题。
",'layout'=>'','updated_at'=>'2026-04-01'],
+ ],
+ 'admin_users' => [
+ ['id'=>1,'username'=>'admin','password'=>password_hash('admin888', PASSWORD_DEFAULT),'name'=>'超级管理员','role'=>'super_admin','crm_role'=>'admin','psi_role'=>'admin','status'=>1,'created_at'=>'2026-01-01'],
+ ['id'=>2,'username'=>'editor','password'=>password_hash('editor888', PASSWORD_DEFAULT),'name'=>'内容管理员','role'=>'admin','status'=>1,'created_at'=>'2026-01-01'],
+ ['id'=>3,'username'=>'writer','password'=>password_hash('writer888', PASSWORD_DEFAULT),'name'=>'编辑员','role'=>'user','status'=>1,'created_at'=>'2026-01-01'],
+ ],
+ 'settings' => [
+ ['skey'=>'site_name','sval'=>'酷冰甲 · 降温服','sgroup'=>'site'],
+ ['skey'=>'site_slogan','sval'=>'科技降温 · 清凉一夏','sgroup'=>'site'],
+ ['skey'=>'contact_phone','sval'=>'400-1783-998','sgroup'=>'site'],
+ ['skey'=>'contact_email','sval'=>'service@st-joyapparel.com','sgroup'=>'site'],
+ ['skey'=>'contact_address','sval'=>'江苏省苏州市工业园区','sgroup'=>'site'],
+ ['skey'=>'icp','sval'=>'苏ICP备10206899号','sgroup'=>'site'],
+ ['skey'=>'seo_title','sval'=>'酷冰甲降温服 - 科技降温服装定制','sgroup'=>'site'],
+ ['skey'=>'seo_keywords','sval'=>'降温服,降温工作服,水冷降温服,相变蓄冷凝胶服,清凉服定制','sgroup'=>'site'],
+ ['skey'=>'seo_description','sval'=>'酷冰甲专注降温服研发与定制,采用相变蓄冷与循环水冷技术,为高温作业人群提供清凉解决方案。','sgroup'=>'site'],
+ ['skey'=>'preset','sval'=>'ocean','sgroup'=>'theme'],
+ ['skey'=>'primary','sval'=>'#0ea5e9','sgroup'=>'theme'],
+ ['skey'=>'primary_600','sval'=>'#0284c7','sgroup'=>'theme'],
+ ['skey'=>'secondary','sval'=>'#14b8a6','sgroup'=>'theme'],
+ ['skey'=>'accent','sval'=>'#f59e0b','sgroup'=>'theme'],
+ ['skey'=>'bg','sval'=>'#ffffff','sgroup'=>'theme'],
+ ['skey'=>'surface','sval'=>'#f8fafc','sgroup'=>'theme'],
+ ['skey'=>'text','sval'=>'#0f172a','sgroup'=>'theme'],
+ ['skey'=>'muted','sval'=>'#64748b','sgroup'=>'theme'],
+ ['skey'=>'border','sval'=>'#e2e8f0','sgroup'=>'theme'],
+ ['skey'=>'nav_bg','sval'=>'rgba(255,255,255,0.72)','sgroup'=>'theme'],
+ ['skey'=>'font','sval'=>"'Noto Sans SC', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",'sgroup'=>'theme'],
+ ['skey'=>'radius','sval'=>'16','sgroup'=>'theme'],
+ ['skey'=>'container','sval'=>'1200','sgroup'=>'theme'],
+ ['skey'=>'header','sval'=>'center','sgroup'=>'theme'],
+ ['skey'=>'default_mode','sval'=>'light','sgroup'=>'theme'],
+ ['skey'=>'custom_css','sval'=>'','sgroup'=>'theme'],
+ ['skey'=>'pay_enabled','sval'=>'1','sgroup'=>'pay'],
+ ['skey'=>'pay_mode','sval'=>'demo','sgroup'=>'pay'],
+ ['skey'=>'pay_alipay_appid','sval'=>'','sgroup'=>'pay'],
+ ['skey'=>'pay_alipay_private_key','sval'=>'','sgroup'=>'pay'],
+ ['skey'=>'pay_alipay_public_key','sval'=>'','sgroup'=>'pay'],
+ ['skey'=>'pay_alipay_gateway','sval'=>'https://openapi.alipay.com/gateway.do','sgroup'=>'pay'],
+ ['skey'=>'pay_wechat_mchid','sval'=>'','sgroup'=>'pay'],
+ ['skey'=>'pay_wechat_appid','sval'=>'','sgroup'=>'pay'],
+ ['skey'=>'pay_wechat_key','sval'=>'','sgroup'=>'pay'],
+
+ // 事件通知(邮件 + 企业微信)
+ ['skey'=>'notify_enabled','sval'=>'0','sgroup'=>'notify'],
+ ['skey'=>'notify_email_enabled','sval'=>'0','sgroup'=>'notify'],
+ ['skey'=>'notify_email_smtp_host','sval'=>'','sgroup'=>'notify'],
+ ['skey'=>'notify_email_smtp_port','sval'=>'465','sgroup'=>'notify'],
+ ['skey'=>'notify_email_smtp_user','sval'=>'','sgroup'=>'notify'],
+ ['skey'=>'notify_email_smtp_pass','sval'=>'','sgroup'=>'notify'],
+ ['skey'=>'notify_email_from','sval'=>'','sgroup'=>'notify'],
+ ['skey'=>'notify_email_to','sval'=>'','sgroup'=>'notify'],
+ ['skey'=>'notify_wechat_enabled','sval'=>'0','sgroup'=>'notify'],
+ ['skey'=>'notify_wechat_webhook','sval'=>'','sgroup'=>'notify'],
+ ['skey'=>'notify_wechat_mention','sval'=>'','sgroup'=>'notify'],
+ ['skey'=>'notify_lowstock_enabled','sval'=>'1','sgroup'=>'notify'],
+ ['skey'=>'notify_lowstock_threshold','sval'=>'20','sgroup'=>'notify'],
+ ],
+ 'orders' => [],
+ 'payments' => [],
+
+ /* ===================== CRM 客户管理(示例数据)===================== */
+ 'crm_customers' => [
+ ['id'=>1,'name'=>'张明','company'=>'苏州恒利纺织有限公司','contact'=>'张经理','phone'=>'13800001111','email'=>'zhang@hengli.com','country'=>'中国','type'=>'trade','source'=>'转介绍','level'=>'A','remark'=>'年采购面料约 50 万米,信用良好','owner'=>'admin','created_at'=>'2026-03-10'],
+ ['id'=>2,'name'=>'John Smith','company'=>'Global Apparel Importers Ltd','contact'=>'John','phone'=>'+1-415-555-0199','email'=>'john@globalapparel.com','country'=>'美国','type'=>'export','source'=>'展会','level'=>'A','remark'=>'北美户外品牌采购商,首单预计 3000 件','owner'=>'admin','created_at'=>'2026-03-15'],
+ ['id'=>3,'name'=>'李婷','company'=>'杭州清凉服饰连锁','contact'=>'李店长','phone'=>'13900002222','email'=>'','country'=>'中国','type'=>'retail','source'=>'官网','level'=>'B','remark'=>'长三角 12 家门店,夏季终端零售','owner'=>'admin','created_at'=>'2026-04-02'],
+ ],
+ 'crm_leads' => [
+ ['id'=>1,'customer_id'=>2,'title'=>'SQY-Pro 水冷套装出口订单','amount'=>389700.00,'stage'=>'negotiation','expected_close'=>'2026-08-30','owner'=>'admin','remark'=>'客户要求 CE 认证,已提供','created_at'=>'2026-05-10'],
+ ['id'=>2,'customer_id'=>1,'title'=>'冰丝面料年度框架','amount'=>900000.00,'stage'=>'proposal','expected_close'=>'2026-09-15','owner'=>'admin','remark'=>'需寄样确认色牢度','created_at'=>'2026-05-20'],
+ ['id'=>3,'customer_id'=>3,'title'=>'门店夏季补货','amount'=>60000.00,'stage'=>'new','expected_close'=>'2026-06-30','owner'=>'admin','remark'=>'','created_at'=>'2026-05-25'],
+ ],
+ 'crm_followups' => [
+ ['id'=>1,'customer_id'=>2,'lead_id'=>1,'content'=>'邮件确认 CE 证书已收到,进入价格谈判','next_at'=>'2026-07-30','owner'=>'admin','created_at'=>'2026-07-01'],
+ ['id'=>2,'customer_id'=>1,'lead_id'=>2,'content'=>'寄出冰丝面料样品 3 色,跟进回传检测报告','next_at'=>'2026-07-28','owner'=>'admin','created_at'=>'2026-07-05'],
+ ],
+
+ /* ===================== 进销存 PSI(示例数据)===================== */
+ 'psi_suppliers' => [
+ ['id'=>1,'name'=>'苏州恒利纺织有限公司','contact'=>'张经理','phone'=>'13800001111','country'=>'中国','remark'=>'主营冰丝、相变面料,交期稳定','created_at'=>'2026-03-10'],
+ ['id'=>2,'name'=>'Shenzhen CoolTech Electronics','contact'=>'Tony','phone'=>'+86-755-8888-0011','country'=>'中国','remark'=>'双涡扇模组、PCBA 供应商','created_at'=>'2026-03-12'],
+ ],
+ 'psi_materials' => [
+ ['id'=>1,'code'=>'M001','name'=>'冰丝降温面料','spec'=>'75D 平纹','unit'=>'米','category'=>'面料','stock'=>500.00,'price'=>18.00,'supplier_id'=>1,'remark'=>'主面料','created_at'=>'2026-03-10'],
+ ['id'=>2,'code'=>'M002','name'=>'PCM 蓄冷凝胶片','spec'=>'5mm 网格','unit'=>'片','category'=>'辅料','stock'=>300.00,'price'=>6.50,'supplier_id'=>0,'remark'=>'相变材料','created_at'=>'2026-03-12'],
+ ['id'=>3,'code'=>'M003','name'=>'双涡扇模组','spec'=>'DC5V 0.25A','unit'=>'个','category'=>'电子件','stock'=>200.00,'price'=>45.00,'supplier_id'=>2,'remark'=>'风冷核心','created_at'=>'2026-03-12'],
+ ],
+ 'psi_products' => [
+ ['id'=>1,'code'=>'P001','name'=>'SQY-Pro 水冷降温套装','spec'=>'4 档调速','unit'=>'件','category'=>'水冷','stock'=>70.00,'cost'=>720.00,'price'=>1299.00,'remark'=>'旗舰款','created_at'=>'2026-03-15'],
+ ['id'=>2,'code'=>'P002','name'=>'冰晶凝胶蓄冷背心','spec'=>'8 片凝胶','unit'=>'件','category'=>'相变','stock'=>200.00,'cost'=>180.00,'price'=>399.00,'remark'=>'免电源','created_at'=>'2026-03-18'],
+ ['id'=>3,'code'=>'P003','name'=>'双涡扇风冷马甲','spec'=>'双涡扇 3 档','unit'=>'件','category'=>'风冷','stock'=>150.00,'cost'=>140.00,'price'=>299.00,'remark'=>'USB 供电','created_at'=>'2026-03-20'],
+ ],
+ 'psi_purchases' => [
+ ['id'=>1,'order_no'=>'PO2026-001','supplier_id'=>1,'item_type'=>'material','item_id'=>1,'qty'=>200.00,'price'=>18.00,'amount'=>3600.00,'status'=>'received','remark'=>'期初采购','created_at'=>'2026-03-20'],
+ ['id'=>2,'order_no'=>'PO2026-002','supplier_id'=>2,'item_type'=>'material','item_id'=>3,'qty'=>100.00,'price'=>45.00,'amount'=>4500.00,'status'=>'pending','remark'=>'待交货','created_at'=>'2026-06-10'],
+ ],
+ 'psi_sales' => [
+ ['id'=>1,'order_no'=>'SO2026-001','customer'=>'某钢铁集团','channel'=>'domestic','item_id'=>1,'qty'=>30.00,'price'=>1299.00,'amount'=>38970.00,'status'=>'shipped','remark'=>'首批交付','created_at'=>'2026-05-15'],
+ ['id'=>2,'order_no'=>'SO2026-002','customer'=>'Global Apparel Importers Ltd','channel'=>'export','item_id'=>2,'qty'=>50.00,'price'=>399.00,'amount'=>19950.00,'status'=>'pending','remark'=>'信用证待开','created_at'=>'2026-06-20'],
+ ],
+ 'psi_stock_moves' => [
+ ['id'=>1,'item_type'=>'material','item_id'=>1,'direction'=>'in','qty'=>300.00,'ref_no'=>'INIT','remark'=>'期初入库','created_at'=>'2026-03-10'],
+ ['id'=>2,'item_type'=>'material','item_id'=>1,'direction'=>'in','qty'=>200.00,'ref_no'=>'PO2026-001','remark'=>'采购入库','created_at'=>'2026-03-20'],
+ ['id'=>3,'item_type'=>'material','item_id'=>2,'direction'=>'in','qty'=>300.00,'ref_no'=>'INIT','remark'=>'期初入库','created_at'=>'2026-03-12'],
+ ['id'=>4,'item_type'=>'material','item_id'=>3,'direction'=>'in','qty'=>200.00,'ref_no'=>'INIT','remark'=>'期初入库','created_at'=>'2026-03-12'],
+ ['id'=>5,'item_type'=>'product','item_id'=>1,'direction'=>'in','qty'=>100.00,'ref_no'=>'INIT','remark'=>'期初入库','created_at'=>'2026-03-15'],
+ ['id'=>6,'item_type'=>'product','item_id'=>1,'direction'=>'out','qty'=>30.00,'ref_no'=>'SO2026-001','remark'=>'销售出库','created_at'=>'2026-05-15'],
+ ['id'=>7,'item_type'=>'product','item_id'=>2,'direction'=>'in','qty'=>200.00,'ref_no'=>'INIT','remark'=>'期初入库','created_at'=>'2026-03-18'],
+ ['id'=>8,'item_type'=>'product','item_id'=>3,'direction'=>'in','qty'=>150.00,'ref_no'=>'INIT','remark'=>'期初入库','created_at'=>'2026-03-20'],
+ ],
+];
diff --git a/install/upgrade_crm_psi.sql b/install/upgrade_crm_psi.sql
new file mode 100644
index 0000000..be40efb
--- /dev/null
+++ b/install/upgrade_crm_psi.sql
@@ -0,0 +1,139 @@
+-- 酷冰甲 CMS 升级脚本:新增 CRM(客户管理)与 PSI(进销存)模块
+-- 用法:在远端 MySQL 执行本文件(phpMyAdmin / 命令行均可)。
+-- 幂等,可重复执行;兼容 MySQL 5.7 与 8.0(5.7 不支持 ADD COLUMN IF NOT EXISTS,故用存储过程判断)。
+SET NAMES utf8mb4;
+
+/* ===================== CRM 客户管理系统 ===================== */
+CREATE TABLE IF NOT EXISTS `crm_customers` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `name` VARCHAR(120) NOT NULL,
+ `company` VARCHAR(160) DEFAULT '',
+ `contact` VARCHAR(60) DEFAULT '',
+ `phone` VARCHAR(40) DEFAULT '',
+ `email` VARCHAR(120) DEFAULT '',
+ `country` VARCHAR(60) DEFAULT '',
+ `type` VARCHAR(20) DEFAULT 'trade',
+ `source` VARCHAR(40) DEFAULT '',
+ `level` VARCHAR(20) DEFAULT 'C',
+ `remark` TEXT,
+ `owner` VARCHAR(60) DEFAULT '',
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `crm_leads` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `customer_id` INT DEFAULT 0,
+ `title` VARCHAR(200) NOT NULL,
+ `amount` DECIMAL(12,2) DEFAULT 0,
+ `stage` VARCHAR(20) DEFAULT 'new',
+ `expected_close` VARCHAR(20) DEFAULT '',
+ `owner` VARCHAR(60) DEFAULT '',
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `crm_followups` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `customer_id` INT DEFAULT 0,
+ `lead_id` INT DEFAULT 0,
+ `content` TEXT,
+ `next_at` VARCHAR(20) DEFAULT '',
+ `owner` VARCHAR(60) DEFAULT '',
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+/* ===================== 进销存 PSI 系统 ===================== */
+CREATE TABLE IF NOT EXISTS `psi_materials` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `code` VARCHAR(40) DEFAULT '',
+ `name` VARCHAR(160) NOT NULL,
+ `spec` VARCHAR(120) DEFAULT '',
+ `unit` VARCHAR(10) DEFAULT '个',
+ `category` VARCHAR(40) DEFAULT '',
+ `stock` DECIMAL(12,2) DEFAULT 0,
+ `price` DECIMAL(10,2) DEFAULT 0,
+ `supplier_id` INT DEFAULT 0,
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_products` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `code` VARCHAR(40) DEFAULT '',
+ `name` VARCHAR(160) NOT NULL,
+ `spec` VARCHAR(120) DEFAULT '',
+ `unit` VARCHAR(10) DEFAULT '件',
+ `category` VARCHAR(40) DEFAULT '',
+ `stock` DECIMAL(12,2) DEFAULT 0,
+ `cost` DECIMAL(10,2) DEFAULT 0,
+ `price` DECIMAL(10,2) DEFAULT 0,
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_suppliers` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `name` VARCHAR(160) NOT NULL,
+ `contact` VARCHAR(60) DEFAULT '',
+ `phone` VARCHAR(40) DEFAULT '',
+ `country` VARCHAR(60) DEFAULT '',
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_purchases` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `order_no` VARCHAR(40) NOT NULL,
+ `supplier_id` INT DEFAULT 0,
+ `item_type` VARCHAR(10) DEFAULT 'material',
+ `item_id` INT DEFAULT 0,
+ `qty` DECIMAL(12,2) DEFAULT 0,
+ `price` DECIMAL(10,2) DEFAULT 0,
+ `amount` DECIMAL(12,2) DEFAULT 0,
+ `status` VARCHAR(20) DEFAULT 'pending',
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_sales` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `order_no` VARCHAR(40) NOT NULL,
+ `customer` VARCHAR(160) DEFAULT '',
+ `channel` VARCHAR(20) DEFAULT 'domestic',
+ `item_id` INT DEFAULT 0,
+ `qty` DECIMAL(12,2) DEFAULT 0,
+ `price` DECIMAL(10,2) DEFAULT 0,
+ `amount` DECIMAL(12,2) DEFAULT 0,
+ `status` VARCHAR(20) DEFAULT 'pending',
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `psi_stock_moves` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `item_type` VARCHAR(10) DEFAULT 'product',
+ `item_id` INT DEFAULT 0,
+ `direction` VARCHAR(10) DEFAULT 'in',
+ `qty` DECIMAL(12,2) DEFAULT 0,
+ `ref_no` VARCHAR(40) DEFAULT '',
+ `remark` TEXT,
+ `created_at` VARCHAR(20) DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+/* 为 admin_users 补齐分系统角色列(幂等,兼容 5.7) */
+DROP PROCEDURE IF EXISTS `add_crm_psi_cols`;
+DELIMITER $$
+CREATE PROCEDURE `add_crm_psi_cols`()
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_users' AND COLUMN_NAME='crm_role') THEN
+ ALTER TABLE `admin_users` ADD COLUMN `crm_role` VARCHAR(20) DEFAULT 'none';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_users' AND COLUMN_NAME='psi_role') THEN
+ ALTER TABLE `admin_users` ADD COLUMN `psi_role` VARCHAR(20) DEFAULT 'none';
+ END IF;
+END$$
+DELIMITER ;
+CALL `add_crm_psi_cols`();
+DROP PROCEDURE IF EXISTS `add_crm_psi_cols`;
+
+-- 升级完成后,登录后台 → 用户管理,将超级管理员/相关账号的 crm_role、psi_role 设为 admin。
diff --git a/install/upgrades/002_create_psi_order_tables.sql b/install/upgrades/002_create_psi_order_tables.sql
new file mode 100644
index 0000000..05e0cdd
--- /dev/null
+++ b/install/upgrades/002_create_psi_order_tables.sql
@@ -0,0 +1,200 @@
+-- ============================================================
+-- PSI 订单/出库与业务表建表 —— 服装厂成衣标准
+-- ============================================================
+-- 包含:销售订单、采购订单、出库单 共 6 张表
+-- + 修复 psi_purchases 缺少列
+-- 各表均已补齐品牌/款号/色号/尺码明细/箱重/体积等成衣行业必备字段
+-- ============================================================
+
+-- -------------------------------------------
+-- 1. 销售订单主表
+-- -------------------------------------------
+CREATE TABLE IF NOT EXISTS `psi_sales_orders` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `order_no` VARCHAR(40) NOT NULL COMMENT '订单编号 SO+年月日+流水',
+ `customer` VARCHAR(100) NOT NULL COMMENT '客户名称',
+ `customer_po` VARCHAR(80) DEFAULT '' COMMENT '客户采购单号(国际成衣贸易必备)',
+ `brand` VARCHAR(100) DEFAULT '' COMMENT '品牌',
+ `salesman` VARCHAR(50) DEFAULT '' COMMENT '业务员',
+ `channel` VARCHAR(20) DEFAULT 'domestic' COMMENT '销售渠道 domestic/export',
+ `region` VARCHAR(60) DEFAULT '' COMMENT '销售区域',
+ `currency` VARCHAR(10) DEFAULT 'CNY' COMMENT '币种 CNY/USD/EUR',
+ `payment_terms` VARCHAR(50) DEFAULT '' COMMENT '付款条件 T/T,L/C,D/P,OA',
+ `incoterm` VARCHAR(10) DEFAULT '' COMMENT '贸易术语 FOB/CIF/EXW/CFR/DDP',
+ `shipping_mark` VARCHAR(200) DEFAULT '' COMMENT '唛头',
+ `style_count` INT DEFAULT 0 COMMENT '款数',
+ `total_qty` INT DEFAULT 0 COMMENT '总数量',
+ `total_amount` DECIMAL(12,2) DEFAULT 0 COMMENT '总金额',
+ `delivery_date` VARCHAR(20) DEFAULT '' COMMENT '预计交付日期',
+ `remark` TEXT COMMENT '备注',
+ `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态 pending/partial/delivered/closed',
+ `created_at` VARCHAR(20) DEFAULT '' COMMENT '创建时间',
+ `updated_at` VARCHAR(20) DEFAULT '' COMMENT '最后修改时间',
+ INDEX `idx_so_customer` (`customer`),
+ INDEX `idx_so_salesman` (`salesman`),
+ INDEX `idx_so_status` (`status`),
+ INDEX `idx_so_created` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售订单主表';
+
+-- -------------------------------------------
+-- 2. 销售订单明细表
+-- -------------------------------------------
+CREATE TABLE IF NOT EXISTS `psi_sales_order_items` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `so_id` INT NOT NULL COMMENT '关联销售订单ID',
+ `product_id` INT DEFAULT 0 COMMENT '关联成品ID',
+ `style_no` VARCHAR(60) DEFAULT '' COMMENT '款号/货号',
+ `name` VARCHAR(120) NOT NULL COMMENT '商品名称',
+ `spec` VARCHAR(100) DEFAULT '' COMMENT '规格',
+ `color_code` VARCHAR(30) DEFAULT '' COMMENT '色号',
+ `size_detail` TEXT COMMENT '尺码明细 JSON [{size:\"S\",qty:100},...]',
+ `unit` VARCHAR(20) DEFAULT '件' COMMENT '单位',
+ `qty` INT DEFAULT 0 COMMENT '数量',
+ `price` DECIMAL(10,2) DEFAULT 0 COMMENT '单价',
+ `amount` DECIMAL(12,2) DEFAULT 0 COMMENT '金额',
+ `unit_price_cny` DECIMAL(10,2) DEFAULT 0 COMMENT '人民币单价(外单换算用)',
+ `packing` VARCHAR(60) DEFAULT '' COMMENT '包装方式(独色独码/混色混码)',
+ `delivered_qty` INT DEFAULT 0 COMMENT '已交付数量',
+ INDEX `idx_soi_so_id` (`so_id`),
+ INDEX `idx_soi_product_id` (`product_id`),
+ INDEX `idx_soi_style_no` (`style_no`),
+ FOREIGN KEY (`so_id`) REFERENCES `psi_sales_orders`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售订单明细';
+
+-- -------------------------------------------
+-- 3. 采购订单主表
+-- -------------------------------------------
+CREATE TABLE IF NOT EXISTS `psi_purchase_orders` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `order_no` VARCHAR(40) NOT NULL COMMENT '订单编号 PO+年月日+流水',
+ `contract_no` VARCHAR(80) DEFAULT '' COMMENT '合同号',
+ `supplier_id` INT DEFAULT 0 COMMENT '供应商ID',
+ `supplier_name` VARCHAR(100) DEFAULT '' COMMENT '供应商名称',
+ `brand` VARCHAR(100) DEFAULT '' COMMENT '品牌',
+ `salesman` VARCHAR(50) DEFAULT '' COMMENT '采购员',
+ `currency` VARCHAR(10) DEFAULT 'CNY' COMMENT '币种',
+ `payment_terms` VARCHAR(50) DEFAULT '' COMMENT '付款条件',
+ `total_qty` INT DEFAULT 0 COMMENT '总数量',
+ `total_amount` DECIMAL(12,2) DEFAULT 0 COMMENT '总金额',
+ `expected_at` VARCHAR(20) DEFAULT '' COMMENT '预计到货日期',
+ `remark` TEXT COMMENT '备注',
+ `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态 pending/received/closed',
+ `created_at` VARCHAR(20) DEFAULT '' COMMENT '创建时间',
+ `updated_at` VARCHAR(20) DEFAULT '' COMMENT '最后修改时间',
+ INDEX `idx_po_supplier` (`supplier_id`),
+ INDEX `idx_po_status` (`status`),
+ INDEX `idx_po_created` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购订单主表';
+
+-- -------------------------------------------
+-- 4. 采购订单明细表
+-- -------------------------------------------
+CREATE TABLE IF NOT EXISTS `psi_purchase_order_items` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `po_id` INT NOT NULL COMMENT '关联采购订单ID',
+ `item_type` VARCHAR(10) DEFAULT 'product' COMMENT '类型 product/material',
+ `item_id` INT DEFAULT 0 COMMENT '关联物料/成品ID',
+ `style_no` VARCHAR(60) DEFAULT '' COMMENT '款号/货号',
+ `name` VARCHAR(120) NOT NULL COMMENT '商品名称',
+ `spec` VARCHAR(100) DEFAULT '' COMMENT '规格',
+ `color_code` VARCHAR(30) DEFAULT '' COMMENT '色号',
+ `size_detail` TEXT COMMENT '尺码明细 JSON',
+ `material_code` VARCHAR(60) DEFAULT '' COMMENT '物料编码',
+ `unit` VARCHAR(20) DEFAULT '件' COMMENT '单位',
+ `qty` INT DEFAULT 0 COMMENT '数量',
+ `price` DECIMAL(10,2) DEFAULT 0 COMMENT '单价',
+ `amount` DECIMAL(12,2) DEFAULT 0 COMMENT '金额',
+ `delivery_date` VARCHAR(20) DEFAULT '' COMMENT '预计交期',
+ `received_qty` INT DEFAULT 0 COMMENT '已收货数量',
+ INDEX `idx_poi_po_id` (`po_id`),
+ INDEX `idx_poi_item_type` (`item_type`),
+ INDEX `idx_poi_style_no` (`style_no`),
+ FOREIGN KEY (`po_id`) REFERENCES `psi_purchase_orders`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购订单明细';
+
+-- -------------------------------------------
+-- 5. 出库单主表
+-- -------------------------------------------
+CREATE TABLE IF NOT EXISTS `psi_outbounds` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `order_no` VARCHAR(40) NOT NULL COMMENT '出库单号 OUT+年月日+流水',
+ `so_no` VARCHAR(40) DEFAULT '' COMMENT '关联销售订单号',
+ `customer` VARCHAR(100) DEFAULT '' COMMENT '客户',
+ `customer_po` VARCHAR(80) DEFAULT '' COMMENT '客户采购单号',
+ `brand` VARCHAR(100) DEFAULT '' COMMENT '品牌',
+ `salesman` VARCHAR(50) DEFAULT '' COMMENT '业务员',
+ `warehouse` VARCHAR(60) DEFAULT '' COMMENT '出库仓库',
+ `carton_count` INT DEFAULT 0 COMMENT '箱数',
+ `container_no` VARCHAR(100) DEFAULT '' COMMENT '柜号/箱号范围',
+ `seal_no` VARCHAR(60) DEFAULT '' COMMENT '封条号',
+ `packing_list_no` VARCHAR(60) DEFAULT '' COMMENT '装箱单号',
+ `shipping_line` VARCHAR(80) DEFAULT '' COMMENT '船公司/快递',
+ `voyage` VARCHAR(60) DEFAULT '' COMMENT '航次/航班',
+ `port_of_loading` VARCHAR(60) DEFAULT '' COMMENT '装运港',
+ `port_of_discharge` VARCHAR(60) DEFAULT '' COMMENT '卸货港',
+ `etd` VARCHAR(20) DEFAULT '' COMMENT '预计离港日',
+ `eta` VARCHAR(20) DEFAULT '' COMMENT '预计到港日',
+ `gross_weight` DECIMAL(10,2) DEFAULT 0 COMMENT '毛重 KG',
+ `net_weight` DECIMAL(10,2) DEFAULT 0 COMMENT '净重 KG',
+ `volume` DECIMAL(10,3) DEFAULT 0 COMMENT '体积 CBM',
+ `status` VARCHAR(20) DEFAULT 'delivered' COMMENT '状态 delivered/partial',
+ `delivery_date` VARCHAR(20) DEFAULT '' COMMENT '实际交付日期',
+ `remark` TEXT COMMENT '备注',
+ `created_at` VARCHAR(20) DEFAULT '' COMMENT '创建时间',
+ `updated_at` VARCHAR(20) DEFAULT '' COMMENT '最后修改时间',
+ INDEX `idx_ob_so_no` (`so_no`),
+ INDEX `idx_ob_customer` (`customer`),
+ INDEX `idx_ob_status` (`status`),
+ INDEX `idx_ob_created` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库单主表';
+
+-- -------------------------------------------
+-- 6. 出库单明细表
+-- -------------------------------------------
+CREATE TABLE IF NOT EXISTS `psi_outbound_items` (
+ `id` INT AUTO_INCREMENT PRIMARY KEY,
+ `ob_id` INT NOT NULL COMMENT '关联出库单ID',
+ `so_item_id` INT DEFAULT 0 COMMENT '关联销售订单明细ID',
+ `product_id` INT DEFAULT 0 COMMENT '成品ID',
+ `style_no` VARCHAR(60) DEFAULT '' COMMENT '款号/货号',
+ `name` VARCHAR(120) NOT NULL COMMENT '商品名称',
+ `spec` VARCHAR(100) DEFAULT '' COMMENT '规格',
+ `color_code` VARCHAR(30) DEFAULT '' COMMENT '色号',
+ `size_detail` TEXT COMMENT '尺码明细 JSON',
+ `carton_no` VARCHAR(40) DEFAULT '' COMMENT '箱号',
+ `unit` VARCHAR(20) DEFAULT '件' COMMENT '单位',
+ `qty` INT DEFAULT 0 COMMENT '数量',
+ `price` DECIMAL(10,2) DEFAULT 0 COMMENT '单价',
+ `amount` DECIMAL(12,2) DEFAULT 0 COMMENT '金额',
+ `unit_weight` DECIMAL(10,2) DEFAULT 0 COMMENT '单件重量 g/KG',
+ INDEX `idx_obi_ob_id` (`ob_id`),
+ INDEX `idx_obi_product_id` (`product_id`),
+ INDEX `idx_obi_so_item_id` (`so_item_id`),
+ INDEX `idx_obi_style_no` (`style_no`),
+ FOREIGN KEY (`ob_id`) REFERENCES `psi_outbounds`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库单明细';
+
+-- -------------------------------------------
+-- 7. 修复 psi_purchases —— 补齐 PurchaseOrdersController::receive() 所需的列
+-- -------------------------------------------
+-- 使用 INFORMATION_SCHEMA 动态检测是否存在列,兼容 MySQL 5.7+ / MariaDB 10.2+
+DROP PROCEDURE IF EXISTS tmp_psi_purchases_alter;
+DELIMITER $$
+CREATE PROCEDURE tmp_psi_purchases_alter()
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'psi_purchases' AND COLUMN_NAME = 'product_id') THEN
+ ALTER TABLE `psi_purchases` ADD COLUMN `product_id` INT DEFAULT 0 COMMENT '成品ID(采购订单收货时使用)' AFTER `item_id`;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'psi_purchases' AND COLUMN_NAME = 'supplier') THEN
+ ALTER TABLE `psi_purchases` ADD COLUMN `supplier` VARCHAR(100) DEFAULT '' COMMENT '供应商名称' AFTER `supplier_id`;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'psi_purchases' AND COLUMN_NAME = 'brand') THEN
+ ALTER TABLE `psi_purchases` ADD COLUMN `brand` VARCHAR(100) DEFAULT '' COMMENT '品牌' AFTER `supplier`;
+ END IF;
+END$$
+DELIMITER ;
+CALL tmp_psi_purchases_alter();
+DROP PROCEDURE IF EXISTS tmp_psi_purchases_alter;
diff --git a/install/upgrades/003_psi_events_and_notify.sql b/install/upgrades/003_psi_events_and_notify.sql
new file mode 100644
index 0000000..536475f
--- /dev/null
+++ b/install/upgrades/003_psi_events_and_notify.sql
@@ -0,0 +1,46 @@
+-- ============================================================
+-- 事件通知(邮件 + 企业微信)数据升级包
+-- ============================================================
+-- 作用:
+-- 1) 创建 psi_events 紧急事件表(Notify 服务使用,首次触发也会自动建表,这里提前建好)
+-- 2) 写入 notify_* 通知设置(INSERT IGNORE,重复执行不会重复插入)
+-- 说明:
+-- 本文件专供后台「数据库升级」面板(/admin/upgrade)使用;
+-- 若已通过「数据升级」按钮执行过,这里仍为幂等安全。
+-- ============================================================
+
+-- -------------------------------------------
+-- 1. 紧急事件表(站内提醒 + 邮件/企业微信推送记录)
+-- -------------------------------------------
+CREATE TABLE IF NOT EXISTS `psi_events` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ `sys` VARCHAR(20) NOT NULL DEFAULT 'psi',
+ `type` VARCHAR(40) NOT NULL DEFAULT '',
+ `level` VARCHAR(20) NOT NULL DEFAULT 'urgent',
+ `title` VARCHAR(255) NOT NULL DEFAULT '',
+ `body` TEXT,
+ `url` VARCHAR(255) NOT NULL DEFAULT '',
+ `ref_no` VARCHAR(64) NOT NULL DEFAULT '',
+ `recipients` TEXT,
+ `channels` VARCHAR(255) NOT NULL DEFAULT '["inapp"]',
+ `read_by` TEXT,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- -------------------------------------------
+-- 2. 通知设置(缺则插入)
+-- -------------------------------------------
+INSERT IGNORE INTO `settings` (`skey`,`sval`,`sgroup`) VALUES
+ ('notify_enabled','0','notify'),
+ ('notify_email_enabled','0','notify'),
+ ('notify_email_smtp_host','','notify'),
+ ('notify_email_smtp_port','465','notify'),
+ ('notify_email_smtp_user','','notify'),
+ ('notify_email_smtp_pass','','notify'),
+ ('notify_email_from','','notify'),
+ ('notify_email_to','','notify'),
+ ('notify_wechat_enabled','0','notify'),
+ ('notify_wechat_webhook','','notify'),
+ ('notify_wechat_mention','','notify'),
+ ('notify_lowstock_enabled','1','notify'),
+ ('notify_lowstock_threshold','20','notify');
diff --git a/install/upgrades/004_psi_sales_item_type.sql b/install/upgrades/004_psi_sales_item_type.sql
new file mode 100644
index 0000000..1c08848
--- /dev/null
+++ b/install/upgrades/004_psi_sales_item_type.sql
@@ -0,0 +1,20 @@
+-- 给 psi_sales 表增加 item_type 字段,用于精确识别成品/物料类型
+-- 幂等升级:可多次安全执行
+
+DROP PROCEDURE IF EXISTS `psi_add_item_type`;
+DELIMITER $$
+CREATE PROCEDURE `psi_add_item_type`()
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='psi_sales' AND COLUMN_NAME='item_type') THEN
+ ALTER TABLE psi_sales ADD COLUMN item_type VARCHAR(16) DEFAULT '' AFTER item_id;
+ END IF;
+END$$
+DELIMITER ;
+CALL `psi_add_item_type`();
+DROP PROCEDURE IF EXISTS `psi_add_item_type`;
+
+-- 如果有 stock_moves 记录,尝试补齐现有数据的 item_type
+UPDATE psi_sales s
+LEFT JOIN psi_stock_moves m ON m.ref_no = s.order_no AND m.item_id = s.item_id AND m.qty = s.qty
+SET s.item_type = m.item_type
+WHERE s.item_type = '' AND m.item_type IS NOT NULL AND m.item_type != '';
diff --git a/install/upgrades/005_page_seo.sql b/install/upgrades/005_page_seo.sql
new file mode 100644
index 0000000..253805f
--- /dev/null
+++ b/install/upgrades/005_page_seo.sql
@@ -0,0 +1,31 @@
+-- 新增 page_seo 表:支持后台逐页设置 SEO(标题/描述/关键词/Open Graph 等)
+-- 幂等升级:可多次安全执行(CREATE TABLE IF NOT EXISTS + INSERT IGNORE)
+
+CREATE TABLE IF NOT EXISTS `page_seo` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `page_key` VARCHAR(64) NOT NULL COMMENT '页面标识(home/products/product_category/news/cases/about/contact)',
+ `page_label` VARCHAR(64) NOT NULL COMMENT '后台显示名称',
+ `title` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '页面标题',
+ `description` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '页面描述',
+ `keywords` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '关键词,逗号分隔',
+ `og_title` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '社交分享标题',
+ `og_description` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '社交分享描述',
+ `og_image` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '社交分享图',
+ `og_type` VARCHAR(32) NOT NULL DEFAULT 'website',
+ `canonical` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '规范链接',
+ `noindex` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否禁止收录',
+ `sort` INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_page_key` (`page_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='逐页SEO设置';
+
+-- 种子数据:默认文案已满足 SEO 建议字数
+-- 首页标题≥35字符 / 描述≥120字符 / 分类页描述≥80字符 / 关键词≥10个(部分页面按语义给 6-10 个)
+INSERT IGNORE INTO `page_seo` (`page_key`,`page_label`,`title`,`description`,`keywords`,`og_type`,`sort`) VALUES
+('home','首页','酷冰甲降温服官网 | 科技降温服定制·水冷循环·相变蓄冷·风冷背心·10套起订','酷冰甲专注降温服的研发、生产与定制,提供水冷循环、相变蓄冷、风冷制冷、冰袋背心等多系列降温装备,广泛适用于消防、工业、电力、钢铁、环卫及户外高温作业场景。支持企业LOGO刺绣、尺寸与面料定制,10套起订,7天打样,全国发货,为您提供一站式高温防护解决方案。','降温服,降温背心,水冷降温服,相变降温服,制冷背心,工业降温服,消防降温服,高温作业防护,降温服定制,酷冰甲','website',10),
+('products','产品中心','降温服产品中心 - 水冷/相变/风冷多系列 | 酷冰甲','酷冰甲降温服产品中心,系统展示水冷循环降温服、相变冰袋降温背心、风冷制冷背心、冰马甲等多系列产品,按使用场景与降温方式分类,参数规格与适用行业一目了然。支持企业批量定制、LOGO刺绣与免费拿样,提供专业选型建议与透明报价,助力高温作业安全防护。','降温服产品,水冷降温服,相变降温服,风冷降温服,制冷背心,冰马甲,工业降温装备,降温服批发,降温服定制,酷冰甲产品','website',20),
+('product_category','产品分类页','{cat}降温服 - 酷冰甲科技降温·定制批发','酷冰甲{cat}系列降温服,采用科技降温方案,专为高温作业与户外暴晒场景设计,具备清凉持久、轻便透气、可循环重复使用等特点。支持企业定制、LOGO刺绣与小批量批发,10套起订,7天打样,全国发货。','{cat}降温服,{cat},降温服定制,降温背心,工业降温,酷冰甲','website',25),
+('news','新闻列表','降温服行业新闻与动态 - 酷冰甲官网','酷冰甲降温服行业新闻中心,汇集高温防护政策解读、降温技术深度解析、产品应用案例、客户现场实录与行业前沿动态,持续分享降温服选型、使用、保养与清洗知识,帮助企业做好高温作业人员的健康与安全防护。我们关注每一次技术迭代,也记录每一处真实应用,让高温防护更有依据、更可落地。','降温服新闻,降温技术,高温防护,工业降温,降温服应用,行业动态,酷冰甲资讯,降温服知识','website',30),
+('cases','客户案例','客户案例 - 酷冰甲降温服应用实录','酷冰甲降温服客户案例展示,覆盖消防、电力、钢铁、环卫、户外施工、车间制造等高温作业场景的真实合作项目,逐一呈现降温方案设计思路、现场使用效果与客户真实反馈,并附上适用行业与选型建议,为同类企业的高温防护升级提供可参考、可复用的实战样本,切实降低高温作业风险。','降温服案例,客户案例,高温作业,降温方案,消防降温,工业应用,酷冰甲案例','website',40),
+('about','关于我们','关于酷冰甲 - 科技降温服研发与定制厂家','酷冰甲专注降温服的研发、生产与定制,拥有水冷循环、相变蓄冷、风冷制冷等多条成熟产品线,长期服务消防、工业、电力、钢铁等高温作业领域。工厂直供、支持ODM/OEM与来样定制,提供从需求沟通、方案设计、打样生产到售后维护的全流程服务,做您身边靠谱的高温防护伙伴。','关于酷冰甲,降温服厂家,降温服工厂,降温服研发,ODM定制,降温服生产,酷冰甲品牌','website',50),
+('contact','联系我们','联系酷冰甲 - 降温服定制咨询与报价','联系酷冰甲,获取降温服定制方案与专属报价。我们支持企业批量采购、LOGO刺绣、尺寸与面料定制,提供在线咨询、电话与邮件多种沟通方式。7天打样、全国发货,专业团队一对一对接您的高温防护需求,从选型到交付全程跟进,确保交付准时可靠,让合作更省心、更可靠。','联系酷冰甲,降温服定制,降温服报价,降温服采购,企业定制,降温服厂家,酷冰甲联系','website',60);
diff --git a/install/upgrades/006_pages_add_mode.sql b/install/upgrades/006_pages_add_mode.sql
new file mode 100644
index 0000000..76aea3b
--- /dev/null
+++ b/install/upgrades/006_pages_add_mode.sql
@@ -0,0 +1,12 @@
+-- 006 页面编辑模式:为 pages 表增加 mode 字段
+-- 用途:后台「页面」编辑支持「固定版面 fixed / 可视化编辑 builder」两种模式
+-- 幂等:用 information_schema 判断列是否存在,存在则跳过(兼容 MySQL 5.7 / 8.x 全版本及 MariaDB)
+-- 旧数据默认 'fixed',渲染行为不变;已用可视化编辑器存过 layout 的页面可手动置为 'builder'
+-- 应用方式:后台「系统 / 数据库升级」→ 找到本文件 → 点「升级」
+
+SET @db = DATABASE();
+SET @has = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'pages' AND COLUMN_NAME = 'mode');
+SET @sql = IF(@has = 0, 'ALTER TABLE `pages` ADD COLUMN `mode` VARCHAR(16) NOT NULL DEFAULT \'fixed\' AFTER `layout`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/install/upgrades/007_products_add_mode.sql b/install/upgrades/007_products_add_mode.sql
new file mode 100644
index 0000000..348ae5d
--- /dev/null
+++ b/install/upgrades/007_products_add_mode.sql
@@ -0,0 +1,12 @@
+-- 007 产品编辑模式:为 products 表增加 mode 字段
+-- 用途:后台「产品」编辑支持「固定版面 fixed / 可视化编辑 builder」两种模式(选择框切换)
+-- 幂等:用 information_schema 判断列是否存在,存在则跳过(兼容 MySQL 5.7 / 8.x 全版本及 MariaDB)
+-- 旧数据默认 'fixed',渲染行为不变;已用可视化编辑器存过 layout 的产品可手动置为 'builder'
+-- 应用方式:后台「系统 / 数据库升级」→ 找到本文件 → 点「升级」
+
+SET @db = DATABASE();
+SET @has = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'products' AND COLUMN_NAME = 'mode');
+SET @sql = IF(@has = 0, 'ALTER TABLE `products` ADD COLUMN `mode` VARCHAR(16) NOT NULL DEFAULT \'fixed\' AFTER `layout`', 'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/install/upgrades/008_add_mode_news_cases_cats.sql b/install/upgrades/008_add_mode_news_cases_cats.sql
new file mode 100644
index 0000000..2776db4
--- /dev/null
+++ b/install/upgrades/008_add_mode_news_cases_cats.sql
@@ -0,0 +1,22 @@
+-- 008 编辑模式扩展:为 news / cases / categories 表增加 mode 字段
+-- 用途:后台新闻、客户案例、分类编辑支持「固定版面 fixed / 可视化编辑 builder」两种模式(与产品/单页一致)
+-- 幂等:用 information_schema 判断列是否存在,存在则跳过(兼容 MySQL 5.7 / 8.x 全版本及 MariaDB)
+-- 旧数据默认 'fixed',渲染行为不变;已用可视化编辑器存过 layout 的记录可手动置为 'builder'
+-- 应用方式:后台「系统 / 数据库升级」→ 找到本文件 → 点「升级」
+
+SET @db = DATABASE();
+
+-- news
+SET @has = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'news' AND COLUMN_NAME = 'mode');
+SET @sql = IF(@has = 0, 'ALTER TABLE `news` ADD COLUMN `mode` VARCHAR(16) NOT NULL DEFAULT \'fixed\' AFTER `layout`', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- cases
+SET @has = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cases' AND COLUMN_NAME = 'mode');
+SET @sql = IF(@has = 0, 'ALTER TABLE `cases` ADD COLUMN `mode` VARCHAR(16) NOT NULL DEFAULT \'fixed\' AFTER `layout`', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- categories
+SET @has = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'categories' AND COLUMN_NAME = 'mode');
+SET @sql = IF(@has = 0, 'ALTER TABLE `categories` ADD COLUMN `mode` VARCHAR(16) NOT NULL DEFAULT \'fixed\' AFTER `layout`', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
diff --git a/install/upgrades/README.txt b/install/upgrades/README.txt
new file mode 100644
index 0000000..e91924f
--- /dev/null
+++ b/install/upgrades/README.txt
@@ -0,0 +1,37 @@
+数据库升级包目录(install/upgrades)
+================================================
+
+用途
+----
+将「升级脚本」(.sql 文件)放入本目录后,进入后台「数据库升级」页面,
+系统会自动扫描并提示「发现 N 个可升级数据库脚本」,管理员可一键或逐个执行。
+每次执行都会在 db_upgrades 表中留下记录(文件名、内容指纹 MD5、执行时间、操作人),
+便于追溯与审计。
+
+命名建议
+--------
+ 序号_功能描述.sql 例如 001_add_member_level.sql
+序号便于排序与阅读,文件名(不含路径)作为唯一标识写入 db_upgrades。
+若同一文件内容被修改后再次放入,系统会识别为「内容已变更」并提示重新升级。
+
+编写规范
+--------
+1. 仅放 DDL / DML 的标准 MySQL 脚本;多条语句以分号(;)分隔,自动拆分执行。
+2. 优先使用「幂等写法」,避免重复执行报错,例如:
+ CREATE TABLE IF NOT EXISTS xxx (...); -- 全版本支持
+ INSERT INTO ... ON DUPLICATE KEY UPDATE ...; -- 全版本支持
+ 注意:ALTER TABLE ... ADD COLUMN IF NOT EXISTS 仅 MySQL 8.0.28+ / MariaDB 10.8+ 支持,
+ 旧版本(含多数宝塔默认的 MySQL 5.7 与 MariaDB 10.4/10.6)会直接报 1064 语法错误。
+ 跨版本安全的「加列」幂等方式(强烈推荐):
+ SET @db = DATABASE();
+ SET @has = (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA=@db AND TABLE_NAME='yyy' AND COLUMN_NAME='zzz');
+ SET @sql = IF(@has=0, 'ALTER TABLE `yyy` ADD COLUMN `zzz` VARCHAR(16) NOT NULL DEFAULT \'x\'', 'SELECT 1');
+ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+3. 不建议在升级包中执行 DROP TABLE / TRUNCATE 等破坏性语句,除非确有必要。
+4. 升级前请务必备份数据库。
+
+说明
+----
+- 本目录下的 examples/ 子目录为示例,不会被自动扫描(仅扫描本目录根下的 *.sql)。
+- 想试用示例时,请将 examples/ 中的文件复制到本目录根下再执行。
diff --git a/install/upgrades/examples/001_example_add_field.sql b/install/upgrades/examples/001_example_add_field.sql
new file mode 100644
index 0000000..b1de7cd
--- /dev/null
+++ b/install/upgrades/examples/001_example_add_field.sql
@@ -0,0 +1,13 @@
+-- 示例升级包:演示「幂等」写法,可安全多次执行
+-- 复制本文件到上级目录(install/upgrades/)根下,即可在后台「数据库升级」中看到并升级。
+
+-- 1) 为新模块创建一张表(若已存在则跳过)
+CREATE TABLE IF NOT EXISTS psi_demo_log (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ msg VARCHAR(255) NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='示例升级包日志表';
+
+-- 2) 写入一条演示记录(依赖唯一键可避免重复)
+INSERT INTO psi_demo_log (msg) VALUES ('示例升级包已执行')
+ ON DUPLICATE KEY UPDATE msg = VALUES(msg);
diff --git a/public/.htaccess b/public/.htaccess
new file mode 100644
index 0000000..3fc9788
--- /dev/null
+++ b/public/.htaccess
@@ -0,0 +1,59 @@
+# ============================================================
+# 圣巧依官网 - public/.htaccess(唯一配置)
+# ============================================================
+
+# ── sitemap.xml 白名单(必须放在最前面)──
+
+ Require all granted
+
+
+# ── 禁止直接访问敏感文件 ──
+
+ Require all denied
+
+
+ Require all denied
+
+
+# ── 禁止列出目录 ──
+Options -Indexes
+
+# ── 安全响应头 ──
+
+ Header always set X-Frame-Options "SAMEORIGIN"
+ Header always set X-Content-Type-Options "nosniff"
+ Header always set Referrer-Policy "strict-origin-when-cross-origin"
+ Header always set X-XSS-Protection "1; mode=block"
+ Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
+ Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
+
+
+# ── 静态资源缓存 ──
+
+ ExpiresActive On
+ ExpiresByType text/css "access plus 1 year"
+ ExpiresByType application/javascript "access plus 1 year"
+ ExpiresByType image/jpeg "access plus 1 year"
+ ExpiresByType image/png "access plus 1 year"
+ ExpiresByType image/gif "access plus 1 year"
+ ExpiresByType image/svg+xml "access plus 1 year"
+ ExpiresByType font/woff2 "access plus 1 year"
+ ExpiresByType font/woff "access plus 1 year"
+
+
+# ── Gzip 压缩 ──
+
+ AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript application/json application/xml image/svg+xml
+
+
+# ── URL 重写:非静态文件全部走 index.php ──
+
+ RewriteEngine On
+
+# 如果请求的是真实存在的文件或目录,直接返回
+ RewriteCond %{REQUEST_FILENAME} !-f
+ RewriteCond %{REQUEST_FILENAME} !-d
+
+ # 其余所有请求交给 index.php(应用自己处理路由)
+ RewriteRule ^ index.php [L,QSA]
+
diff --git a/public/.htaccess_D4zD6.tar.gz b/public/.htaccess_D4zD6.tar.gz
new file mode 100644
index 0000000..0c682cf
Binary files /dev/null and b/public/.htaccess_D4zD6.tar.gz differ
diff --git a/public/.user.ini b/public/.user.ini
new file mode 100644
index 0000000..c6ba7aa
--- /dev/null
+++ b/public/.user.ini
@@ -0,0 +1,2 @@
+session.save_path=/www/php_session/st-joyapparel.com/
+session.save_handler = files
\ No newline at end of file
diff --git a/public/assets/css/admin.css b/public/assets/css/admin.css
new file mode 100644
index 0000000..2af4c2c
--- /dev/null
+++ b/public/assets/css/admin.css
@@ -0,0 +1,306 @@
+/* ===== 后台管理样式 · 现代化设计系统 ===== */
+:root {
+ --brand-1: #0ea5e9; /* 主色·天蓝 */
+ --brand-2: #14b8a6; /* 辅色·青绿 */
+ --brand-grad: linear-gradient(135deg, #0ea5e9, #14b8a6);
+ --side-bg: #0b1220; /* 侧栏深底 */
+ --side-bg-2: #111a2e;
+ --ink: #0f172a;
+ --ink-soft: #475569;
+ --muted: #94a3b8;
+ --line: #e5e9f0;
+ --surface: #ffffff;
+ --canvas: #f4f6fb;
+ --radius: 14px;
+ --radius-lg: 18px;
+ --shadow-sm: 0 1px 2px rgba(15,23,42,.06), 0 1px 3px rgba(15,23,42,.04);
+ --shadow-md: 0 6px 24px -12px rgba(15,23,42,.24);
+ --shadow-lg: 0 24px 60px -28px rgba(2,12,27,.55);
+}
+* { box-sizing: border-box; }
+body.admin { margin: 0; font-family: "Noto Sans SC", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background: var(--canvas); color: var(--ink); -webkit-font-smoothing: antialiased; }
+a { color: inherit; text-decoration: none; }
+.li { flex: 0 0 auto; vertical-align: middle; }
+
+/* sidebar */
+.admin-side { position: fixed; inset: 0 auto 0 0; width: 252px; background: linear-gradient(180deg, var(--side-bg-2), var(--side-bg)); color: #cbd5e1; padding: 18px 14px 14px; display: flex; flex-direction: column; gap: 4px; z-index: 30; border-right: 1px solid rgba(255,255,255,.04); }
+.admin-brand { display: flex; align-items: center; gap: 11px; color: #fff; font-weight: 800; font-size: 16px; padding: 8px 10px 16px; letter-spacing: .01em; }
+.admin-brand .brand-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.admin-nav { display: flex; flex-direction: column; gap: 3px; overflow-y: auto; flex: 1; margin: 4px -4px; padding: 0 4px; }
+.admin-nav::-webkit-scrollbar { width: 6px; }
+.admin-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border-radius: 999px; }
+.admin-nav a { position: relative; display: flex; align-items: center; gap: 12px; padding: 10px 13px; border-radius: 11px; color: #aab6c8; font-weight: 600; font-size: 14px; transition: background .18s, color .18s, transform .1s; }
+.admin-nav a .nav-ic { display: inline-flex; color: #7c8aa3; transition: color .18s; }
+.admin-nav a:hover { background: rgba(255,255,255,.06); color: #fff; }
+.admin-nav a:hover .nav-ic { color: #cbd5e1; }
+.admin-nav a.active { background: var(--brand-grad); color: #fff; box-shadow: 0 10px 22px -12px rgba(14,165,233,.8); }
+.admin-nav a.active .nav-ic { color: #fff; }
+.admin-nav a.active::before { content: ""; position: absolute; left: -14px; top: 50%; transform: translateY(-50%); width: 4px; height: 22px; border-radius: 0 4px 4px 0; background: #fff; }
+.admin-side-foot { border-top: 1px solid rgba(255,255,255,.07); padding-top: 12px; margin-top: 4px; }
+.admin-side-foot a { display: flex; align-items: center; gap: 9px; padding: 9px 12px; border-radius: 10px; color: #8fa0b8; font-size: 13px; font-weight: 600; transition: background .18s, color .18s; }
+.admin-side-foot a:hover { background: rgba(255,255,255,.06); color: #fff; }
+/* 子系统子页面(CRM / PSI 的二级项),作为左导主项下缩进子项,与 admin 框架风格统一 */
+.admin-nav a.child { padding-left: 40px; font-size: 13px; gap: 10px; opacity: .82; }
+.admin-nav a.child .nav-ic, .admin-nav a.child span { font-size: 14px; }
+.admin-nav a.child.active { opacity: 1; }
+
+.admin-body { margin-left: 252px; min-height: 100vh; display: flex; flex-direction: column; }
+.admin-top { height: 64px; background: rgba(255,255,255,.85); backdrop-filter: saturate(1.6) blur(8px); border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; padding: 0 26px; position: sticky; top: 0; z-index: 20; }
+.admin-top-left { display: flex; align-items: center; gap: 14px; }
+.top-link { display: inline-flex; align-items: center; gap: 6px; color: var(--brand-1); font-weight: 700; font-size: 14px; }
+.admin-user { display: flex; align-items: center; gap: 12px; font-size: 14px; color: var(--ink-soft); }
+.admin-user-name { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; }
+.admin-avatar { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: var(--brand-grad); color: #fff; }
+.admin-content { padding: 30px; flex: 1; max-width: 1400px; width: 100%; }
+
+/* login */
+.admin-login-wrap { min-height: 100vh; display: grid; place-items: center; background: radial-gradient(60% 55% at 25% 15%, rgba(14,165,233,.28), transparent), radial-gradient(55% 55% at 85% 85%, rgba(20,184,166,.26), transparent), #0b1220; padding: 20px; }
+.login-card { width: 400px; max-width: 100%; background: var(--surface); border-radius: 22px; padding: 40px 34px; box-shadow: var(--shadow-lg); border: 1px solid rgba(255,255,255,.6); }
+.login-card h1 { font-size: 23px; margin: 4px 0 6px; }
+.login-card p.sub { color: #64748b; font-size: 14px; margin-bottom: 22px; }
+.login-brand { display: flex; align-items: center; gap: 11px; margin-bottom: 20px; font-weight: 800; font-size: 18px; }
+.brand-mark { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 11px; background: var(--brand-grad); color: #fff; box-shadow: 0 10px 20px -8px rgba(14,165,233,.7); }
+
+/* cards / headings */
+.admin-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 24px; box-shadow: var(--shadow-sm); }
+.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
+.page-head h1 { font-size: 24px; margin: 0; letter-spacing: -.01em; }
+.page-head .desc { color: #64748b; font-size: 14px; margin-top: 5px; }
+.stat-row { display: grid; grid-template-columns: repeat(auto-fit,minmax(200px,1fr)); gap: 18px; margin-bottom: 28px; }
+.stat-box { position: relative; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px 22px; box-shadow: var(--shadow-sm); overflow: hidden; transition: transform .18s, box-shadow .18s; }
+.stat-box:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); }
+.stat-box b { display: block; font-size: 32px; font-weight: 900; color: var(--ink); line-height: 1.1; }
+.stat-box span { color: #64748b; font-size: 13px; }
+.stat-box .stat-ic { position: absolute; top: 16px; right: 16px; width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; color: #fff; background: var(--brand-grad); box-shadow: 0 8px 18px -8px rgba(14,165,233,.6); }
+.stat-box.c1 .stat-ic { background: linear-gradient(135deg,#0ea5e9,#38bdf8); }
+.stat-box.c2 .stat-ic { background: linear-gradient(135deg,#14b8a6,#2dd4bf); }
+.stat-box.c3 .stat-ic { background: linear-gradient(135deg,#8b5cf6,#a78bfa); }
+.stat-box.c4 .stat-ic { background: linear-gradient(135deg,#f59e0b,#fbbf24); }
+
+/* buttons */
+.btn-primary, .btn-soft, .btn-danger, .btn-ghost { display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 10px 18px; border-radius: 11px; font-weight: 700; font-size: 14px; cursor: pointer; border: 1px solid transparent; transition: transform .14s, box-shadow .2s, background .2s, filter .2s; }
+.btn-primary { background: var(--brand-grad); color: #fff; box-shadow: 0 12px 26px -14px rgba(14,165,233,.9); }
+.btn-primary:hover { transform: translateY(-2px); filter: brightness(1.05); }
+.btn-soft { background: #eef2ff; color: #4338ca; }
+.btn-soft:hover { background: #e0e7ff; }
+.btn-danger { background: #fee2e2; color: #dc2626; }
+.btn-danger:hover { background: #fecaca; }
+.btn-ghost { background: #f1f5f9; color: #334155; border-color: #e2e8f0; }
+.btn-ghost:hover { background: #e9eef5; border-color: #cbd5e1; }
+.btn-sm { padding: 6px 12px; font-size: 13px; border-radius: 9px; }
+
+/* tables */
+.admin-table { width: 100%; border-collapse: collapse; background: var(--surface); border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); box-shadow: var(--shadow-sm); }
+.admin-table th, .admin-table td { padding: 14px 16px; text-align: left; font-size: 14px; border-bottom: 1px solid #eef2f7; }
+.admin-table th { background: #f8fafc; color: var(--ink-soft); font-weight: 700; font-size: 12.5px; letter-spacing: .02em; text-transform: uppercase; }
+.admin-table tr:last-child td { border-bottom: none; }
+.admin-table tbody tr { transition: background .15s; }
+.admin-table tr:hover td { background: #f6f9fe; }
+.thum { width: 46px; height: 46px; border-radius: 10px; display: grid; place-items: center; color: #fff; font-size: 18px; }
+.tag-mini { font-size: 12px; padding: 2px 9px; border-radius: 999px; background: #e0f2fe; color: #0369a1; }
+.muted { color: var(--muted); }
+
+/* forms */
+.field { margin-bottom: 16px; }
+.field label { display: block; font-weight: 700; font-size: 13px; margin-bottom: 7px; color: #334155; }
+.field input, .field textarea, .field select { width: 100%; padding: 11px 13px; border: 1px solid #cbd5e1; border-radius: 10px; font-size: 14px; font-family: inherit; background: #fff; color: var(--ink); transition: border-color .2s, box-shadow .2s; }
+.field input:focus, .field textarea:focus, .field select:focus { border-color: var(--brand-1); outline: none; box-shadow: 0 0 0 3px rgba(14,165,233,.18); }
+.field textarea { min-height: 130px; resize: vertical; line-height: 1.7; }
+.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 18px; }
+.form-actions { display: flex; gap: 12px; margin-top: 8px; }
+.alert { padding: 12px 15px; border-radius: 11px; margin-bottom: 16px; font-size: 14px; border: 1px solid transparent; }
+.alert-err { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
+.alert-ok { background: #f0fdf4; color: #15803d; border-color: #bbf7d0; }
+
+/* role badge */
+.role-badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
+.role-super_admin { background: #fef3c7; color: #b45309; }
+.role-admin { background: #dbeafe; color: #1d4ed8; }
+.role-user { background: #dcfce7; color: #15803d; }
+.role-none { background: #e5e7eb; color: #374151; }
+.row-actions { display: flex; gap: 8px; }
+/* status badge (CRM 客户状态) */
+.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; line-height: 1.6; }
+.st-grey { background: #e5e7eb; color: #4b5563; }
+.st-blue { background: #dbeafe; color: #1d4ed8; }
+.st-purple { background: #ede9fe; color: #6d28d9; }
+.st-green { background: #dcfce7; color: #15803d; }
+.st-gold { background: #fef3c7; color: #b45309; }
+.st-red { background: #fee2e2; color: #b91c1c; }
+/* 仪表盘快捷操作 */
+.quick-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; margin: 4px 0; }
+.qa-card { display: flex; flex-direction: column; gap: 4px; padding: 18px; border-radius: 14px; background: #fff; border: 1px solid var(--line); text-decoration: none; color: #111827; transition: transform .2s, box-shadow .2s, border-color .2s; }
+.qa-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--brand-1); }
+.qa-ico { display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: 11px; margin-bottom: 8px; background: linear-gradient(135deg, rgba(14,165,233,.12), rgba(20,184,166,.12)); color: var(--brand-1); }
+.qa-t { font-weight: 700; font-size: 15px; }
+.qa-d { font-size: 12px; color: #6b7280; }
+
+.preview-box { border-radius: 12px; padding: 26px; color: #fff; margin-top: 14px; }
+
+@media (max-width: 860px) {
+ .admin-side { transform: translateX(-100%); transition: transform .3s; }
+ .admin-side.open { transform: none; }
+ .admin-body { margin-left: 0; }
+ .form-grid { grid-template-columns: 1fr; }
+}
+.menu-toggle { display: none; background: none; border: none; color: var(--ink-soft); cursor: pointer; padding: 6px; border-radius: 8px; align-items: center; }
+.menu-toggle:hover { background: #f1f5f9; }
+@media (max-width: 860px) { .menu-toggle { display: inline-flex; } }
+
+/* ===== 可视化拖拽构建器 ===== */
+.pb-workspace { display: flex; gap: 0; min-height: 74vh; border: 1px solid #e2e8f0; border-radius: 14px; overflow: hidden; background: #fff; }
+/* 旧版(admin/page_builder.php)兼容 */
+.pb-left, .pb-right { width: 248px; flex: 0 0 248px; background: #f8fafc; border-left: 1px solid #e2e8f0; border-right: 1px solid #e2e8f0; padding: 14px; overflow: auto; }
+.pb-box { margin-bottom: 18px; }
+.pb-box h4 { margin: 0 0 10px; font-size: 13px; color: #475569; letter-spacing: .04em; text-transform: uppercase; }
+.pb-upload { display: block; text-align: center; background: #0ea5e9; color: #fff; border-radius: 10px; padding: 10px; cursor: pointer; font-size: 14px; font-weight: 600; margin-bottom: 10px; }
+.pb-upload:hover { background: #0284c7; }
+.pb-add { display: block; width: 100%; margin-bottom: 8px; background: #fff; border: 1px dashed #94a3b8; color: #334155; border-radius: 10px; padding: 10px; cursor: pointer; font-size: 14px; }
+.pb-add:hover { border-color: #0ea5e9; color: #0ea5e9; }
+/* 新版三栏:图标栏 | 编辑画布 | 右栏标签页 */
+.pb-rail { width: 60px; flex: 0 0 60px; background: #0f172a; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 0; }
+.pb-rail-btn { width: 42px; height: 42px; border: none; border-radius: 10px; background: rgba(255,255,255,.08); color: #e2e8f0; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s, transform .15s; }
+.pb-rail-btn:hover { background: #0ea5e9; color: #fff; transform: translateY(-1px); }
+.pb-rail-btn:active { transform: scale(.95); }
+.pb-canvas-wrap { flex: 1; overflow: auto; background: #eef2f7; background-image: radial-gradient(#dbe3ec 1px, transparent 1px); background-size: 18px 18px; padding: 26px; display: flex; justify-content: center; align-items: flex-start; }
+.pb-stage { position: relative; width: 720px; min-height: 480px; background: #fff; box-shadow: 0 10px 40px rgba(15,23,42,.12); border-radius: 6px; }
+.pb-side { width: 312px; flex: 0 0 312px; background: #f8fafc; border-left: 1px solid #e2e8f0; display: flex; flex-direction: column; }
+.pb-tabs { display: flex; border-bottom: 1px solid #e2e8f0; background: #fff; }
+.pb-tab { flex: 1; border: none; background: none; padding: 12px 4px; font-size: 13px; color: #64748b; cursor: pointer; border-bottom: 2px solid transparent; }
+.pb-tab.active { color: #0ea5e9; border-bottom-color: #0ea5e9; font-weight: 600; }
+.pb-panes { flex: 1; overflow: auto; padding: 14px; }
+.pb-pane.hidden { display: none; }
+.pb-preview { position: relative; width: 720px; transform-origin: top left; }
+.pb-preview .pb-el { cursor: default; }
+.pb-preview .pb-handle { display: none !important; }
+.pb-preview .pb-el.sel { outline: none; }
+.pb-hint { font-size: 12px; color: #94a3b8; line-height: 1.8; }
+.pb-lib { display: flex; flex-wrap: wrap; gap: 8px; }
+.pb-lib-item { position: relative; }
+.pb-lib-item img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; border: 2px solid transparent; cursor: pointer; display: block; }
+.pb-lib-item img:hover { border-color: #0ea5e9; }
+.pb-lib-del { position: absolute; top: -6px; right: -6px; width: 20px; height: 20px; border-radius: 50%; border: none; background: #ef4444; color: #fff; font-size: 13px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
+.pb-lib-del:hover { background: #dc2626; }
+.pb-el { position: absolute; box-sizing: border-box; cursor: move; user-select: none; }
+.pb-text { width: 100%; min-height: 1.4em; white-space: pre-wrap; word-break: break-word; padding: 2px 4px; }
+.pb-img { max-width: 100%; width: auto; display: block; object-fit: cover; border-radius: 4px; }
+.pb-text[contenteditable=true] { user-select: text; cursor: text; outline: none; }
+.pb-el.sel { outline: 2px dashed #0ea5e9; outline-offset: 2px; }
+.pb-handle { position: absolute; right: -7px; bottom: -7px; width: 14px; height: 14px; background: #0ea5e9; border: 2px solid #fff; border-radius: 50%; cursor: nwse-resize; display: none; }
+.pb-el.sel .pb-handle { display: block; }
+.pb-empty { font-size: 13px; color: #94a3b8; }
+#pbPropsBody label { display: block; font-size: 12px; color: #64748b; margin: 10px 0 4px; }
+#pbPropsBody input[type=text], #pbPropsBody input[type=number], #pbPropsBody input[type=color], #pbPropsBody textarea, #pbPropsBody select { width: 100%; border: 1px solid #e2e8f0; border-radius: 8px; padding: 7px 9px; font-size: 13px; font-family: inherit; }
+#pbPropsBody textarea { min-height: 64px; resize: vertical; }
+.pb-del { margin-top: 14px; width: 100%; background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; border-radius: 8px; padding: 8px; cursor: pointer; font-size: 13px; }
+.pb-del:hover { background: #fee2e2; }
+.pb-align { display: flex; gap: 6px; margin: 2px 0 6px; }
+.pb-ab { flex: 1; border: 1px solid #e2e8f0; background: #fff; border-radius: 8px; padding: 8px 0; cursor: pointer; font-size: 13px; color: #334155; }
+.pb-ab:hover { border-color: #0ea5e9; }
+.pb-ab.active { background: #0ea5e9; border-color: #0ea5e9; color: #fff; font-weight: 600; }
+/* 新增元素类型(按钮/价格/规格)在编辑器中为静态预览,避免拦截拖拽 */
+.pb-static { pointer-events: none; user-select: none; }
+.pb-btn-prev { display: inline-block; padding: 10px 22px; border-radius: 10px; background: #0ea5e9; color: #fff; font-weight: 600; cursor: move; box-sizing: border-box; }
+.pb-btn-prev.pb-buy { background: #ef4444; }
+.pb-price-prev { font-size: 28px; font-weight: 700; }
+.pb-specs-prev { width: 100%; font-size: 13px; background: #fff; }
+.pb-specs-prev .sp-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e2e8f0; }
+@media (max-width: 980px) { .pb-left, .pb-right { width: 180px; flex-basis: 180px; } .pb-rail { width: 52px; flex-basis: 52px; } .pb-side { width: 260px; flex-basis: 260px; } }
+
+/* ===== 分系统(CRM/PSI)共享后台组件 ===== */
+.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px,1fr)); gap: 16px; margin-bottom: 24px; }
+.stat-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 16px; padding: 22px; box-shadow: 0 10px 30px -26px rgba(0,0,0,.5); }
+.stat-card .stat-num { font-size: 28px; font-weight: 900; color: #0ea5e9; line-height: 1.1; }
+.stat-card .stat-label { color: #64748b; font-size: 13px; margin-top: 8px; }
+
+.panel { background: #fff; border: 1px solid #e2e8f0; border-radius: 16px; padding: 22px; margin-bottom: 22px; box-shadow: 0 10px 30px -26px rgba(0,0,0,.5); }
+.panel h3 { margin: 0 0 16px; font-size: 17px; }
+
+.tbl { width: 100%; border-collapse: collapse; }
+.tbl th, .tbl td { padding: 12px 14px; text-align: left; font-size: 14px; border-bottom: 1px solid #eef2f7; }
+.tbl th { background: #f8fafc; color: #475569; font-weight: 700; }
+.tbl tr:last-child td { border-bottom: none; }
+.tbl tr:hover td { background: #f8fafc; }
+
+/* Excel 式冻结表头:列表表格在面板内可滚动,表头吸顶 */
+.panel:has(> .tbl) { max-height: calc(100vh - 220px); overflow: auto; }
+.tbl thead th { position: sticky; top: 0; z-index: 3; background: #f8fafc; }
+
+/* 用户管理:分系统页面权限勾选 */
+.chk-grid { display: flex; flex-wrap: wrap; gap: 10px 18px; margin-top: 6px; }
+.chk { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; font-size: 13px; color: #334155; }
+
+.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
+@media (max-width: 880px) { .two-col { grid-template-columns: 1fr; } }
+
+.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0 20px; }
+@media (max-width: 760px) { .form-grid { grid-template-columns: 1fr; } }
+.form-grid .field { min-width: 0; }
+
+.row-actions a { margin-right: 12px; color: #0ea5e9; font-weight: 700; font-size: 13px; }
+.row-actions a:last-child { color: #dc2626; }
+
+.tag-in { display: inline-block; padding: 3px 10px; border-radius: 999px; background: #dcfce7; color: #15803d; font-weight: 700; font-size: 13px; }
+.tag-out { display: inline-block; padding: 3px 10px; border-radius: 999px; background: #fee2e2; color: #dc2626; font-weight: 700; font-size: 13px; }
+
+.alert { padding: 12px 16px; border-radius: 10px; margin-bottom: 18px; font-size: 14px; font-weight: 600; }
+.alert-ok { background: #dcfce7; color: #15803d; }
+.alert-err { background: #fee2e2; color: #dc2626; }
+.alert-warn { background: #fffbeb; color: #b45309; border: 1px solid #fde68a; font-weight: 600; }
+
+/* 导航待升级徽标 */
+.nav-badge { margin-left: auto; background: #ef4444; color: #fff; font-size: 11px; font-weight: 800; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; box-shadow: 0 2px 6px -2px rgba(239,68,68,.7); }
+
+/* 通用小工具 */
+.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
+.num { text-align: right; white-space: nowrap; }
+.nowrap { white-space: nowrap; }
+.card-title { margin: 0 0 16px; font-size: 17px; }
+.head-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
+.btn-link { color: #64748b; font-weight: 600; font-size: 13px; }
+
+/* 数据库管理:表列表与浏览 */
+.db-stat { color: #64748b; font-size: 13px; margin-bottom: 14px; }
+.db-stat b { color: var(--ink); }
+.db-search { display: flex; gap: 10px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
+.db-search input { flex: 1; min-width: 220px; padding: 9px 12px; border: 1px solid #cbd5e1; border-radius: 10px; font-size: 14px; }
+.db-search input:focus { border-color: var(--brand-1); outline: none; box-shadow: 0 0 0 3px rgba(14,165,233,.18); }
+.tbl-data .cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.link-ok { color: #0ea5e9; font-weight: 700; font-size: 13px; margin-right: 12px; }
+.link-del { color: #dc2626; font-weight: 700; font-size: 13px; }
+.pager { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
+.pager-info { color: #64748b; font-size: 13px; }
+
+/* 信息表(系统设置) */
+.tbl-info th { width: 180px; text-align: left; color: #64748b; font-weight: 700; background: #f8fafc; }
+.tbl-info td { color: var(--ink); }
+
+/* 快捷入口卡片 */
+.shortcut-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; margin-top: 22px; }
+.shortcut-card { display: flex; flex-direction: column; gap: 6px; padding: 22px; border-radius: 16px; background: #fff; border: 1px solid var(--line); box-shadow: var(--shadow-sm); transition: transform .18s, box-shadow .2s, border-color .2s; }
+.shortcut-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--brand-1); }
+.shortcut-ic { color: var(--brand-1); }
+.shortcut-title { font-weight: 800; font-size: 16px; color: var(--ink); }
+.shortcut-desc { font-size: 13px; color: #64748b; }
+
+/* 数据库升级提示条 */
+.alert-upgrade { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 18px 20px; border-radius: 14px; background: linear-gradient(135deg, #fff7ed, #fffbeb); border: 1px solid #fdba74; margin-bottom: 18px; }
+.alert-upgrade-main { display: flex; align-items: center; gap: 14px; }
+.alert-upgrade-ic { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; background: linear-gradient(135deg,#f59e0b,#fbbf24); color: #fff; }
+.alert-upgrade b { font-size: 16px; color: #b45309; }
+
+/* 状态标签(数据库管理 / 升级) */
+.tag-blue { background: #dbeafe; color: #1d4ed8; }
+.tag-amber { background: #fef3c7; color: #b45309; }
+.tag-green { background: #dcfce7; color: #15803d; }
+.tag-gray { background: #e5e7eb; color: #4b5563; }
+
+/* 数据库编辑表单 */
+.db-form-card { max-width: 880px; }
+.form-row { margin-bottom: 16px; }
+.form-row label { display: block; }
+.form-label { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 13px; margin-bottom: 7px; color: #334155; }
+.form-type { font-style: normal; color: #94a3b8; font-weight: 500; font-size: 12px; }
+.form-row .input { width: 100%; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 10px; font-size: 14px; font-family: inherit; background: #fff; color: var(--ink); }
+.form-row .input:focus { border-color: var(--brand-1); outline: none; box-shadow: 0 0 0 3px rgba(14,165,233,.18); }
+.form-actions { display: flex; gap: 12px; margin-top: 10px; }
diff --git a/public/assets/css/site.css b/public/assets/css/site.css
new file mode 100644
index 0000000..b2ad292
--- /dev/null
+++ b/public/assets/css/site.css
@@ -0,0 +1,227 @@
+/* ===== 酷冰甲降温服 · 前台样式 ===== */
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+html{scroll-behavior:smooth}
+body{
+ font-family:var(--font-base);
+ background:var(--c-bg);
+ color:var(--c-text);
+ line-height:1.7;
+ -webkit-font-smoothing:antialiased;
+ transition:background .4s ease,color .4s ease;
+ overflow-x:hidden;
+}
+a{color:inherit;text-decoration:none}
+img{max-width:100%;display:block}
+.container{max-width:var(--container);margin:0 auto;padding:0 24px}
+.section{padding:88px 0}
+.eyebrow{color:var(--c-primary);font-weight:700;letter-spacing:.12em;text-transform:uppercase;font-size:13px;margin-bottom:12px}
+.section-title{font-size:clamp(26px,3.4vw,40px);font-weight:800;letter-spacing:-.01em;line-height:1.2}
+.section-sub{color:var(--c-muted);margin-top:14px;max-width:620px}
+.section-head{text-align:center;margin-bottom:54px}
+.section-head .section-sub{margin-left:auto;margin-right:auto}
+
+/* buttons */
+.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:13px 26px;border-radius:var(--radius);font-weight:700;font-size:15px;cursor:pointer;border:1px solid transparent;transition:transform .25s cubic-bezier(.16,1,.3,1),box-shadow .25s,background .25s,color .25s;will-change:transform}
+.btn-primary{background:linear-gradient(135deg,var(--c-primary),var(--c-secondary));color:#fff;box-shadow:0 10px 30px -10px var(--c-primary)}
+.btn-primary:hover{transform:translateY(-3px);box-shadow:0 18px 40px -12px var(--c-primary)}
+.btn-ghost{background:var(--c-surface);color:var(--c-text);border-color:var(--c-border)}
+.btn-ghost:hover{transform:translateY(-3px);border-color:var(--c-primary);color:var(--c-primary)}
+.btn-outline{background:transparent;color:var(--c-primary);border-color:var(--c-primary)}
+.btn-outline:hover{background:var(--c-primary);color:#fff}
+.magnetic{transition:transform .2s cubic-bezier(.16,1,.3,1)}
+
+/* header */
+.site-header{position:sticky;top:0;z-index:50;backdrop-filter:blur(18px);-webkit-backdrop-filter:blur(18px);background:var(--nav-bg);border-bottom:1px solid color-mix(in srgb,var(--c-border) 60%,transparent);transition:box-shadow .3s,background .3s}
+.site-header.scrolled{box-shadow:0 10px 30px -18px rgba(0,0,0,.35)}
+.nav-inner{display:flex;align-items:center;justify-content:space-between;height:72px;gap:20px}
+.brand{display:flex;align-items:center;gap:10px;font-weight:800;font-size:19px}
+.brand-logo{display:block;height:52px;width:auto}
+.brand-mark{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,var(--c-primary),var(--c-secondary));color:#fff;font-size:18px}
+.brand-name{letter-spacing:-.01em}
+.nav-links{display:flex;gap:6px}
+.nav-links a{padding:9px 15px;border-radius:10px;font-weight:600;color:var(--c-muted);transition:color .2s,background .2s}
+.nav-links a:hover,.nav-links a.active{color:var(--c-primary);background:color-mix(in srgb,var(--c-primary) 12%,transparent)}
+.nav-actions{display:flex;align-items:center;gap:12px}
+.theme-toggle{width:40px;height:40px;border-radius:50%;border:1px solid var(--c-border);background:var(--c-surface);cursor:pointer;font-size:17px;transition:transform .25s,background .25s}
+.theme-toggle:hover{transform:rotate(20deg) scale(1.08)}
+.nav-burger{display:none;background:none;border:none;font-size:24px;cursor:pointer;color:var(--c-text)}
+
+/* hero */
+.hero{position:relative;overflow:hidden;padding:96px 0 110px}
+.hero-bg{position:absolute;inset:0;background:
+ radial-gradient(60% 60% at 15% 10%,color-mix(in srgb,var(--c-primary) 30%,transparent),transparent 60%),
+ radial-gradient(50% 50% at 85% 20%,color-mix(in srgb,var(--c-secondary) 28%,transparent),transparent 60%),
+ radial-gradient(40% 40% at 70% 90%,color-mix(in srgb,var(--c-accent) 22%,transparent),transparent 60%);
+ filter:saturate(1.1);animation:floatbg 14s ease-in-out infinite alternate}
+@keyframes floatbg{from{transform:translate3d(-2%,-1%,0) scale(1)}to{transform:translate3d(2%,2%,0) scale(1.06)}}
+.hero .container{position:relative;display:grid;grid-template-columns:1.1fr .9fr;gap:40px;align-items:center}
+.hero-title{font-size:clamp(34px,5vw,60px);font-weight:900;line-height:1.08;letter-spacing:-.02em}
+.hero-title .hl{background:linear-gradient(120deg,var(--c-primary),var(--c-secondary));-webkit-background-clip:text;background-clip:text;color:transparent}
+.hero-sub{margin-top:22px;font-size:18px;color:var(--c-muted);max-width:520px}
+.hero-cta{margin-top:34px;display:flex;gap:14px;flex-wrap:wrap}
+.hero-visual{position:relative;aspect-ratio:1/1;display:grid;place-items:center}
+.hero-orb{width:78%;aspect-ratio:1/1;border-radius:50%;background:conic-gradient(from 0deg,var(--c-primary),var(--c-secondary),var(--c-accent),var(--c-primary));filter:blur(2px);animation:spin 18s linear infinite;opacity:.9}
+.hero-orb::after{content:"";position:absolute;inset:14%;border-radius:50%;background:var(--c-bg);box-shadow:inset 0 30px 60px -30px rgba(0,0,0,.4)}
+.hero-frost{position:absolute;font-size:120px;filter:drop-shadow(0 20px 40px rgba(0,0,0,.25))}
+@keyframes spin{to{transform:rotate(360deg)}}
+.hero-stats{position:relative;display:flex;gap:14px;flex-wrap:wrap;margin-top:46px;grid-column:1/-1;justify-content:center}
+.stat{background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius);padding:18px 26px;text-align:center;min-width:140px}
+.stat b{display:block;font-size:28px;font-weight:900;color:var(--c-primary);line-height:1}
+.stat span{color:var(--c-muted);font-size:13px}
+
+/* cards grids */
+.cat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:20px}
+.cat-card{background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius);padding:26px;transition:transform .3s cubic-bezier(.16,1,.3,1),box-shadow .3s,border-color .3s}
+.cat-card:hover{transform:translateY(-6px);border-color:var(--c-primary);box-shadow:0 24px 50px -28px var(--c-primary)}
+.cat-icon{font-size:34px;margin-bottom:12px}
+.cat-card h3{font-size:18px;margin-bottom:8px}
+.cat-card p{color:var(--c-muted);font-size:14px}
+
+.product-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:24px}
+.product-card{background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column;transition:transform .3s cubic-bezier(.16,1,.3,1),box-shadow .3s,border-color .3s}
+.product-card:hover{transform:translateY(-8px);box-shadow:0 30px 60px -30px var(--c-primary);border-color:color-mix(in srgb,var(--c-primary) 50%,var(--c-border))}
+.product-thumb{aspect-ratio:4/3;display:grid;place-items:center;color:#fff;font-size:40px;position:relative;overflow:hidden}
+.product-thumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
+.product-thumb::after{content:"";position:absolute;inset:0;background:radial-gradient(circle at 30% 20%,rgba(255,255,255,.35),transparent 50%)}
+.product-body{padding:20px;display:flex;flex-direction:column;gap:8px;flex:1}
+.product-name{font-size:17px;font-weight:800}
+.product-sum{color:var(--c-muted);font-size:14px;flex:1}
+.product-meta{display:flex;align-items:center;justify-content:space-between;margin-top:6px}
+.product-price{color:var(--c-primary);font-weight:900;font-size:18px}
+.product-price small{color:var(--c-muted);font-weight:500;font-size:12px}
+.product-tags{display:flex;gap:6px;flex-wrap:wrap}
+.product-link{display:flex;flex-direction:column;flex:1;color:inherit;text-decoration:none}
+.product-actions{display:flex;gap:10px;padding:0 18px 18px}
+.product-actions .btn{flex:1;padding:11px 14px;font-size:14px}
+.tag{font-size:12px;padding:3px 10px;border-radius:999px;background:color-mix(in srgb,var(--c-primary) 12%,transparent);color:var(--c-primary)}
+.more-link{display:inline-flex;align-items:center;gap:6px;color:var(--c-primary);font-weight:700;margin-top:34px}
+
+/* 产品详情:底部吸底购买栏 */
+.detail-sec{padding-bottom:104px}
+.buy-bar{position:fixed;left:0;right:0;bottom:0;z-index:60;background:color-mix(in srgb,var(--c-bg) 88%,transparent);border-top:1px solid var(--c-border);box-shadow:0 -10px 30px -12px rgba(0,0,0,.18);backdrop-filter:blur(14px) saturate(160%);-webkit-backdrop-filter:blur(14px) saturate(160%)}
+.buy-bar-inner{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 0}
+.buy-bar-info{display:flex;flex-direction:column;line-height:1.25;min-width:0}
+.buy-bar-name{font-weight:700;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.buy-bar-price{color:var(--c-primary);font-weight:900;font-size:18px}
+.buy-bar-price small{font-size:12px;color:var(--c-muted);font-weight:500}
+.buy-bar-actions{display:flex;gap:10px;flex-shrink:0}
+.buy-bar-actions .btn{padding:11px 20px}
+@media(max-width:560px){
+ .buy-bar-inner{gap:10px;padding:10px 0}
+ .buy-bar-name{font-size:13px}
+ .buy-bar-price{font-size:16px}
+ .buy-bar-actions .btn{padding:10px 14px;font-size:13px}
+ .detail-sec{padding-bottom:92px}
+}
+
+/* 订单查询结果卡片 */
+.order-card{margin-top:18px;padding:20px;border:1px solid var(--c-border);border-radius:var(--radius);background:var(--c-surface)}
+.order-card + .order-card{margin-top:16px}
+.ok{color:#16a34a;font-weight:700}
+.pend{color:#d97706;font-weight:700}
+
+.adv-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:22px}
+.adv-card{position:relative;padding:30px;border-radius:var(--radius);background:linear-gradient(160deg,color-mix(in srgb,var(--c-primary) 8%,var(--c-surface)),var(--c-surface));border:1px solid var(--c-border);overflow:hidden}
+.adv-card .num{font-size:46px;font-weight:900;color:color-mix(in srgb,var(--c-primary) 35%,transparent);line-height:1}
+.adv-card h3{margin:10px 0 8px;font-size:19px}
+.adv-card p{color:var(--c-muted);font-size:14px}
+
+.process{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:18px;counter-reset:s}
+.step{position:relative;padding:26px 22px;border-radius:var(--radius);background:var(--c-surface);border:1px solid var(--c-border)}
+.step::before{counter-increment:s;content:"0" counter(s);font-size:30px;font-weight:900;color:var(--c-primary)}
+.step h4{margin:8px 0 6px;font-size:16px}
+.step p{color:var(--c-muted);font-size:13px}
+
+.news-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:24px}
+.news-card{background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column;transition:transform .3s,box-shadow .3s}
+.news-card:hover{transform:translateY(-6px);box-shadow:0 26px 50px -30px var(--c-primary)}
+.news-thumb{aspect-ratio:16/9;display:grid;place-items:center;color:#fff;font-size:34px}
+.news-body{padding:20px;display:flex;flex-direction:column;gap:8px;flex:1}
+.news-date{color:var(--c-muted);font-size:13px}
+.news-title{font-size:17px;font-weight:800;line-height:1.4}
+.news-sum{color:var(--c-muted);font-size:14px;flex:1}
+
+.cta-banner{position:relative;overflow:hidden;border-radius:calc(var(--radius) * 1.4);padding:60px 50px;color:#fff;background:linear-gradient(120deg,var(--c-primary-600),var(--c-secondary));text-align:center}
+.cta-banner h2{font-size:clamp(24px,3vw,36px);font-weight:900}
+.cta-banner p{margin:14px auto 26px;max-width:560px;opacity:.95}
+.cta-banner .btn-primary{background:#fff;color:var(--c-primary-600)}
+
+/* footer */
+.site-footer{margin-top:40px;background:var(--c-surface);border-top:1px solid var(--c-border);padding-top:56px}
+.footer-grid{display:grid;grid-template-columns:1.6fr 1fr 1fr 1fr;gap:30px}
+.footer-grid h4{font-size:15px;margin-bottom:14px}
+.footer-grid a{display:block;color:var(--c-muted);padding:5px 0;transition:color .2s}
+.footer-grid a:hover{color:var(--c-primary)}
+.footer-slogan{color:var(--c-muted);margin:12px 0;font-size:14px}
+.footer-line{color:var(--c-muted);font-size:14px;margin:4px 0}
+.footer-bottom{display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px;padding:22px 24px;margin-top:40px;border-top:1px solid var(--c-border);color:var(--c-muted);font-size:13px}
+
+/* inner page hero */
+.page-hero{padding:70px 0 36px;text-align:center;background:radial-gradient(60% 80% at 50% 0%,color-mix(in srgb,var(--c-primary) 16%,transparent),transparent)}
+.page-hero h1{font-size:clamp(28px,4vw,44px);font-weight:900}
+.page-hero p{color:var(--c-muted);margin-top:12px}
+.breadcrumb{padding:18px 0;color:var(--c-muted);font-size:14px}
+.breadcrumb a:hover{color:var(--c-primary)}
+
+/* product detail */
+.detail-wrap{display:grid;grid-template-columns:1fr 1fr;gap:40px;align-items:start}
+.detail-thumb{aspect-ratio:4/3;border-radius:var(--radius);display:grid;place-items:center;color:#fff;font-size:64px}
+.detail-info h1{font-size:30px;font-weight:900;margin-bottom:10px}
+.detail-price{font-size:26px;font-weight:900;color:var(--c-primary);margin:14px 0}
+.detail-sum{color:var(--c-muted)}
+.detail-desc{margin-top:18px;white-space:pre-line;line-height:1.9}
+.spec-table{width:100%;border-collapse:collapse;margin-top:24px}
+.spec-table td{padding:12px 14px;border-bottom:1px solid var(--c-border);font-size:14px}
+.spec-table td:first-child{color:var(--c-muted);width:140px}
+.gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:14px}
+
+/* forms */
+.form-card{max-width:640px;margin:0 auto;background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius);padding:34px}
+.field{margin-bottom:18px}
+.field label{display:block;font-weight:700;margin-bottom:8px;font-size:14px}
+.field input,.field textarea,.field select{width:100%;padding:12px 14px;border-radius:12px;border:1px solid var(--c-border);background:var(--c-bg);color:var(--c-text);font-size:15px;font-family:inherit;transition:border-color .2s,box-shadow .2s}
+.field input:focus,.field textarea:focus{border-color:var(--c-primary);outline:none;box-shadow:0 0 0 4px color-mix(in srgb,var(--c-primary) 18%,transparent)}
+.field textarea{min-height:130px;resize:vertical}
+.form-note{background:color-mix(in srgb,var(--c-primary) 10%,var(--c-surface));border:1px solid color-mix(in srgb,var(--c-primary) 30%,var(--c-border));border-radius:12px;padding:14px 16px;color:var(--c-muted);font-size:14px;margin-bottom:20px}
+.alert{padding:12px 16px;border-radius:12px;margin-bottom:16px;font-size:14px}
+.alert-ok{background:color-mix(in srgb,#22c55e 14%,var(--c-surface));border:1px solid #22c55e66;color:#15803d}
+.alert-err{background:color-mix(in srgb,#ef4444 14%,var(--c-surface));border:1px solid #ef444466;color:#b91c1c}
+
+/* reveal on scroll */
+.reveal{opacity:0;transform:translateY(28px);transition:opacity .7s cubic-bezier(.16,1,.3,1),transform .7s cubic-bezier(.16,1,.3,1)}
+.reveal.in{opacity:1;transform:none}
+
+/* responsive */
+@media (max-width:900px){
+ .hero .container{grid-template-columns:1fr}
+ .hero-visual{max-width:360px;margin:0 auto}
+ .detail-wrap{grid-template-columns:1fr}
+ .footer-grid{grid-template-columns:1fr 1fr}
+}
+@media (max-width:720px){
+ .nav-links{position:fixed;inset:72px 0 auto 0;flex-direction:column;background:var(--c-bg);padding:16px 24px;gap:4px;border-bottom:1px solid var(--c-border);transform:translateY(-120%);transition:transform .3s;z-index:40}
+ .nav-links.open{transform:none}
+ .nav-burger{display:block}
+ .section{padding:60px 0}
+ .footer-grid{grid-template-columns:1fr}
+ .cta-banner{padding:42px 22px}
+}
+
+/* ===== 订单 / 支付 ===== */
+.pay-channels{display:flex;gap:14px;flex-wrap:wrap;margin-top:8px}
+.pay-channel{flex:1;min-width:140px;padding:22px;border-radius:var(--radius);border:1.5px solid var(--c-border);background:var(--c-surface);font-size:17px;font-weight:800;cursor:pointer;transition:transform .2s,border-color .2s,box-shadow .2s,color .2s}
+.pay-channel:hover{transform:translateY(-3px);border-color:var(--c-primary);color:var(--c-primary);box-shadow:0 18px 40px -22px var(--c-primary)}
+.pay-qr{margin-top:16px;padding:18px;border-radius:14px;background:#fff;color:#0f172a;font-size:13px;word-break:break-all;border:1px solid var(--c-border);line-height:1.7}
+.pay-demo-box{margin-top:18px;padding:30px 20px;border-radius:var(--radius);text-align:center;background:linear-gradient(160deg,color-mix(in srgb,var(--c-primary) 10%,var(--c-surface)),var(--c-surface));border:1px solid var(--c-border)}
+.pay-demo-icon{font-size:48px;margin-bottom:8px}
+.order-sum{margin-bottom:6px}
+
+/* ===== FAQ 折叠(首页/产品页)===== */
+.faq-list{display:flex;flex-direction:column;gap:12px;margin-top:8px}
+.faq-item{border:1.5px solid var(--c-border);border-radius:var(--radius);background:var(--c-surface);overflow:hidden;transition:border-color .2s,box-shadow .2s}
+.faq-item[open]{border-color:var(--c-primary);box-shadow:0 16px 40px -24px var(--c-primary)}
+.faq-item summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:18px 20px;cursor:pointer;font-size:17px;font-weight:700;list-style:none}
+.faq-item summary::-webkit-details-marker{display:none}
+.faq-item .faq-ico{flex:none;width:26px;height:26px;display:grid;place-items:center;border-radius:50%;background:color-mix(in srgb,var(--c-primary) 14%,transparent);color:var(--c-primary);font-size:20px;line-height:1;transition:transform .2s}
+.faq-item[open] .faq-ico{transform:rotate(45deg)}
+.faq-a{padding:0 20px 20px;color:var(--c-muted);line-height:1.85;font-size:15px}
diff --git a/public/assets/css/subsys.css b/public/assets/css/subsys.css
new file mode 100644
index 0000000..a6646b1
--- /dev/null
+++ b/public/assets/css/subsys.css
@@ -0,0 +1,305 @@
+/* ============================================================
+ 分系统框架样式(CRM / PSI)—— 渐变风格
+ 仅作用于 body.subsys,不影响 admin 后台
+ ============================================================ */
+.admin.subsys {
+ --brand-1: #0ea5e9;
+ --brand-2: #14b8a6;
+ --brand-3: #6366f1;
+ --ss-side-1: #0f2a44;
+ --ss-side-2: #134e6f;
+ --ss-side-3: #0e7490;
+ --ink: #0f2a44;
+ --muted: #5b7187;
+ display: flex;
+ min-height: 100vh;
+ background:
+ radial-gradient(1200px 600px at 100% -10%, rgba(20,184,166,.10), transparent 60%),
+ radial-gradient(1000px 500px at -10% 110%, rgba(14,165,233,.12), transparent 55%),
+ linear-gradient(135deg, #eef4ff 0%, #f6fbff 45%, #eafaf6 100%);
+ color: var(--ink);
+}
+
+/* ---------- 侧边栏 ---------- */
+.ss-side {
+ width: 246px;
+ flex: 0 0 246px;
+ align-self: stretch;
+ display: flex;
+ flex-direction: column;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ background: linear-gradient(180deg, var(--ss-side-1) 0%, var(--ss-side-2) 55%, var(--ss-side-3) 100%);
+ color: #dbeafe;
+ box-shadow: 6px 0 28px rgba(15,42,68,.22);
+ z-index: 30;
+}
+.ss-brand {
+ display: flex; align-items: center; gap: 12px;
+ padding: 20px 18px;
+ color: #fff; text-decoration: none;
+ border-bottom: 1px solid rgba(255,255,255,.10);
+}
+.ss-brand-ic {
+ display: inline-flex; width: 40px; height: 40px; flex: 0 0 40px;
+ align-items: center; justify-content: center; border-radius: 12px;
+ background: linear-gradient(135deg, var(--brand-1), var(--brand-2));
+ color: #fff; box-shadow: 0 6px 16px rgba(14,165,233,.35);
+}
+.ss-brand-text { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }
+.ss-brand-text b { font-size: 16px; font-weight: 700; }
+.ss-brand-text small { font-size: 11px; color: #93c5d9; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.ss-nav { flex: 1; padding: 14px 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
+.ss-nav-item {
+ display: flex; align-items: center; gap: 12px;
+ padding: 11px 14px; border-radius: 12px;
+ color: #cfe3f3; text-decoration: none; font-size: 14px; font-weight: 500;
+ transition: background .18s ease, color .18s ease, transform .18s ease;
+}
+.ss-nav-item:hover { background: rgba(255,255,255,.10); color: #fff; transform: translateX(2px); }
+.ss-nav-item.active {
+ color: #fff;
+ background: linear-gradient(135deg, rgba(14,165,233,.95), rgba(20,184,166,.95));
+ box-shadow: 0 8px 18px rgba(14,165,233,.35);
+}
+.ss-ic { display: inline-flex; width: 22px; height: 22px; flex: 0 0 22px; align-items: center; justify-content: center; }
+.ss-label { white-space: nowrap; }
+
+.ss-side-foot { padding: 14px 16px; border-top: 1px solid rgba(255,255,255,.10); }
+.ss-foot-link { display: flex; align-items: center; gap: 8px; color: #93c5d9; text-decoration: none; font-size: 13px; }
+.ss-foot-link:hover { color: #fff; }
+
+/* ---------- 主体 ---------- */
+.ss-body { flex: 1; display: flex; flex-direction: column; min-width: 0; }
+.ss-top {
+ position: sticky; top: 0; z-index: 20;
+ display: flex; align-items: center; justify-content: space-between;
+ gap: 16px; padding: 14px 24px;
+ background: linear-gradient(135deg, rgba(255,255,255,.92), rgba(240,248,255,.92));
+ backdrop-filter: blur(10px);
+ border-bottom: 1px solid rgba(15,42,68,.08);
+ box-shadow: 0 4px 18px rgba(15,42,68,.06);
+}
+.ss-top-left { display: flex; align-items: center; gap: 12px; }
+.ss-crumb { font-weight: 700; font-size: 15px; color: var(--ink); }
+.menu-toggle { display: none; align-items: center; justify-content: center; width: 40px; height: 40px; border: 1px solid rgba(15,42,68,.12); border-radius: 10px; background: #fff; color: var(--ink); cursor: pointer; }
+.ss-user { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.ss-user-name { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; color: var(--ink); }
+.ss-avatar { display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 50%; background: linear-gradient(135deg, var(--brand-1), var(--brand-2)); color: #fff; }
+
+/* ---------- 内容区 ---------- */
+.ss-content { padding: 26px 28px 40px; }
+body.subsys .page-head h1 { font-size: 22px; margin: 0 0 4px; }
+body.subsys .page-head .sub { color: var(--muted); margin: 0 0 18px; }
+
+/* 统计卡 */
+body.subsys .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 22px; }
+body.subsys .stat-card {
+ position: relative; overflow: hidden;
+ padding: 20px 22px; border-radius: 16px; background: #fff;
+ border: 1px solid rgba(15,42,68,.07);
+ box-shadow: 0 10px 26px rgba(15,42,68,.07);
+}
+body.subsys .stat-card::before {
+ content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px;
+ background: linear-gradient(180deg, var(--brand-1), var(--brand-2));
+}
+body.subsys .stat-card .stat-num { font-size: 28px; font-weight: 800; background: linear-gradient(135deg, var(--brand-1), var(--brand-2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
+body.subsys .stat-card .stat-label { color: var(--muted); font-size: 13px; margin-top: 4px; }
+
+/* 快捷入口 */
+body.subsys .quick-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-bottom: 22px; }
+body.subsys .qa-card {
+ display: flex; flex-direction: column; gap: 2px; text-decoration: none;
+ padding: 16px 18px; border-radius: 14px; background: #fff;
+ border: 1px solid rgba(15,42,68,.07);
+ box-shadow: 0 8px 20px rgba(15,42,68,.06);
+ transition: transform .18s ease, box-shadow .18s ease;
+}
+body.subsys .qa-card:hover { transform: translateY(-3px); box-shadow: 0 14px 30px rgba(14,165,233,.18); }
+body.subsys .qa-ico { font-size: 20px; }
+body.subsys .qa-t { font-weight: 700; color: var(--ink); }
+body.subsys .qa-d { font-size: 12px; color: var(--muted); }
+
+/* 面板 */
+body.subsys .panel {
+ background: #fff; border-radius: 16px; padding: 18px 20px; margin-bottom: 20px;
+ border: 1px solid rgba(15,42,68,.07); box-shadow: 0 10px 26px rgba(15,42,68,.07);
+}
+body.subsys .panel h3 { margin: 0 0 12px; font-size: 16px; }
+body.subsys .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
+@media (max-width: 900px) { body.subsys .two-col { grid-template-columns: 1fr; } }
+
+/* ============ 渐变表格(行 / 列) ============ */
+body.subsys .tbl {
+ width: 100%; border-collapse: separate; border-spacing: 0;
+ border: 0; border-radius: 14px; overflow: hidden;
+ box-shadow: 0 10px 30px rgba(15,42,68,.10);
+ /* 列渐变:整表左→右的轻微底色过渡 */
+ background: linear-gradient(100deg, #eef6ff 0%, #ffffff 45%, #ecfbf6 100%);
+}
+body.subsys .tbl thead th {
+ background: linear-gradient(135deg, var(--brand-1), var(--brand-2));
+ color: #fff; font-weight: 600; text-align: left; padding: 12px 14px;
+ border: 0; white-space: nowrap;
+}
+body.subsys .tbl tbody td { padding: 11px 14px; border-top: 1px solid rgba(15,42,68,.06); color: var(--ink); }
+/* 行渐变:斑马行 + 悬停行 */
+body.subsys .tbl tbody tr:nth-child(odd) td { background: linear-gradient(90deg, rgba(14,165,233,.07), rgba(20,184,166,.03)); }
+body.subsys .tbl tbody tr:nth-child(even) td { background: rgba(255,255,255,.55); }
+body.subsys .tbl tbody tr:hover td { background: linear-gradient(90deg, rgba(14,165,233,.18), rgba(20,184,166,.10)); }
+body.subsys .tbl tbody tr:first-child td { border-top: 0; }
+
+/* ---------- 按钮 / 徽标 / 提示 ---------- */
+body.subsys .btn-soft {
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 7px 12px; border-radius: 10px; font-size: 13px; font-weight: 600;
+ text-decoration: none; color: var(--ink);
+ background: #fff; border: 1px solid rgba(15,42,68,.12);
+ transition: background .15s ease, transform .15s ease;
+}
+body.subsys .btn-soft:hover { background: linear-gradient(135deg, rgba(14,165,233,.12), rgba(20,184,166,.12)); transform: translateY(-1px); }
+body.subsys .btn-soft.btn-sm { padding: 6px 10px; font-size: 12px; }
+body.subsys .btn {
+ display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; border-radius: 10px;
+ background: linear-gradient(135deg, var(--brand-1), var(--brand-2)); color: #fff;
+ text-decoration: none; font-weight: 600; border: 0; cursor: pointer;
+ box-shadow: 0 8px 18px rgba(14,165,233,.28);
+}
+body.subsys .btn:hover { filter: brightness(1.05); }
+body.subsys .btn-sm { padding: 6px 11px; font-size: 12px; }
+body.subsys .btn-ok {
+ display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; border-radius: 10px;
+ background: linear-gradient(135deg, #10b981, #14b8a6); color: #fff; font-weight: 600; border: 0; cursor: pointer;
+ box-shadow: 0 8px 18px rgba(16,185,129,.28);
+}
+body.subsys .btn-ok:hover { filter: brightness(1.05); }
+body.subsys .row-actions a { color: var(--brand-1); text-decoration: none; font-weight: 600; margin-right: 10px; }
+
+/* 看板图标 + 报表卡片主体 */
+body.subsys .stat-ic { width: 38px; height: 38px; border-radius: 10px; display: inline-flex; align-items: center; justify-content: center;
+ background: linear-gradient(135deg, rgba(14,165,233,.16), rgba(20,184,166,.12)); color: var(--brand-1); margin-bottom: 8px; }
+body.subsys .stat-main { display: flex; flex-direction: column; }
+body.subsys .stat-main .stat-num { font-size: 20px; font-weight: 800; color: var(--ink); margin: 2px 0; }
+body.subsys .stat-sub { color: var(--muted); font-size: 13px; }
+body.subsys .stat-card { text-decoration: none; color: inherit; }
+
+/* 状态小标签配色 */
+body.subsys .tag-mini.tag-gray { background: rgba(100,116,139,.16); color: #475569; }
+body.subsys .tag-mini.tag-amber { background: rgba(245,158,11,.16); color: #b45309; }
+body.subsys .tag-mini.tag-green { background: rgba(16,185,129,.16); color: #047857; }
+body.subsys .tag-mini.tag-red { background: rgba(239,68,68,.16); color: #b91c1c; }
+
+.role-badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; margin-left: 6px; }
+.role-super_admin, .role-admin { background: linear-gradient(135deg, var(--brand-1), var(--brand-2)); color: #fff; }
+.role-user, .role-none { background: rgba(15,42,68,.08); color: var(--muted); }
+
+body.subsys .alert { padding: 12px 16px; border-radius: 10px; margin-bottom: 16px; font-weight: 500; }
+body.subsys .alert-ok { background: linear-gradient(135deg, rgba(16,185,129,.15), rgba(20,184,166,.12)); color: #047857; }
+body.subsys .alert-err { background: linear-gradient(135deg, rgba(239,68,68,.15), rgba(244,114,114,.12)); color: #b91c1c; }
+body.subsys .muted { color: var(--muted); }
+
+/* ---------- 响应式:侧栏抽屉 ---------- */
+@media (max-width: 900px) {
+ .admin.subsys { display: block; }
+ .ss-side { position: fixed; left: 0; top: 0; transform: translateX(-100%); transition: transform .25s ease; }
+ body.nav-open .ss-side { transform: translateX(0); }
+ .menu-toggle { display: inline-flex; }
+ .ss-body { display: block; }
+ .ss-content { padding: 18px 16px 36px; }
+}
+
+/* ---------- 用户管理页面 ---------- */
+body.subsys .panel-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
+body.subsys .panel-bar h3 { margin: 0; }
+body.subsys .table-wrap { overflow-x: auto; }
+body.subsys .tbl .perm-cell { line-height: 1.9; }
+body.subsys .perm-chip {
+ display: inline-block; margin: 0 4px 2px 0; padding: 2px 9px; border-radius: 999px; font-size: 12px;
+ background: rgba(14,165,233,.12); color: #0e7490; border: 1px solid rgba(14,165,233,.20);
+}
+body.subsys .st { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
+body.subsys .st-on { background: rgba(16,185,129,.14); color: #047857; }
+body.subsys .st-off { background: rgba(100,116,139,.14); color: #475569; }
+body.subsys .inline-form { display: inline; }
+body.subsys .link-danger { background: none; border: 0; padding: 0; color: #dc2626; font-weight: 600; cursor: pointer; font-size: 14px; }
+body.subsys .link-danger:hover { text-decoration: underline; }
+body.subsys .link-ok { background: none; border: 0; padding: 0; color: #047857; font-weight: 600; cursor: pointer; font-size: 14px; }
+body.subsys .link-ok:hover { text-decoration: underline; }
+body.subsys .btn-ghost { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 10px; border: 1px solid rgba(15,42,68,.16); background: #fff; color: var(--ink); font-weight: 600; font-size: 14px; cursor: pointer; }
+body.subsys .btn-ghost:hover { border-color: rgba(15,42,68,.32); }
+
+/* ---------- 表单 ---------- */
+body.subsys .form-card { max-width: 760px; }
+body.subsys .form-row { margin-bottom: 16px; display: flex; flex-direction: column; gap: 6px; }
+body.subsys .form-row > label { font-weight: 600; font-size: 13px; color: var(--ink); }
+body.subsys .form-row input[type="text"],
+body.subsys .form-row input[type="password"],
+body.subsys .form-row select,
+body.subsys .form-row textarea {
+ padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(15,42,68,.14); background: #fff; color: var(--ink); font-size: 14px;
+}
+body.subsys .form-row input[readonly] { background: #f1f5f9; color: var(--muted); }
+body.subsys .form-row small { color: var(--muted); font-size: 12px; }
+body.subsys .req { color: #dc2626; }
+body.subsys .radio-row { display: flex; flex-wrap: wrap; gap: 18px; }
+body.subsys .radio, body.subsys .check { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; color: var(--ink); cursor: pointer; }
+body.subsys .perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px 14px; padding: 10px 12px; border-radius: 10px; background: rgba(15,42,68,.04); border: 1px solid rgba(15,42,68,.08); }
+body.subsys .perm-grid .check { font-weight: 500; }
+body.subsys .form-actions { display: flex; gap: 12px; align-items: center; margin-top: 6px; }
+
+/* ---------- 顶栏铃铛 + 未读徽标 ---------- */
+body.subsys .ss-bell {
+ position: relative; display: inline-flex; align-items: center; justify-content: center;
+ width: 38px; height: 38px; border-radius: 10px; text-decoration: none; font-size: 18px;
+ background: rgba(15,42,68,.05); color: var(--ink); margin-left: 10px;
+}
+body.subsys .ss-bell:hover { background: rgba(14,165,233,.14); }
+body.subsys .ss-badge {
+ position: absolute; top: -5px; right: -5px; min-width: 18px; height: 18px; padding: 0 5px;
+ border-radius: 999px; background: linear-gradient(135deg, #ef4444, #dc2626); color: #fff;
+ font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; box-shadow: 0 2px 6px rgba(220,38,38,.45);
+}
+
+/* ---------- 页面头 ---------- */
+body.subsys .page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
+body.subsys .page-head h2 { margin: 0; font-size: 20px; }
+body.subsys .page-head .muted { margin: 4px 0 0; font-size: 13px; }
+body.subsys .head-actions { display: flex; gap: 10px; align-items: center; flex-shrink: 0; }
+body.subsys .btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; border-radius: 10px;
+ background: linear-gradient(135deg, var(--brand-1), var(--brand-2)); color: #fff; text-decoration: none; font-weight: 600; border: 0; cursor: pointer;
+ box-shadow: 0 8px 18px rgba(14,165,233,.28); }
+body.subsys .btn-primary:hover { filter: brightness(1.05); }
+body.subsys .empty-box { padding: 40px; text-align: center; color: var(--muted); border: 1px dashed rgba(15,42,68,.18); border-radius: 14px; background: rgba(15,42,68,.03); }
+
+/* ---------- 紧急事件列表 ---------- */
+body.subsys .ev-list { display: flex; flex-direction: column; gap: 12px; }
+body.subsys .ev-card { display: flex; gap: 14px; background: #fff; border: 1px solid rgba(15,42,68,.08); border-radius: 14px; padding: 14px 16px;
+ box-shadow: 0 8px 22px rgba(15,42,68,.06); border-left: 4px solid #ef4444; }
+body.subsys .ev-card.read { border-left-color: rgba(15,42,68,.12); opacity: .72; }
+body.subsys .ev-left { flex-shrink: 0; padding-top: 4px; }
+body.subsys .ev-dot { display: block; width: 10px; height: 10px; border-radius: 50%; background: #ef4444; box-shadow: 0 0 0 4px rgba(239,68,68,.18); }
+body.subsys .ev-card.read .ev-dot { background: #94a3b8; box-shadow: none; }
+body.subsys .ev-main { flex: 1; min-width: 0; }
+body.subsys .ev-top { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; flex-wrap: wrap; }
+body.subsys .ev-tag { font-size: 12px; font-weight: 700; padding: 2px 9px; border-radius: 999px; background: rgba(239,68,68,.14); color: #b91c1c; }
+body.subsys .ev-new { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 999px; background: #ef4444; color: #fff; }
+body.subsys .ev-time { font-size: 12px; color: var(--muted); margin-left: auto; }
+body.subsys .ev-title { font-size: 15px; font-weight: 700; color: var(--ink); margin-bottom: 4px; }
+body.subsys .ev-body { font-size: 13px; color: #334155; white-space: pre-wrap; line-height: 1.7; }
+body.subsys .ev-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
+body.subsys .ev-channels { display: flex; gap: 6px; }
+body.subsys .ch { font-size: 11px; padding: 2px 8px; border-radius: 999px; font-weight: 600; }
+body.subsys .ch-inapp { background: rgba(14,165,233,.14); color: #0e7490; }
+body.subsys .ch-email { background: rgba(16,185,129,.14); color: #047857; }
+body.subsys .ch-wechat { background: rgba(245,158,11,.16); color: #b45309; }
+body.subsys .ev-ops { display: flex; align-items: center; gap: 12px; }
+
+/* ---------- 通知设置表单 ---------- */
+body.subsys .notify-form .form-section { border: 1px solid rgba(15,42,68,.08); border-radius: 14px; padding: 16px 18px; margin-bottom: 16px; background: rgba(15,42,68,.02); }
+body.subsys .notify-form fieldset.form-section { border: 1px solid rgba(15,42,68,.12); }
+body.subsys .notify-form legend { font-weight: 700; color: var(--ink); padding: 0 8px; font-size: 14px; }
+body.subsys .notify-form .form-section > small { display: block; color: var(--muted); font-size: 12px; margin-top: 4px; }
+body.subsys .notify-form .check { margin-bottom: 8px; }
diff --git a/public/assets/css/theme.css b/public/assets/css/theme.css
new file mode 100644
index 0000000..d9963d3
--- /dev/null
+++ b/public/assets/css/theme.css
@@ -0,0 +1,19 @@
+:root{
+ --c-primary:#0ea5e9;
+ --c-primary-600:#0284c7;
+ --c-secondary:#14b8a6;
+ --c-accent:#f59e0b;
+ --c-bg:#ffffff;
+ --c-surface:#f8fafc;
+ --c-text:#0f172a;
+ --c-muted:#64748b;
+ --c-border:#e2e8f0;
+ --nav-bg:rgba(255,255,255,0.72);
+ --font-base:'Noto Sans SC', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+ --radius:16px;
+ --container:1200px;
+}
+[data-theme="dark"]{
+ --c-bg:#0b1120;--c-surface:#111827;--c-text:#e5e7eb;--c-muted:#94a3b8;--c-border:#1f2937;--nav-bg:rgba(11,17,32,0.72);
+}
+
diff --git a/public/assets/img/apple-touch-icon.png b/public/assets/img/apple-touch-icon.png
new file mode 100644
index 0000000..3d8c809
Binary files /dev/null and b/public/assets/img/apple-touch-icon.png differ
diff --git a/public/assets/img/favicon-16.png b/public/assets/img/favicon-16.png
new file mode 100644
index 0000000..5f7e76e
Binary files /dev/null and b/public/assets/img/favicon-16.png differ
diff --git a/public/assets/img/favicon-32.png b/public/assets/img/favicon-32.png
new file mode 100644
index 0000000..80ed6fe
Binary files /dev/null and b/public/assets/img/favicon-32.png differ
diff --git a/public/assets/img/logo.png b/public/assets/img/logo.png
new file mode 100644
index 0000000..62a253e
Binary files /dev/null and b/public/assets/img/logo.png differ
diff --git a/public/assets/img/og-default.png b/public/assets/img/og-default.png
new file mode 100644
index 0000000..dad79d9
Binary files /dev/null and b/public/assets/img/og-default.png differ
diff --git a/public/assets/js/admin.js b/public/assets/js/admin.js
new file mode 100644
index 0000000..004ec44
--- /dev/null
+++ b/public/assets/js/admin.js
@@ -0,0 +1,65 @@
+/* 后台交互 */
+(function () {
+ var mt = document.querySelector('.menu-toggle');
+ if (mt) mt.addEventListener('click', function () {
+ var s = document.querySelector('.admin-side'); if (s) s.classList.toggle('open');
+ });
+
+ // 分系统(CRM/PSI)侧栏抽屉
+ var mt2 = document.querySelector('.subsys .menu-toggle');
+ if (mt2) mt2.addEventListener('click', function () {
+ document.body.classList.toggle('nav-open');
+ });
+
+ function val(k) { var el = document.getElementById('f_' + k); return el ? el.value : ''; }
+ function updatePreview() {
+ var pb = document.getElementById('themePreview'); if (!pb) return;
+ pb.style.background = 'linear-gradient(135deg,' + val('primary') + ',' + val('secondary') + ')';
+ pb.style.color = '#fff';
+ pb.querySelector('.pv-title').textContent = '降温服 · 风格预览';
+ pb.querySelector('.pv-sub').textContent = '主色 ' + val('primary') + ' / 辅色 ' + val('secondary');
+ }
+
+ var presetSel = document.getElementById('preset');
+ var presets = window.__presets__ || null;
+ if (presetSel && presets) {
+ presetSel.addEventListener('change', function () {
+ var p = presets[this.value]; if (!p) return;
+ ['primary', 'primary_600', 'secondary', 'accent', 'bg', 'surface', 'text', 'muted', 'border', 'nav_bg']
+ .forEach(function (k) {
+ var el = document.getElementById('f_' + k);
+ if (el && p.vars[k] != null) el.value = p.vars[k];
+ });
+ updatePreview();
+ });
+ }
+ ['primary', 'secondary', 'accent', 'bg', 'surface', 'text', 'muted', 'border', 'nav_bg']
+ .forEach(function (k) {
+ var el = document.getElementById('f_' + k);
+ if (el) el.addEventListener('input', updatePreview);
+ });
+ updatePreview();
+
+ // 确认删除
+ document.querySelectorAll('form.del-form').forEach(function (f) {
+ f.addEventListener('submit', function (e) {
+ if (!confirm('确定要删除吗?此操作不可恢复。')) e.preventDefault();
+ });
+ });
+})();
+
+// 通用删除/操作确认:用 data-confirm 属性替代内联 onclick/onsubmit 确认,
+// 以配合严格 CSP(script-src 已移除 'unsafe-inline')。
+document.addEventListener('submit', function (e) {
+ var el = e.target;
+ if (el && el.hasAttribute('data-confirm') && !window.confirm(el.getAttribute('data-confirm'))) {
+ e.preventDefault();
+ }
+});
+document.addEventListener('click', function (e) {
+ var el = e.target && e.target.closest ? e.target.closest('[data-confirm]') : null;
+ if (el && !window.confirm(el.getAttribute('data-confirm'))) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+});
diff --git a/public/assets/js/fixed-editor.js b/public/assets/js/fixed-editor.js
new file mode 100644
index 0000000..bc925c0
--- /dev/null
+++ b/public/assets/js/fixed-editor.js
@@ -0,0 +1,88 @@
+/* 固定版面富文本编辑器(依赖 contenteditable + execCommand,无外部依赖)
+ * - 工具栏:加粗/斜体/标题/列表/引用/链接/图片
+ * - 图片:从素材库选择或上传(复用 admin/media 接口)
+ * - 保存前将富文本 HTML 同步到隐藏 textarea(name=content)
+ */
+(function () {
+ 'use strict';
+ var ed = document.getElementById('fxEditor');
+ var ta = document.getElementById('fxContent');
+ var form = document.getElementById('fxForm');
+ if (!ed || !ta || !form) return;
+
+ function sync() { ta.value = ed.innerHTML; }
+ ed.addEventListener('input', sync);
+ form.addEventListener('submit', function () { sync(); });
+
+ // 工具栏命令
+ document.querySelectorAll('.fx-toolbar button[data-cmd]').forEach(function (b) {
+ b.addEventListener('mousedown', function (e) { e.preventDefault(); }); // 防止编辑器失焦
+ b.addEventListener('click', function () {
+ var cmd = b.getAttribute('data-cmd');
+ var val = b.getAttribute('data-val') || null;
+ ed.focus();
+ document.execCommand(cmd, false, val);
+ sync();
+ });
+ });
+
+ // 链接
+ var linkBtn = document.getElementById('fxLink');
+ if (linkBtn) linkBtn.addEventListener('click', function () {
+ var url = prompt('链接地址(如 /contact 或 https://...)');
+ if (url) { ed.focus(); document.execCommand('createLink', false, url); sync(); }
+ });
+
+ // 图片弹层
+ var modal = document.getElementById('fxModal');
+ var imgBtn = document.getElementById('fxImg');
+ var closeBtn = document.getElementById('fxModalClose');
+ if (imgBtn) imgBtn.addEventListener('click', openModal);
+ if (closeBtn) closeBtn.addEventListener('click', function () { modal.hidden = true; });
+ if (modal) modal.addEventListener('click', function (e) { if (e.target === modal) modal.hidden = true; });
+
+ function insertImg(url) {
+ ed.focus();
+ document.execCommand('insertHTML', false,
+ '
');
+ sync();
+ modal.hidden = true;
+ }
+
+ function openModal() {
+ modal.hidden = false;
+ var lib = document.getElementById('fxLib');
+ lib.innerHTML = '加载中…';
+ fetch(window.__PB_MEDIA__)
+ .then(function (r) { return r.json(); })
+ .then(function (j) {
+ lib.innerHTML = '';
+ var items = (j && j.items) || [];
+ if (!items.length) { lib.innerHTML = '暂无素材,先上传'; return; }
+ items.forEach(function (it) {
+ var img = document.createElement('img');
+ img.src = it.url; img.title = it.name;
+ img.addEventListener('click', function () { insertImg(it.url); });
+ lib.appendChild(img);
+ });
+ })
+ .catch(function () { lib.innerHTML = '加载失败'; });
+ }
+
+ // 上传图片
+ var up = document.getElementById('fxUp');
+ if (up) up.addEventListener('change', function () {
+ var file = this.files && this.files[0];
+ if (!file) return;
+ var fd = new FormData();
+ fd.append('file', file);
+ fetch(window.__PB_UPLOAD__, { method: 'POST', body: fd, headers: { 'X-CSRF-TOKEN': window.__PB_CSRF__ } })
+ .then(function (r) { return r.json(); })
+ .then(function (j) {
+ if (j && j.ok) { insertImg(j.url); openModal(); }
+ else alert((j && j.msg) || '上传失败');
+ })
+ .catch(function () { alert('上传失败,请重试'); });
+ this.value = '';
+ });
+})();
diff --git a/public/assets/js/main.js b/public/assets/js/main.js
new file mode 100644
index 0000000..86e1779
--- /dev/null
+++ b/public/assets/js/main.js
@@ -0,0 +1,55 @@
+/* 酷冰甲降温服 · 前台交互 */
+(function () {
+ 'use strict';
+
+ // 明暗切换
+ var root = document.documentElement;
+ var toggle = document.getElementById('themeToggle');
+ if (toggle) {
+ toggle.addEventListener('click', function () {
+ var cur = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
+ root.setAttribute('data-theme', cur);
+ try { localStorage.setItem('site_theme', cur); } catch (e) {}
+ });
+ }
+
+ // 滚动时 header 阴影
+ var header = document.getElementById('siteHeader');
+ function onScroll() {
+ if (header) header.classList.toggle('scrolled', window.scrollY > 10);
+ }
+ window.addEventListener('scroll', onScroll, { passive: true });
+ onScroll();
+
+ // 移动端菜单
+ var burger = document.getElementById('navBurger');
+ var links = document.getElementById('navLinks');
+ if (burger && links) {
+ burger.addEventListener('click', function () { links.classList.toggle('open'); });
+ links.addEventListener('click', function (e) {
+ if (e.target.tagName === 'A') links.classList.remove('open');
+ });
+ }
+
+ // 滚动揭示
+ var io = new IntersectionObserver(function (entries) {
+ entries.forEach(function (en) {
+ if (en.isIntersecting) { en.target.classList.add('in'); io.unobserve(en.target); }
+ });
+ }, { threshold: 0.12 });
+ document.querySelectorAll('.reveal').forEach(function (el, i) {
+ el.style.transitionDelay = (i % 6) * 60 + 'ms';
+ io.observe(el);
+ });
+
+ // 磁性按钮
+ document.querySelectorAll('.magnetic').forEach(function (el) {
+ el.addEventListener('mousemove', function (e) {
+ var r = el.getBoundingClientRect();
+ var x = e.clientX - r.left - r.width / 2;
+ var y = e.clientY - r.top - r.height / 2;
+ el.style.transform = 'translate(' + x * 0.18 + 'px,' + y * 0.28 + 'px)';
+ });
+ el.addEventListener('mouseleave', function () { el.style.transform = ''; });
+ });
+})();
diff --git a/public/assets/js/page-builder.js b/public/assets/js/page-builder.js
new file mode 100644
index 0000000..c9c5f72
--- /dev/null
+++ b/public/assets/js/page-builder.js
@@ -0,0 +1,495 @@
+/* 可视化页面编辑器(可复用):三栏布局(图标栏 | 编辑画布 | 标签页右栏)
+ * 右栏:预览(只读实时模拟)/ 属性 / 素材(可删除)
+ * 支持元素类型:text 文字, image 图片, button 链接按钮, buy 产品购买, price 产品价格, specs 产品规格
+ * 对齐:左/中/右 + 竖(文字=竖排 writing-mode,图片=垂直居中)
+ * 依赖全局(由后台 partial 注入):
+ * __PB_INIT__ 初始布局数组
+ * __PB_MODULE__ page|product|news|case|category
+ * __PB_MEDIA__ 素材列表接口
+ * __PB_UPLOAD__ 上传接口
+ * __PB_DELETE__ 删除接口前缀(拼接文件名)
+ * __PB_CSRF__ CSRF 令牌
+ */
+(function () {
+ 'use strict';
+ var STAGE_W = 720;
+ var stage = document.getElementById('pbStage');
+ if (!stage) return;
+ var MODULE = (window.__PB_MODULE__ || 'page');
+
+ var elements = Array.isArray(window.__PB_INIT__) ? window.__PB_INIT__.slice() : [];
+ var selectedId = null;
+ var zCounter = elements.reduce(function (m, e) { return Math.max(m, e.z || 0); }, 0);
+
+ function uid() { return 'el_' + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36); }
+ function nextZ() { return ++zCounter; }
+ function findEl(id) { for (var i = 0; i < elements.length; i++) if (elements[i].id === id) return elements[i]; return null; }
+ function nodeOf(id) { return stage.querySelector('[data-id="' + id + '"]'); }
+ function ptOf(ev) { return ev.touches && ev.touches[0] ? ev.touches[0] : ev; }
+ function on(id, evt, fn) { var el = document.getElementById(id); if (el) el.addEventListener(evt, fn); }
+
+ /* ---------- 对齐样式(文字:text-align + 竖排;图片:flex 水平 + 垂直居中) ---------- */
+ function styleAlign(el, node) {
+ node.style.textAlign = '';
+ node.style.writingMode = '';
+ node.style.display = '';
+ node.style.flexDirection = '';
+ node.style.alignItems = '';
+ node.style.justifyContent = '';
+ if (el.type === 'image') {
+ node.style.display = 'flex';
+ node.style.flexDirection = 'column';
+ node.style.alignItems = (el.align === 'center') ? 'center' : (el.align === 'right') ? 'flex-end' : 'flex-start';
+ node.style.justifyContent = el.v ? 'center' : 'flex-start';
+ } else if (el.type === 'text') {
+ node.style.textAlign = el.align || 'left';
+ if (el.v) node.style.writingMode = 'vertical-rl';
+ }
+ }
+
+ /* ---------- 通用指针拖拽(鼠标 + 触摸) ---------- */
+ function onPointer(ev, handlers) {
+ ev.preventDefault();
+ var move = function (e) { if (handlers.move) handlers.move(ptOf(e)); };
+ var up = function () {
+ document.removeEventListener('mousemove', move);
+ document.removeEventListener('mouseup', up);
+ document.removeEventListener('touchmove', move);
+ document.removeEventListener('touchend', up);
+ if (handlers.end) handlers.end();
+ };
+ document.addEventListener('mousemove', move);
+ document.addEventListener('mouseup', up);
+ document.addEventListener('touchmove', move, { passive: false });
+ document.addEventListener('touchend', up);
+ if (handlers.start) handlers.start(ptOf(ev));
+ }
+
+ /* ---------- 构建元素内部内容 ---------- */
+ function buildInner(el) {
+ if (el.type === 'image') {
+ var im = document.createElement('img');
+ im.className = 'pb-img'; im.src = el.src || ''; im.alt = ''; im.draggable = false;
+ im.addEventListener('load', updateStageSize);
+ return im;
+ }
+ if (el.type === 'button' || el.type === 'buy') {
+ var a = document.createElement('a');
+ a.className = 'pb-static pb-btn-prev' + (el.type === 'buy' ? ' pb-buy' : '');
+ a.textContent = el.type === 'buy' ? '立即购买' : (el.text || '按钮');
+ if (el.type === 'button') {
+ if (el.bg) a.style.background = el.bg;
+ if (el.tc) a.style.color = el.tc;
+ a.style.fontSize = (el.size === 'lg' ? '18px' : el.size === 'sm' ? '13px' : '15px');
+ }
+ return a;
+ }
+ if (el.type === 'price') {
+ var d = document.createElement('div');
+ d.className = 'pb-static pb-price-prev';
+ d.textContent = '¥ — 起/套';
+ if (el.color) d.style.color = el.color;
+ return d;
+ }
+ if (el.type === 'specs') {
+ var t = document.createElement('div');
+ t.className = 'pb-static pb-specs-prev';
+ t.innerHTML = '参数 1值 1
参数 2值 2
';
+ return t;
+ }
+ var tx = document.createElement('div');
+ tx.className = 'pb-text';
+ tx.textContent = el.text != null ? el.text : '';
+ tx.style.fontSize = (el.fontSize || 18) + 'px';
+ tx.style.color = el.color || '#0f172a';
+ tx.style.fontWeight = el.bold ? '700' : '400';
+ tx.style.textAlign = el.align || 'left';
+ tx.style.lineHeight = '1.4';
+ return tx;
+ }
+
+ /* ---------- 渲染单个元素 ---------- */
+ function renderEl(el) {
+ var wrap = document.createElement('div');
+ wrap.className = 'pb-el';
+ wrap.setAttribute('data-id', el.id);
+ wrap.style.left = (el.x || 0) + 'px';
+ wrap.style.top = (el.y || 0) + 'px';
+ wrap.style.width = (el.w || 200) + 'px';
+ wrap.style.zIndex = el.z || 1;
+ wrap.appendChild(buildInner(el));
+ styleAlign(el, wrap);
+
+ var h = document.createElement('span');
+ h.className = 'pb-handle';
+ h.addEventListener('mousedown', function (e) { startResize(e, el); });
+ h.addEventListener('touchstart', function (e) { startResize(e, el); }, { passive: false });
+ wrap.appendChild(h);
+
+ wrap.addEventListener('mousedown', function (e) { startDrag(e, el); });
+ wrap.addEventListener('touchstart', function (e) { startDrag(e, el); }, { passive: false });
+ wrap.addEventListener('click', function (e) { e.stopPropagation(); selectEl(el.id); });
+ if (el.type === 'text') {
+ wrap.addEventListener('dblclick', function (e) { e.stopPropagation(); startEdit(el, wrap.firstChild); });
+ }
+ return wrap;
+ }
+
+ function renderAll() {
+ stage.innerHTML = '';
+ elements.forEach(function (el) { stage.appendChild(renderEl(el)); });
+ if (selectedId && nodeOf(selectedId)) nodeOf(selectedId).classList.add('sel');
+ updateStageSize();
+ renderPreview();
+ }
+
+ /* 画布高度自适应:容纳所有元素底部 */
+ function updateStageSize() {
+ var maxB = 480;
+ elements.forEach(function (el) {
+ var n = nodeOf(el.id);
+ if (!n) return;
+ var b = (el.y || 0) + n.offsetHeight;
+ if (b > maxB) maxB = b;
+ });
+ stage.style.minHeight = maxB + 'px';
+ }
+
+ function applyEl(el) {
+ var n = nodeOf(el.id);
+ if (!n) return;
+ n.style.width = (el.w || 200) + 'px';
+ styleAlign(el, n);
+ var inner = n.firstChild;
+ if (el.type === 'image') {
+ inner.src = el.src || '';
+ } else if (el.type === 'text') {
+ inner.textContent = el.text != null ? el.text : '';
+ inner.style.fontSize = (el.fontSize || 18) + 'px';
+ inner.style.color = el.color || '#0f172a';
+ inner.style.fontWeight = el.bold ? '700' : '400';
+ inner.style.textAlign = el.align || 'left';
+ inner.style.writingMode = el.v ? 'vertical-rl' : '';
+ } else if (el.type === 'button') {
+ inner.textContent = el.text || '按钮';
+ if (el.bg) inner.style.background = el.bg;
+ if (el.tc) inner.style.color = el.tc;
+ inner.style.fontSize = (el.size === 'lg' ? '18px' : el.size === 'sm' ? '13px' : '15px');
+ } else if (el.type === 'price') {
+ if (el.color) inner.style.color = el.color;
+ }
+ updateStageSize();
+ renderPreview();
+ }
+
+ /* ---------- 拖拽移动 ---------- */
+ function startDrag(e, el) {
+ if (e.target && e.target.classList.contains('pb-handle')) return;
+ e.stopPropagation();
+ selectEl(el.id);
+ var p = ptOf(e);
+ var sx = p.clientX, sy = p.clientY, ox = el.x || 0, oy = el.y || 0;
+ onPointer(e, {
+ move: function (p2) {
+ el.x = Math.max(0, ox + (p2.clientX - sx));
+ el.y = Math.max(0, oy + (p2.clientY - sy));
+ var n = nodeOf(el.id);
+ if (n) { n.style.left = el.x + 'px'; n.style.top = el.y + 'px'; }
+ }
+ });
+ }
+
+ /* ---------- 缩放(右下角) ---------- */
+ function startResize(e, el) {
+ e.stopPropagation(); e.preventDefault();
+ selectEl(el.id);
+ var p = ptOf(e);
+ var sx = p.clientX, sw = el.w || 200;
+ onPointer(e, {
+ move: function (p2) {
+ el.w = Math.max(40, sw + (p2.clientX - sx));
+ var n = nodeOf(el.id);
+ if (n) n.style.width = el.w + 'px';
+ }
+ });
+ }
+
+ /* ---------- 双击编辑文字 ---------- */
+ function startEdit(el, node) {
+ node.setAttribute('contenteditable', 'true');
+ node.focus();
+ try { document.getSelection().selectAllChildren(node); } catch (x) {}
+ function done() {
+ node.removeAttribute('contenteditable');
+ el.text = node.textContent;
+ node.removeEventListener('blur', done);
+ if (selectedId === el.id) { syncProps(); renderPreview(); }
+ }
+ node.addEventListener('blur', done);
+ }
+
+ /* ---------- 选中 / 属性面板 ---------- */
+ function selectEl(id) {
+ selectedId = id;
+ Array.prototype.forEach.call(stage.querySelectorAll('.pb-el'), function (n) {
+ n.classList.toggle('sel', n.getAttribute('data-id') === id);
+ });
+ renderProps(findEl(id));
+ if (id) switchTab('props');
+ }
+
+ function alignGroup(el, types) {
+ var html = '';
+ html += '';
+ html += '';
+ html += '';
+ html += '';
+ html += '
';
+ return html;
+ }
+ function wireAlign(el) {
+ document.querySelectorAll('#pbPropsBody .pb-ab').forEach(function (b) {
+ var a = b.getAttribute('data-a');
+ var on = (a === 'v') ? !!el.v : ((el.align || 'left') === a);
+ b.classList.toggle('active', on);
+ b.onclick = function () {
+ if (a === 'v') el.v = !el.v; else el.align = a;
+ wireAlign(el); applyEl(el); renderPreview();
+ };
+ });
+ }
+
+ function renderProps(el) {
+ var body = document.getElementById('pbPropsBody');
+ if (!el) { body.className = 'pb-empty'; body.textContent = '在画布中选择一个元素以编辑属性'; return; }
+ body.className = '';
+ if (el.type === 'text') {
+ body.innerHTML =
+ '' +
+ '' +
+ '' +
+ '' +
+ alignGroup(el) +
+ '';
+ document.getElementById('pp_text').value = el.text != null ? el.text : '';
+ document.getElementById('pp_size').value = el.fontSize || 18;
+ document.getElementById('pp_color').value = el.color || '#0f172a';
+ document.getElementById('pp_bold').checked = !!el.bold;
+ bind('pp_text', 'input', function (v) { el.text = v; applyEl(el); });
+ bind('pp_size', 'input', function (v) { el.fontSize = parseInt(v, 10) || 18; applyEl(el); });
+ bind('pp_color', 'input', function (v) { el.color = v; applyEl(el); });
+ bind('pp_bold', 'change', function (v) { el.bold = !!v; applyEl(el); });
+ wireAlign(el);
+ } else if (el.type === 'image') {
+ body.innerHTML =
+ '' +
+ alignGroup(el) +
+ '' +
+ '';
+ document.getElementById('pp_w').value = el.w || 200;
+ bind('pp_w', 'input', function (v) { el.w = Math.max(40, parseInt(v, 10) || 200); applyEl(el); });
+ fillLib(document.getElementById('pp_lib'), function (url) { el.src = url; applyEl(el); });
+ wireAlign(el);
+ } else if (el.type === 'button') {
+ body.innerHTML =
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '';
+ document.getElementById('pp_text').value = el.text || '按钮';
+ document.getElementById('pp_href').value = el.href || '';
+ document.getElementById('pp_bg').value = el.bg || '#0ea5e9';
+ document.getElementById('pp_tc').value = el.tc || '#ffffff';
+ document.getElementById('pp_size').value = el.size || 'md';
+ bind('pp_text', 'input', function (v) { el.text = v; applyEl(el); });
+ bind('pp_href', 'input', function (v) { el.href = v; });
+ bind('pp_bg', 'input', function (v) { el.bg = v; applyEl(el); });
+ bind('pp_tc', 'input', function (v) { el.tc = v; applyEl(el); });
+ bind('pp_size', 'change', function (v) { el.size = v; applyEl(el); });
+ } else if (el.type === 'price') {
+ body.innerHTML =
+ '自动显示本产品「价格」,无需填写。
' +
+ '' +
+ '';
+ document.getElementById('pp_color').value = el.color || '#0f172a';
+ bind('pp_color', 'input', function (v) { el.color = v; applyEl(el); });
+ } else if (el.type === 'buy') {
+ body.innerHTML = '自动链接到本产品的「立即购买」下单页。
';
+ } else if (el.type === 'specs') {
+ body.innerHTML = '自动显示本产品的「规格参数」。
';
+ }
+ var del = document.getElementById('pp_del');
+ if (del) del.addEventListener('click', function () { removeEl(el.id); });
+ }
+
+ function bind(id, evt, fn) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ el.addEventListener(evt, function () { fn(el.value !== undefined ? el.value : el.checked); });
+ }
+ function syncProps() { var el = findEl(selectedId); if (el) renderProps(el); }
+
+ function removeEl(id) {
+ elements = elements.filter(function (e) { return e.id !== id; });
+ if (selectedId === id) selectedId = null;
+ renderAll();
+ switchTab('preview');
+ if (!selectedId) renderProps(null);
+ }
+
+ /* ---------- 标签页切换 ---------- */
+ function switchTab(name) {
+ document.querySelectorAll('.pb-tab').forEach(function (t) {
+ t.classList.toggle('active', t.getAttribute('data-tab') === name);
+ });
+ var panes = { preview: 'panePreview', props: 'paneProps', material: 'paneMaterial' };
+ Object.keys(panes).forEach(function (k) {
+ document.getElementById(panes[k]).classList.toggle('hidden', k !== name);
+ });
+ if (name === 'preview') fitPreview();
+ }
+ document.querySelectorAll('.pb-tab').forEach(function (t) {
+ t.addEventListener('click', function () { switchTab(t.getAttribute('data-tab')); });
+ });
+
+ /* ---------- 实时预览(克隆只读 + 自适应缩放) ---------- */
+ function renderPreview() {
+ var pv = document.getElementById('pbPreview');
+ if (!pv) return;
+ pv.innerHTML = stage.innerHTML;
+ pv.querySelectorAll('.pb-handle').forEach(function (h) { h.remove(); });
+ pv.querySelectorAll('.pb-el').forEach(function (n) { n.classList.remove('sel'); n.style.outline = ''; });
+ pv.querySelectorAll('[contenteditable]').forEach(function (n) { n.removeAttribute('contenteditable'); });
+ fitPreview();
+ }
+ function fitPreview() {
+ var wrap = document.getElementById('pbPreviewWrap');
+ var pv = document.getElementById('pbPreview');
+ if (!wrap || !pv) return;
+ var avail = wrap.clientWidth || STAGE_W;
+ var scale = Math.min(1, avail / STAGE_W);
+ var m = 420;
+ pv.querySelectorAll('.pb-el').forEach(function (n) {
+ var y = parseInt(n.style.top, 10) || 0;
+ var b = y + n.offsetHeight;
+ if (b > m) m = b;
+ });
+ pv.style.transform = 'scale(' + scale + ')';
+ pv.style.transformOrigin = 'top left';
+ pv.style.height = m + 'px';
+ wrap.style.height = (m * scale) + 'px';
+ }
+
+ /* ---------- 素材库(含删除) ---------- */
+ function fillLib(container, onPick) {
+ if (!container) return;
+ container.innerHTML = '加载中…';
+ fetch(window.__PB_MEDIA__)
+ .then(function (r) { return r.json(); })
+ .then(function (j) {
+ container.innerHTML = '';
+ var items = (j && j.items) || [];
+ if (!items.length) { container.innerHTML = '暂无素材,先上传'; return; }
+ items.forEach(function (it) {
+ var cell = document.createElement('div');
+ cell.className = 'pb-lib-item';
+ var img = document.createElement('img');
+ img.src = it.url; img.title = it.name;
+ img.addEventListener('click', function () { onPick(it.url); });
+ var del = document.createElement('button');
+ del.className = 'pb-lib-del'; del.type = 'button'; del.textContent = '×'; del.title = '删除素材';
+ del.addEventListener('click', function (e) { e.stopPropagation(); deleteMedia(it.name); });
+ cell.appendChild(img); cell.appendChild(del);
+ container.appendChild(cell);
+ });
+ })
+ .catch(function () { container.innerHTML = '加载失败'; });
+ }
+
+ function deleteMedia(name) {
+ if (!confirm('确定删除素材「' + name + '」?此操作不可撤销。')) return;
+ fetch(window.__PB_DELETE__ + encodeURIComponent(name), {
+ method: 'POST',
+ headers: { 'X-CSRF-TOKEN': window.__PB_CSRF__ }
+ })
+ .then(function (r) { return r.json(); })
+ .then(function (j) {
+ if (j && j.ok) {
+ loadLib();
+ var pp = document.getElementById('pp_lib');
+ if (pp) fillLib(pp, function (url) { var el = findEl(selectedId); if (el) { el.src = url; applyEl(el); } });
+ } else { alert((j && j.msg) || '删除失败'); }
+ })
+ .catch(function () { alert('删除失败,请重试'); });
+ }
+
+ function loadLib() { fillLib(document.getElementById('pbLib'), function (url) { addImage(url); }); }
+
+ /* ---------- 添加元素 ---------- */
+ function addText() {
+ var el = { id: uid(), type: 'text', x: 40, y: 40, w: 320, text: '双击编辑文字', fontSize: 20, color: '#0f172a', bold: false, align: 'left', v: false, z: nextZ() };
+ elements.push(el); renderAll(); selectEl(el.id);
+ }
+ function addImage(url) {
+ var el = { id: uid(), type: 'image', x: 40, y: 40, w: 280, src: url, align: 'left', v: false, z: nextZ() };
+ elements.push(el); renderAll(); selectEl(el.id);
+ }
+ function addButton() {
+ var el = { id: uid(), type: 'button', x: 40, y: 40, w: 200, text: '按钮', href: '#', bg: '#0ea5e9', tc: '#ffffff', size: 'md', z: nextZ() };
+ elements.push(el); renderAll(); selectEl(el.id);
+ }
+ function addBuy() {
+ var el = { id: uid(), type: 'buy', x: 40, y: 40, w: 200, z: nextZ() };
+ elements.push(el); renderAll(); selectEl(el.id);
+ }
+ function addPrice() {
+ var el = { id: uid(), type: 'price', x: 40, y: 40, w: 240, color: '#0f172a', z: nextZ() };
+ elements.push(el); renderAll(); selectEl(el.id);
+ }
+ function addSpecs() {
+ var el = { id: uid(), type: 'specs', x: 40, y: 40, w: 360, z: nextZ() };
+ elements.push(el); renderAll(); selectEl(el.id);
+ }
+
+ /* 图标栏动作 */
+ document.querySelectorAll('.pb-rail-btn').forEach(function (b) {
+ b.addEventListener('click', function () {
+ var act = b.getAttribute('data-act');
+ if (act === 'material') { switchTab('material'); loadLib(); }
+ else if (act === 'image') { switchTab('material'); }
+ else if (act === 'text') { addText(); }
+ else if (act === 'button') { addButton(); }
+ else if (act === 'buy') { addBuy(); }
+ else if (act === 'price') { addPrice(); }
+ else if (act === 'specs') { addSpecs(); }
+ });
+ });
+
+ stage.addEventListener('click', function () { selectEl(null); });
+
+ /* ---------- 上传素材 ---------- */
+ on('pbFile', 'change', function () {
+ var file = this.files && this.files[0];
+ if (!file) return;
+ var fd = new FormData();
+ fd.append('file', file);
+ fetch(window.__PB_UPLOAD__, { method: 'POST', body: fd, headers: { 'X-CSRF-TOKEN': window.__PB_CSRF__ } })
+ .then(function (r) { return r.json(); })
+ .then(function (j) { if (j && j.ok) { addImage(j.url); loadLib(); } else alert((j && j.msg) || '上传失败'); })
+ .catch(function () { alert('上传失败,请重试'); });
+ this.value = '';
+ });
+
+ /* ---------- 保存:序列化到隐藏字段 ---------- */
+ on('pbForm', 'submit', function () {
+ var f = document.getElementById('pbLayout');
+ if (f) f.value = JSON.stringify(elements);
+ });
+
+ /* ---------- 启动 ---------- */
+ renderAll();
+ loadLib();
+ window.addEventListener('resize', fitPreview);
+})();
diff --git a/public/assets/uploads/.gitkeep b/public/assets/uploads/.gitkeep
new file mode 100644
index 0000000..fcf2bda
--- /dev/null
+++ b/public/assets/uploads/.gitkeep
@@ -0,0 +1,2 @@
+# 素材上传目录(图片由后台可视化编辑器上传到此)
+# 部署时请确保该目录可写:chown www:www 且 chmod 755
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..d6c3d1c
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,65 @@
+/