diff --git a/backend-web/config/local.php.example b/backend-web/config/local.php.example index d5e7618..35095fb 100644 --- a/backend-web/config/local.php.example +++ b/backend-web/config/local.php.example @@ -27,6 +27,7 @@ return [ 'storage' => [ 'users_dir' => '/www/wwwroot/designadmin.cardsoon.com/backend/storage/users', 'models_dir' => '/www/wwwroot/designadmin.cardsoon.com/soonModels', + 'templates_dir' => '/www/wwwroot/designadmin.cardsoon.com/backend/storage/templates', ], 'limits' => [ 'free_quota_mb' => 20, diff --git a/backend-web/public/admin.php b/backend-web/public/admin.php index 0f5f43b..32f91f3 100644 --- a/backend-web/public/admin.php +++ b/backend-web/public/admin.php @@ -9,6 +9,7 @@ use Soon\Api\Admin\Controllers\PaymentController; use Soon\Api\Admin\Controllers\PlansController; use Soon\Api\Admin\Controllers\SettingsController; use Soon\Api\Admin\Controllers\StatsController; +use Soon\Api\Admin\Controllers\TemplatesController; use Soon\Api\Admin\Controllers\UsersController; use Soon\Api\Core\Config; use Soon\Api\Core\Json; @@ -58,6 +59,11 @@ Router::post('/api/admin/orders/{id}/refund/reject', [OrdersController::class, ' Router::get('/api/admin/plans', [PlansController::class, 'list']); Router::post('/api/admin/plans', [PlansController::class, 'upsert']); +Router::get('/api/admin/templates', [TemplatesController::class, 'list']); +Router::post('/api/admin/templates', [TemplatesController::class, 'create']); +Router::put('/api/admin/templates/{id}', [TemplatesController::class, 'update']); +Router::delete('/api/admin/templates/{id}', [TemplatesController::class, 'delete']); + Router::get('/api/admin/settings', [SettingsController::class, 'list']); Router::post('/api/admin/settings', [SettingsController::class, 'set']); Router::post('/api/admin/settings/batch', [SettingsController::class, 'setBatch']); diff --git a/backend-web/public/index.php b/backend-web/public/index.php index 15742e6..88104a2 100644 --- a/backend-web/public/index.php +++ b/backend-web/public/index.php @@ -9,6 +9,7 @@ use Soon\Api\Controllers\PayController; use Soon\Api\Controllers\PlanController; use Soon\Api\Controllers\SettingsController; use Soon\Api\Controllers\SoonModelController; +use Soon\Api\Controllers\TemplateController; use Soon\Api\Core\Config; use Soon\Api\Core\Json; use Soon\Api\Core\Router; @@ -61,8 +62,11 @@ Router::post('/api/v1/pay/orders/{order_no}/refund-request', [PayController::cla Router::post('/api/v1/pay/alipay/notify', [PayController::class, 'alipayNotify']); Router::post('/api/v1/pay/wechat/notify', [PayController::class, 'wechatNotify']); +Router::get('/api/v1/templates', [TemplateController::class, 'index']); +Router::get('/api/v1/templates/{id}/thumb', [TemplateController::class, 'thumb']); +Router::get('/api/v1/templates/{id}/file', [TemplateController::class, 'file']); Router::get('/api/v1/soon-models/files/{name}', [SoonModelController::class, 'download']); -Router::get('/api/v1/soon-models', [SoonModelController::class, 'index']); +Router::get('/api/v1/soon-models', [TemplateController::class, 'index']); Router::get('/api/v1/settings', [SettingsController::class, 'publicSettings']); if (str_starts_with($path, '/api/v1/')) { diff --git a/backend-web/schema.sql b/backend-web/schema.sql index f6c25fe..1af6948 100644 --- a/backend-web/schema.sql +++ b/backend-web/schema.sql @@ -48,12 +48,14 @@ CREATE TABLE IF NOT EXISTS `plans` ( INSERT INTO `plans` (`code`,`name`,`description`,`price_cents`,`quota_mb`,`max_files`,`duration_days`,`features`,`sort_order`,`is_recommended`,`is_active`) VALUES ('free','免费版','免费体验设计与编辑',0,2048,200,0, '["设计与编辑工具免费使用"]',0,0,1), + ('member_lifetime','永久会员','一次激活,永久使用预览交付能力',100,2048,200,0, + '["高清预览","成品打印","云端保存","导出设计文件","永久有效"]',10,1,1), ('member_monthly','月度订阅','按月灵活使用,随时续订',1999,2048,200,30, - '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]',10,1,1), + '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]',20,0,0), ('member_quarterly','季度订阅','连续三个月,更省心',5299,2048,200,90, - '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]',15,0,1), + '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]',25,0,0), ('member_yearly','年度订阅','全年畅享,性价比更高',19999,2048,200,365, - '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]',20,0,1) + '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]',30,0,0) ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), @@ -63,7 +65,8 @@ ON DUPLICATE KEY UPDATE duration_days=VALUES(duration_days), features=VALUES(features), sort_order=VALUES(sort_order), - is_recommended=VALUES(is_recommended); + is_recommended=VALUES(is_recommended), + is_active=VALUES(is_active); CREATE TABLE IF NOT EXISTS `subscriptions` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, @@ -114,6 +117,20 @@ CREATE TABLE IF NOT EXISTS `audit_logs` ( KEY `admin` (`admin_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `soon_templates` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(120) NOT NULL, + `type` TINYINT UNSIGNED NOT NULL DEFAULT 1, + `thumb` MEDIUMTEXT DEFAULT NULL, + `file_size` INT UNSIGNED NOT NULL DEFAULT 0, + `sort_order` INT NOT NULL DEFAULT 0, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `active_sort` (`is_active`, `sort_order`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `settings` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `key` VARCHAR(120) NOT NULL, diff --git a/backend-web/src/Admin/Controllers/PlansController.php b/backend-web/src/Admin/Controllers/PlansController.php index 8ac6c02..cfa40e0 100644 --- a/backend-web/src/Admin/Controllers/PlansController.php +++ b/backend-web/src/Admin/Controllers/PlansController.php @@ -57,9 +57,13 @@ final class PlansController if ($code !== 'free' && $priceCents <= 0) { Json::fail('bad_request', '付费套餐价格须大于 0', 400); } - if ($code !== 'free' && $durationDays <= 0) { + $isLifetime = $code === 'member_lifetime'; + if ($code !== 'free' && !$isLifetime && $durationDays <= 0) { Json::fail('bad_request', '付费套餐须设置有效天数', 400); } + if ($isLifetime) { + $durationDays = 0; + } if ($coreFeatures === []) { Json::fail('bad_request', '请至少填写一条权益说明', 400); } diff --git a/backend-web/src/Admin/Controllers/TemplatesController.php b/backend-web/src/Admin/Controllers/TemplatesController.php new file mode 100644 index 0000000..e3c68d3 --- /dev/null +++ b/backend-web/src/Admin/Controllers/TemplatesController.php @@ -0,0 +1,54 @@ + TemplateService::listAdmin()]); + } + + public function create(int $adminId): void + { + $body = Json::readBody(); + $name = (string)($body['name'] ?? ''); + $content = (string)($body['content'] ?? ''); + $sortOrder = (int)($body['sort_order'] ?? 0); + $isActive = (int)($body['is_active'] ?? 1) === 1 ? 1 : 0; + if ($content === '') { + Json::fail('bad_request', 'content 必填(.soon JSON 文本)', 400); + } + $item = TemplateService::create($name, $content, $sortOrder, $isActive); + AuditService::log($adminId, 'templates.create', 'soon_templates:' . $item['id']); + Json::ok($item); + } + + public function update(int $adminId, int $id): void + { + $body = Json::readBody(); + $name = (string)($body['name'] ?? ''); + $sortOrder = (int)($body['sort_order'] ?? 0); + $isActive = (int)($body['is_active'] ?? 1) === 1 ? 1 : 0; + $content = array_key_exists('content', $body) ? (string)$body['content'] : null; + if ($content !== null && trim($content) === '') { + $content = null; + } + $item = TemplateService::update($id, $name, $sortOrder, $isActive, $content); + AuditService::log($adminId, 'templates.update', 'soon_templates:' . $id); + Json::ok($item); + } + + public function delete(int $adminId, int $id): void + { + TemplateService::delete($id); + AuditService::log($adminId, 'templates.delete', 'soon_templates:' . $id); + Json::ok(['id' => $id]); + } +} diff --git a/backend-web/src/Admin/Controllers/UsersController.php b/backend-web/src/Admin/Controllers/UsersController.php index 553bb19..fec5c6e 100644 --- a/backend-web/src/Admin/Controllers/UsersController.php +++ b/backend-web/src/Admin/Controllers/UsersController.php @@ -7,6 +7,7 @@ use Soon\Api\Core\Db; use Soon\Api\Core\Json; use Soon\Api\Services\AdminPermission; use Soon\Api\Services\AuditService; +use Soon\Api\Services\MembershipService; final class UsersController { @@ -270,9 +271,9 @@ final class UsersController try { $pdo->prepare('UPDATE subscriptions SET status = \'expired\' WHERE user_id = :u AND status = \'active\'') ->execute(['u' => $id]); - if ($plan['code'] !== 'free' && $durationDays > 0) { + if ($plan['code'] !== 'free') { $now = date('Y-m-d H:i:s'); - $expires = date('Y-m-d H:i:s', time() + $durationDays * 86400); + $expires = MembershipService::subscriptionExpiresAt($durationDays); $pdo->prepare( 'INSERT INTO subscriptions (user_id, plan_id, status, started_at, expires_at) ' . 'VALUES (:u, :p, \'active\', :sa, :ea)' diff --git a/backend-web/src/Controllers/FileController.php b/backend-web/src/Controllers/FileController.php index 51f97b8..f527523 100644 --- a/backend-web/src/Controllers/FileController.php +++ b/backend-web/src/Controllers/FileController.php @@ -6,7 +6,6 @@ namespace Soon\Api\Controllers; use Soon\Api\Core\Json; use Soon\Api\Middleware\Auth; use Soon\Api\Services\FileService; -use Soon\Api\Services\MembershipService; final class FileController { @@ -39,7 +38,6 @@ final class FileController public function create(): void { $u = Auth::require(); - MembershipService::requireActiveMember($u['id']); $body = Json::readBody(); $name = (string)($body['name'] ?? 'untitled.soon'); $json = (string)($body['json'] ?? '{}'); @@ -49,7 +47,6 @@ final class FileController public function update(int $id): void { $u = Auth::require(); - MembershipService::requireActiveMember($u['id']); $body = Json::readBody(); $name = (string)($body['name'] ?? 'untitled.soon'); $json = (string)($body['json'] ?? '{}'); @@ -67,12 +64,6 @@ final class FileController public function download(int $id): void { $u = Auth::require(); - $accept = (string)($_SERVER['HTTP_ACCEPT'] ?? ''); - $jsonRead = stripos($accept, 'application/json') !== false - && stripos($accept, 'application/octet-stream') === false; - if (!$jsonRead) { - MembershipService::requireActiveMember($u['id']); - } $row = FileService::fetch($u['id'], $id); $name = (string)$row['name']; $json = (string)$row['json']; diff --git a/backend-web/src/Controllers/TemplateController.php b/backend-web/src/Controllers/TemplateController.php new file mode 100644 index 0000000..fa32dba --- /dev/null +++ b/backend-web/src/Controllers/TemplateController.php @@ -0,0 +1,26 @@ + $items, 'total' => count($items)]); + } + + public function thumb(int $id): void + { + TemplateService::outputThumb($id); + } + + public function file(int $id): void + { + TemplateService::outputFile($id); + } +} diff --git a/backend-web/src/Services/AlipayClient.php b/backend-web/src/Services/AlipayClient.php index 24c9b90..1778edd 100644 --- a/backend-web/src/Services/AlipayClient.php +++ b/backend-web/src/Services/AlipayClient.php @@ -23,7 +23,7 @@ final class AlipayClient 'version' => '1.0', 'notify_url' => (string)Config::get('site.base_url', '') . '/api/v1/pay/alipay/notify', 'return_url' => (string)Config::get('site.front_base_url', Config::get('site.base_url', '')) - . '/pages/member.web.html?paid=' . rawurlencode($orderNo), + . '/pages/index.web.html?paid=' . rawurlencode($orderNo), 'biz_content' => json_encode([ 'out_trade_no' => $orderNo, 'product_code' => 'FAST_INSTANT_TRADE_PAY', diff --git a/backend-web/src/Services/MembershipService.php b/backend-web/src/Services/MembershipService.php index 402a7cd..e4ab7d6 100644 --- a/backend-web/src/Services/MembershipService.php +++ b/backend-web/src/Services/MembershipService.php @@ -5,7 +5,6 @@ namespace Soon\Api\Services; use Soon\Api\Core\Config; use Soon\Api\Core\Db; -use Soon\Api\Core\Json; /** * 会员与配额服务。 @@ -16,7 +15,8 @@ final class MembershipService { $stmt = Db::pdo()->query( 'SELECT id, code, name, description, price_cents, quota_mb, max_files, duration_days, ' - . 'features, sort_order, is_recommended, is_active FROM plans WHERE is_active = 1 ' + . 'features, sort_order, is_recommended, is_active FROM plans ' + . 'WHERE is_active = 1 AND code = "member_lifetime" AND price_cents > 0 ' . 'ORDER BY sort_order ASC, price_cents ASC' ); $items = []; @@ -42,15 +42,25 @@ final class MembershipService if ($row) { $plan = self::enrichPlan($row); - $expiresAt = (string)($row['subscription_expires_at'] ?? ''); - $daysRemaining = self::daysUntil($expiresAt); - $plan['subscription'] = [ - 'id' => (int)$row['subscription_id'], - 'started_at' => $row['subscription_started_at'] ?? null, - 'expires_at' => $expiresAt, - 'days_remaining' => $daysRemaining, - 'status' => $daysRemaining <= 7 ? 'expiring' : 'active', - ]; + $durationDays = (int)($row['duration_days'] ?? 0); + $startedAt = $row['subscription_started_at'] ?? null; + if ($durationDays <= 0 || (string)($row['code'] ?? '') === 'member_lifetime') { + $plan['subscription'] = [ + 'type' => 'lifetime', + 'activated_at' => $startedAt, + 'status' => 'active', + ]; + } else { + $expiresAt = (string)($row['subscription_expires_at'] ?? ''); + $daysRemaining = self::daysUntil($expiresAt); + $plan['subscription'] = [ + 'id' => (int)$row['subscription_id'], + 'started_at' => $startedAt, + 'expires_at' => $expiresAt, + 'days_remaining' => $daysRemaining, + 'status' => $daysRemaining <= 7 ? 'expiring' : 'active', + ]; + } $plan['usage'] = $usage; $plan['tier'] = 'member'; $plan['is_member'] = true; @@ -69,7 +79,7 @@ final class MembershipService $plan = self::enrichPlan([ 'id' => 0, 'code' => 'free', - 'name' => '免费版', + 'name' => '普通用户', 'description' => '适合个人体验与轻量设计', 'price_cents' => 0, 'quota_mb' => $freeQuota, @@ -105,13 +115,6 @@ final class MembershipService return (bool)$stmt->fetchColumn(); } - public static function requireActiveMember(int $userId): void - { - if (!self::isActiveMember($userId)) { - Json::fail('membership_required', '此功能需要订阅后使用', 403); - } - } - /** @return array> */ public static function recentOrders(int $userId, int $limit = 8): array { @@ -158,9 +161,15 @@ final class MembershipService return ['items' => $items, 'total' => $total, 'page' => $page, 'size' => $size]; } - public static function previewAllowed(int $userId): bool + private const LIFETIME_EXPIRES = '9999-12-31 23:59:59'; + + public static function subscriptionExpiresAt(int $durationDays, ?int $baseTs = null): string { - return self::isActiveMember($userId); + if ($durationDays <= 0) { + return self::LIFETIME_EXPIRES; + } + $baseTs = $baseTs ?? time(); + return date('Y-m-d H:i:s', $baseTs + $durationDays * 86400); } public static function settings(): array @@ -255,6 +264,8 @@ final class MembershipService } if ($durationDays > 0) { $core[] = '订阅周期:' . self::periodLabel($durationDays); + } elseif ($code === 'member_lifetime' || $durationDays <= 0) { + $core[] = '永久有效'; } return $core; } diff --git a/backend-web/src/Services/PayService.php b/backend-web/src/Services/PayService.php index cf7e165..1f23437 100644 --- a/backend-web/src/Services/PayService.php +++ b/backend-web/src/Services/PayService.php @@ -19,6 +19,9 @@ final class PayService public static function createOrder(int $userId, int $planId, string $channel, string $clientIp): array { + if (MembershipService::isActiveMember($userId)) { + Json::fail('already_member', '您已是会员,无需重复激活', 409); + } $plan = self::loadActivePlan($planId); self::expireStalePendingOrders($userId); $existing = self::findReusablePendingOrder($userId, $planId, $channel); @@ -239,7 +242,12 @@ final class PayService if ($activeExpires) { $baseTs = max($baseTs, strtotime((string)$activeExpires)); } - $expires = date('Y-m-d H:i:s', $baseTs + (int)$plan['duration_days'] * 86400); + $durationDays = (int)$plan['duration_days']; + if ($durationDays <= 0) { + $expires = '9999-12-31 23:59:59'; + } else { + $expires = date('Y-m-d H:i:s', $baseTs + $durationDays * 86400); + } $pdo->prepare('UPDATE subscriptions SET status = "expired" WHERE user_id = :u AND status = "active"') ->execute(['u' => $order['user_id']]); $pdo->prepare( diff --git a/backend-web/src/Services/PaymentConfig.php b/backend-web/src/Services/PaymentConfig.php index c5ac6cf..541abc5 100644 --- a/backend-web/src/Services/PaymentConfig.php +++ b/backend-web/src/Services/PaymentConfig.php @@ -169,7 +169,7 @@ final class PaymentConfig 'wechat' => $baseUrl !== '' ? $baseUrl . '/api/v1/pay/wechat/notify' : '', ], 'return_url_hint' => rtrim((string)Config::get('site.front_base_url', Config::get('site.base_url', '')), '/') - . '/pages/member.web.html?paid={order_no}', + . '/pages/index.web.html?paid={order_no}', ]; } diff --git a/backend-web/src/Services/TemplateService.php b/backend-web/src/Services/TemplateService.php new file mode 100644 index 0000000..8debd2c --- /dev/null +++ b/backend-web/src/Services/TemplateService.php @@ -0,0 +1,292 @@ +> */ + public static function listPublic(): array + { + $stmt = Db::pdo()->query( + 'SELECT id, name, type FROM soon_templates WHERE is_active = 1 ' + . 'ORDER BY sort_order ASC, id ASC' + ); + $items = []; + foreach ($stmt->fetchAll() as $row) { + $items[] = [ + 'id' => (int)$row['id'], + 'name' => (string)$row['name'], + 'type' => (int)$row['type'], + ]; + } + return $items; + } + + /** @return list> */ + public static function listAdmin(): array + { + $stmt = Db::pdo()->query( + 'SELECT id, name, type, file_size, sort_order, is_active, created_at, updated_at, ' + . '(CASE WHEN thumb IS NOT NULL AND thumb <> "" THEN 1 ELSE 0 END) AS has_thumb ' + . 'FROM soon_templates ORDER BY sort_order ASC, id ASC' + ); + $items = []; + foreach ($stmt->fetchAll() as $row) { + $items[] = [ + 'id' => (int)$row['id'], + 'name' => (string)$row['name'], + 'type' => (int)$row['type'], + 'file_size' => (int)$row['file_size'], + 'sort_order' => (int)$row['sort_order'], + 'is_active' => (int)$row['is_active'] === 1, + 'has_thumb' => (int)$row['has_thumb'] === 1, + 'created_at' => $row['created_at'], + 'updated_at' => $row['updated_at'], + ]; + } + return $items; + } + + /** @return array|null */ + public static function find(int $id): ?array + { + $stmt = Db::pdo()->prepare('SELECT * FROM soon_templates WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + return $row ?: null; + } + + public static function filePath(int $id): ?string + { + $path = self::storageDir() . DIRECTORY_SEPARATOR . $id . '.soon'; + return is_file($path) && is_readable($path) ? $path : null; + } + + /** @return array{type:int, thumb:string} */ + public static function parseSoonContent(string $json): array + { + $json = trim($json); + if ($json === '') { + Json::fail('bad_request', '模板内容为空', 400); + } + $data = json_decode($json, true); + if (!is_array($data)) { + Json::fail('bad_request', '模板须为有效的 JSON(.soon)', 400); + } + + $type = 1; + if (!empty($data['soonType']) && (int)$data['soonType'] === 2) { + $type = 2; + } elseif (!empty($data['backBlackPic'])) { + $type = 2; + } + + $thumb = ''; + if (!empty($data['frontDisplayPic']) && is_string($data['frontDisplayPic'])) { + $candidate = trim($data['frontDisplayPic']); + if (str_starts_with($candidate, 'data:image/') && strlen($candidate) <= self::THUMB_MAX_BYTES) { + $thumb = $candidate; + } + } + + return ['type' => $type, 'thumb' => $thumb]; + } + + public static function create( + string $name, + string $json, + int $sortOrder = 0, + int $isActive = 1 + ): array { + $name = trim($name); + if ($name === '') { + Json::fail('bad_request', '名称必填', 400); + } + if (mb_strlen($name) > 120) { + Json::fail('bad_request', '名称过长', 400); + } + + $meta = self::parseSoonContent($json); + $now = date('Y-m-d H:i:s'); + $pdo = Db::pdo(); + $pdo->prepare( + 'INSERT INTO soon_templates (name, type, thumb, file_size, sort_order, is_active, created_at, updated_at) ' + . 'VALUES (:n, :t, :th, :sz, :so, :a, :ca, :ua)' + )->execute([ + 'n' => $name, + 't' => $meta['type'], + 'th' => $meta['thumb'], + 'sz' => strlen($json), + 'so' => $sortOrder, + 'a' => $isActive ? 1 : 0, + 'ca' => $now, + 'ua' => $now, + ]); + $id = (int)$pdo->lastInsertId(); + self::writeFile($id, $json); + $item = self::find($id); + if (!$item) { + Json::fail('server_error', '模板创建失败', 500); + } + return [ + 'id' => $id, + 'name' => (string)$item['name'], + 'type' => (int)$item['type'], + 'file_size' => (int)$item['file_size'], + 'sort_order' => (int)$item['sort_order'], + 'is_active' => (int)$item['is_active'] === 1, + 'has_thumb' => trim((string)($item['thumb'] ?? '')) !== '', + 'created_at' => $item['created_at'], + 'updated_at' => $item['updated_at'], + ]; + } + + public static function update( + int $id, + string $name, + int $sortOrder, + int $isActive, + ?string $json = null + ): array { + $row = self::find($id); + if (!$row) { + Json::fail('not_found', '模板不存在', 404); + } + $name = trim($name); + if ($name === '') { + Json::fail('bad_request', '名称必填', 400); + } + + $type = (int)$row['type']; + $thumb = (string)($row['thumb'] ?? ''); + $size = (int)$row['file_size']; + if ($json !== null && trim($json) !== '') { + $meta = self::parseSoonContent($json); + $type = $meta['type']; + $thumb = $meta['thumb']; + $size = strlen($json); + self::writeFile($id, $json); + } + + Db::pdo()->prepare( + 'UPDATE soon_templates SET name = :n, type = :t, thumb = :th, file_size = :sz, ' + . 'sort_order = :so, is_active = :a, updated_at = :ua WHERE id = :id' + )->execute([ + 'n' => $name, + 't' => $type, + 'th' => $thumb, + 'sz' => $size, + 'so' => $sortOrder, + 'a' => $isActive ? 1 : 0, + 'ua' => date('Y-m-d H:i:s'), + 'id' => $id, + ]); + + $item = self::find($id); + if (!$item) { + Json::fail('server_error', '模板更新失败', 500); + } + return [ + 'id' => $id, + 'name' => (string)$item['name'], + 'type' => (int)$item['type'], + 'file_size' => (int)$item['file_size'], + 'sort_order' => (int)$item['sort_order'], + 'is_active' => (int)$item['is_active'] === 1, + 'has_thumb' => trim((string)($item['thumb'] ?? '')) !== '', + 'created_at' => $item['created_at'], + 'updated_at' => $item['updated_at'], + ]; + } + + public static function delete(int $id): void + { + $row = self::find($id); + if (!$row) { + Json::fail('not_found', '模板不存在', 404); + } + Db::pdo()->prepare('DELETE FROM soon_templates WHERE id = :id')->execute(['id' => $id]); + $path = self::storageDir() . DIRECTORY_SEPARATOR . $id . '.soon'; + if (is_file($path)) { + @unlink($path); + } + } + + public static function outputThumb(int $id): void + { + $row = self::find($id); + if (!$row || (int)$row['is_active'] !== 1) { + http_response_code(404); + exit; + } + $thumb = (string)($row['thumb'] ?? ''); + if ($thumb === '' || !str_starts_with($thumb, 'data:image/')) { + http_response_code(404); + exit; + } + if (!preg_match('#^data:(image/[a-zA-Z0-9.+-]+);base64,(.+)$#', $thumb, $m)) { + http_response_code(404); + exit; + } + $bin = base64_decode($m[2], true); + if ($bin === false) { + http_response_code(404); + exit; + } + header('Content-Type: ' . $m[1]); + header('Cache-Control: public, max-age=86400'); + header('Content-Length: ' . strlen($bin)); + echo $bin; + exit; + } + + public static function outputFile(int $id): void + { + $row = self::find($id); + if (!$row || (int)$row['is_active'] !== 1) { + Json::fail('not_found', '模板不存在', 404); + } + $path = self::filePath($id); + if ($path === null) { + Json::fail('not_found', '模板文件缺失', 404); + } + $name = preg_replace('/[^\w\x{4e00}-\x{9fff}\-.]+/u', '_', (string)$row['name']); + if ($name === '') { + $name = 'template'; + } + header('Content-Type: application/json; charset=utf-8'); + header('Content-Disposition: inline; filename="' . str_replace('"', '', $name) . '.soon"'); + header('Cache-Control: public, max-age=300'); + readfile($path); + exit; + } + + private static function writeFile(int $id, string $json): void + { + $path = self::storageDir() . DIRECTORY_SEPARATOR . $id . '.soon'; + if (@file_put_contents($path, $json, LOCK_EX) === false) { + Db::pdo()->prepare('DELETE FROM soon_templates WHERE id = :id')->execute(['id' => $id]); + Json::fail('server_error', '模板文件写入失败', 500); + } + } +} diff --git a/backend-web/storage/templates/.gitkeep b/backend-web/storage/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker/php/migrate-dev-schema.php b/docker/php/migrate-dev-schema.php index 945c4a9..a3504e9 100644 --- a/docker/php/migrate-dev-schema.php +++ b/docker/php/migrate-dev-schema.php @@ -88,19 +88,21 @@ if (columnExists($pdo, 'plans', 'description')) { $seed = [ ['free', '免费版', '免费体验设计与编辑', 0, $quota, $maxFiles, 0, '["设计与编辑工具免费使用"]', 0, 0, 1], + ['member_lifetime', '永久会员', '一次激活,永久使用预览交付能力', 100, $quota, $maxFiles, 0, + '["高清预览","成品打印","云端保存","导出设计文件","永久有效"]', 10, 1, 1], ['member_monthly', '月度订阅', '按月灵活使用,随时续订', 1999, $quota, $maxFiles, 30, - '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]', 10, 1, 1], + '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 个月"]', 20, 0, 0], ['member_quarterly', '季度订阅', '连续三个月,更省心', 5299, $quota, $maxFiles, 90, - '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]', 15, 0, 1], + '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 季"]', 25, 0, 0], ['member_yearly', '年度订阅', '全年畅享,性价比更高', 19999, $quota, $maxFiles, 365, - '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]', 20, 0, 1], + '["高清预览","成品打印","云端保存","导出设计文件","订阅周期:1 年"]', 30, 0, 0], ]; $upsert = $pdo->prepare( 'INSERT INTO plans (code, name, description, price_cents, quota_mb, max_files, duration_days, features, sort_order, is_recommended, is_active) ' . 'VALUES (:c, :n, :d, :p, :q, :mf, :dd, :f, :so, :ir, :a) ' - . 'ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), price_cents=VALUES(price_cents), ' + . 'ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), price_cents=VALUES(price_cents), ' . 'quota_mb=VALUES(quota_mb), max_files=VALUES(max_files), duration_days=VALUES(duration_days), ' - . 'sort_order=VALUES(sort_order), is_recommended=VALUES(is_recommended), is_active=VALUES(is_active)' + . 'features=VALUES(features), sort_order=VALUES(sort_order), is_recommended=VALUES(is_recommended), is_active=VALUES(is_active)' ); foreach ($seed as $row) { $upsert->execute([ @@ -139,3 +141,20 @@ if (columnExists($pdo, 'plans', 'description')) { } fwrite(STDOUT, 'migrate-dev-schema: legacy plan copy refreshed' . PHP_EOL); } + +$pdo->exec( + 'CREATE TABLE IF NOT EXISTS `soon_templates` (' + . '`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,' + . '`name` VARCHAR(120) NOT NULL,' + . '`type` TINYINT UNSIGNED NOT NULL DEFAULT 1,' + . '`thumb` MEDIUMTEXT DEFAULT NULL,' + . '`file_size` INT UNSIGNED NOT NULL DEFAULT 0,' + . '`sort_order` INT NOT NULL DEFAULT 0,' + . '`is_active` TINYINT(1) NOT NULL DEFAULT 1,' + . '`created_at` DATETIME NOT NULL,' + . '`updated_at` DATETIME NOT NULL,' + . 'PRIMARY KEY (`id`),' + . 'KEY `active_sort` (`is_active`, `sort_order`, `id`)' + . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' +); +fwrite(STDOUT, 'migrate-dev-schema: soon_templates ensured' . PHP_EOL); diff --git a/docker/sync-config.ps1 b/docker/sync-config.ps1 index 37aca50..9377277 100644 --- a/docker/sync-config.ps1 +++ b/docker/sync-config.ps1 @@ -44,6 +44,7 @@ return [ 'storage' => [ 'users_dir' => '/var/www/storage/users', 'models_dir' => '/var/soonModels', + 'templates_dir' => '/var/www/storage/templates', ], 'limits' => ['free_quota_mb' => 20, 'preview_require_membership' => false], 'rate_limits' => [ diff --git a/docs/API-PAGINATION.md b/docs/API-PAGINATION.md index ebd6342..f550d7b 100644 --- a/docs/API-PAGINATION.md +++ b/docs/API-PAGINATION.md @@ -81,6 +81,7 @@ Authorization: Bearer | 路径 | 说明 | |------|------| | `GET /api/admin/plans` | 套餐配置 | +| `GET/POST /api/admin/templates`、`PUT/DELETE /api/admin/templates/{id}` | 模板库 CRUD | | `GET /api/admin/settings` | 系统键值 | | `GET /api/admin/payment/status` | PEM 文件元数据 | @@ -163,7 +164,10 @@ GET /api/v1/pay/orders?page=1&size=8 | `GET /api/v1/plans` | `{ items: [...] }` 全量活跃套餐 | | `GET /api/v1/plans/me` | 会员信息 + `recent_orders`(默认 8 条,非翻页) | | `GET /api/v1/settings` | 扁平 key-value 对象,非 `items` 数组 | -| `GET /api/v1/soon-models` | 扫描 `models_dir` 下 `.soon` 自动生成的列表 | +| `GET /api/v1/templates` | 首页模板列表(仅 `id/name/type`;缩略图与文件按需拉取) | +| `GET /api/v1/templates/{id}/thumb` | 模板缩略图(从 `.soon` 内 `frontDisplayPic` 提取) | +| `GET /api/v1/templates/{id}/file` | 完整 `.soon` JSON(点击使用时再下载) | +| `GET /api/v1/soon-models` | 兼容别名,同 `GET /api/v1/templates` | --- diff --git a/frontend-web/assets/css/subscribe-gate.css b/frontend-web/assets/css/subscribe-gate.css index f481019..c1b0d19 100644 --- a/frontend-web/assets/css/subscribe-gate.css +++ b/frontend-web/assets/css/subscribe-gate.css @@ -222,3 +222,86 @@ opacity: 0.45; cursor: not-allowed; } + +.soon-login-gate__form { + padding: 0 24px 8px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.soon-login-gate__form .soon-input-wrap { + width: 100%; +} + +.soon-login-gate__error { + color: #f87171; + font-size: 13px; + margin: 0; +} + +.soon-login-gate__links { + padding: 0 24px 16px; + font-size: 13px; + color: rgba(255, 255, 255, 0.65); +} + +.soon-login-gate__links a { + color: #7dd3fc; +} + +.soon-activate-price { + padding: 16px 20px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + flex-direction: column; + gap: 6px; +} + +.soon-activate-price__label { + font-size: 13px; + color: rgba(255, 255, 255, 0.55); +} + +.soon-activate-price__value { + font-size: 28px; + font-weight: 700; + color: #fff; +} + +.soon-activate-price__hint { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); +} + +.soon-activate-channels { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.soon-activate-channel { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + font-size: 13px; + cursor: pointer; +} + +.soon-activate-channel.is-active { + border-color: #38bdf8; + background: rgba(56, 189, 248, 0.12); +} + +.soon-activate-load { + margin: 12px 0 0; + font-size: 13px; + color: rgba(255, 255, 255, 0.55); + min-height: 18px; +} diff --git a/frontend-web/js/common/cloud-files.js b/frontend-web/js/common/cloud-files.js index 57e9f66..652eb3f 100644 --- a/frontend-web/js/common/cloud-files.js +++ b/frontend-web/js/common/cloud-files.js @@ -66,9 +66,8 @@ var _membership = { loaded: false, isMember: false, - name: '免费版', + name: '普通用户', tier: 'free', - expiresAt: null, loading: null, }; @@ -81,18 +80,6 @@ return '/api/v1'; } - function formatExpireDate(iso) { - if (!iso) return ''; - var d = new Date(String(iso).replace(' ', 'T')); - if (isNaN(d.getTime())) return ''; - var y = d.getFullYear(); - var mo = String(d.getMonth() + 1); - var day = String(d.getDate()); - if (mo.length < 2) mo = '0' + mo; - if (day.length < 2) day = '0' + day; - return y + '-' + mo + '-' + day; - } - function soonApplyMembership(data) { var m = data || {}; var sub = m.subscription || {}; @@ -103,9 +90,8 @@ _membership = { loaded: true, isMember: !!isMember, - name: m.name || (isMember ? '订阅版' : '免费版'), + name: isMember ? (m.name || '会员') : '普通用户', tier: m.tier || (isMember ? 'member' : 'free'), - expiresAt: sub.expires_at || null, loading: null, }; if (typeof window.soonRefreshPortalIdentity === 'function') { @@ -132,13 +118,10 @@ var cls = 'soon-portal-identity'; if (_membership.isMember) { cls += ' soon-portal-identity--member'; - text = _membership.name || '订阅版'; - if (_membership.expiresAt) { - text += ' · 至 ' + formatExpireDate(_membership.expiresAt); - } + text = _membership.name || '会员'; } else { cls += ' soon-portal-identity--free'; - text = _membership.name || '免费版'; + text = _membership.name || '普通用户'; } el.className = cls; el.textContent = text; @@ -182,34 +165,70 @@ return !!_membership.isMember; } - function soonMemberPageUrl() { - return 'member.web.html'; - } - - function soonRequireMember(actionLabel) { - if (!soonGetAccessToken()) return soonRequireLogin(actionLabel); - if (soonIsMember()) return true; - if (typeof window.soonShowSubscribeGate === 'function') { - window.soonShowSubscribeGate({ action: actionLabel || '使用' }); - } else { - soonToast('订阅后可' + (actionLabel || '使用'), 'warn'); + function soonGuardPreviewDeliver(actionLabel, onAllowed) { + if (typeof onAllowed !== 'function') return; + if (!soonIsWebPortal()) { + onAllowed(); + return; + } + if (!soonGetAccessToken()) { + if (typeof window.soonShowLoginGate === 'function') { + window.soonShowLoginGate({ + reason: actionLabel || '导出或打印', + onSuccess: function () { + soonGuardPreviewDeliver(actionLabel, onAllowed); + }, + }); + } else { + soonRequireLogin(actionLabel); + } + return; + } + if (soonIsMember()) { + onAllowed(); + return; + } + if (typeof window.soonShowActivateGate === 'function') { + window.soonShowActivateGate({ + reason: actionLabel || '导出或打印', + onSuccess: function () { + onAllowed(); + }, + }); + } else { + soonToast('请先激活会员后再' + (actionLabel || '操作'), 'warn'); } - return false; } function soonGuardCloudSave() { - if (!soonIsWebPortal()) return true; - return soonRequireMember('保存'); + return true; } - function soonGuardMemberPreview() { - if (!soonIsWebPortal()) return true; - return soonRequireMember('预览效果'); + function soonSoonTypeFromJson(j) { + return j && j.soonType ? j.soonType : (j && j.backBlackPic ? 2 : 1); } - function soonGuardMemberExport() { - if (!soonIsWebPortal()) return true; - return soonRequireMember('导出'); + function soonPutSoonSession(j, fileName) { + if (!j) return ''; + var hint = (fileName || 'design').replace(/\.soon$/i, ''); + var key = 'soondesign_session:' + hint + '-' + Date.now(); + try { + sessionStorage.setItem(key, JSON.stringify(j)); + try { localStorage.setItem(key, JSON.stringify(j)); } catch (e2) { /* ignore quota */ } + return key; + } catch (e) { + return ''; + } + } + + function soonOpenSoonJsonLocally(j, fileName) { + var key = soonPutSoonSession(j, fileName); + if (!key) return ''; + var type = soonSoonTypeFromJson(j); + if (window.platformBridge && window.platformBridge.openDesignPage) { + window.platformBridge.openDesignPage(key, type); + } + return key; } function soonToast(message, type) { @@ -244,14 +263,11 @@ if (response && response.status === 409) { return { status: 409, message: '文件已被其他端修改,请刷新后重试', code: code || 'conflict' }; } - if (response && response.status === 403 && (code === 'membership_required' || msg.indexOf('订阅') >= 0 || msg.indexOf('会员') >= 0)) { - return { status: 403, message: msg || '此功能需要订阅后使用', code: code || 'membership_required' }; - } if (response && response.status === 413) { if (code === 'file_limit_exceeded') { - return { status: 413, message: msg || '文件数量已达上限,请清理文件或续订', code: code }; + return { status: 413, message: msg || '文件数量已达上限,请清理文件', code: code }; } - return { status: 413, message: msg || '存储空间不足,请清理文件或续订', code: code || 'quota_exceeded' }; + return { status: 413, message: msg || '存储空间不足,请清理文件', code: code || 'quota_exceeded' }; } if (response && response.status === 404) { return { status: 404, message: '文件不存在', code: code || 'not_found' }; @@ -265,14 +281,9 @@ if (info.status === 401) kind = 'warn'; else if (info.status === 409) kind = 'warn'; else if (info.status === 413) kind = 'warn'; - else if (info.status === 403 && info.code === 'membership_required') kind = 'warn'; soonToast(info.message, kind); if (info.status === 401) { soonRequireLogin('操作'); - } else if (info.status === 403 && info.code === 'membership_required') { - if (typeof window.soonShowSubscribeGate === 'function') { - window.soonShowSubscribeGate({ action: '使用' }); - } } } @@ -290,6 +301,32 @@ return String(pathOrKey).split(/[/\\]/).pop(); } + function soonHandlePayReturn() { + var paid = null; + var flag = false; + try { + paid = new URLSearchParams(location.search).get('paid'); + } catch (e) { /* ignore */ } + try { + flag = !!sessionStorage.getItem('soon_pay_return'); + } catch (e) { /* ignore */ } + if (!paid && !flag) return; + try { + sessionStorage.removeItem('soon_pay_return'); + } catch (e) { /* ignore */ } + if (paid) { + try { + var u = new URL(location.href); + u.searchParams.delete('paid'); + history.replaceState(null, '', u.pathname + (u.search || '') + (u.hash || '')); + } catch (e) { /* ignore */ } + } + soonLoadMembership(true).then(function () { + soonRefreshPortalIdentity(); + soonToast('支付成功,会员已激活', 'success'); + }); + } + window.soonIsWebPortal = soonIsWebPortal; window.soonParseFileKey = soonParseFileKey; window.soonMakeFileKey = soonMakeFileKey; @@ -298,15 +335,23 @@ window.soonBindFileMeta = soonBindFileMeta; window.soonFormatOpenTitle = soonFormatOpenTitle; window.soonGuardCloudSave = soonGuardCloudSave; - window.soonGuardMemberPreview = soonGuardMemberPreview; - window.soonGuardMemberExport = soonGuardMemberExport; + window.soonGuardPreviewDeliver = soonGuardPreviewDeliver; + window.soonHandlePayReturn = soonHandlePayReturn; + window.soonOpenSoonJsonLocally = soonOpenSoonJsonLocally; + window.soonPutSoonSession = soonPutSoonSession; + window.soonSoonTypeFromJson = soonSoonTypeFromJson; window.soonLoadMembership = soonLoadMembership; window.soonApplyMembership = soonApplyMembership; window.soonRefreshPortalIdentity = soonRefreshPortalIdentity; window.soonIsMember = soonIsMember; - window.soonRequireMember = soonRequireMember; window.soonRequireLogin = soonRequireLogin; window.soonParseApiError = soonParseApiError; window.soonShowApiError = soonShowApiError; window.soonDisplayFileName = soonDisplayFileName; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', soonHandlePayReturn); + } else { + soonHandlePayReturn(); + } })(); diff --git a/frontend-web/js/common/member-activate.js b/frontend-web/js/common/member-activate.js new file mode 100644 index 0000000..8853b95 --- /dev/null +++ b/frontend-web/js/common/member-activate.js @@ -0,0 +1,249 @@ +(function () { + 'use strict'; + + var GATE_FEATURES = ['预览内导出成品', '预览内打印']; + var _gate = { index: null, payIndex: null, plan: null, gateOpts: null }; + + var pay = window.SoonMemberPay; + + function esc(s) { + if (pay && pay.esc) return pay.esc(s); + if (s == null) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>'); + } + + function toast(msg, type) { + if (typeof window.soonToast === 'function') window.soonToast(msg, type); + } + + function ensureLayer(cb) { + if (typeof layer !== 'undefined' && layer.open) { + if (layer.config) layer.config({ skin: 'soon-layer' }); + cb(); + return; + } + if (typeof layui !== 'undefined') { + layui.use(['layer'], function () { + window.layer = layui.layer; + layer.config({ skin: 'soon-layer' }); + cb(); + }); + return; + } + toast('激活功能暂不可用,请刷新页面后重试', 'warn'); + } + + function planPriceDisplay(plan) { + if (!plan) return ''; + return plan.price_display || (plan.price_cents / 100).toFixed(2); + } + + function shellHtml(actionLabel, plan) { + var feat = GATE_FEATURES.map(function (t) { + return '
  • ' + esc(t) + '
  • '; + }).join(''); + var price = plan ? planPriceDisplay(plan) : '—'; + var channels = (pay && pay.displayChannels) ? pay.displayChannels() : ['alipay']; + var chHtml = channels.map(function (ch, i) { + var label = ch === 'wechat' ? '微信支付' : '支付宝'; + return ''; + }).join(''); + return '
    ' + + '
    ' + + '会员激活' + + '

    激活会员

    ' + + '

    普通用户可使用全部设计工具;预览内' + esc(actionLabel || '导出或打印') + '需激活会员。

    ' + + '
      ' + feat + '
    ' + + '
    ' + + '
    ' + + '
    激活价格' + + '¥' + esc(price) + '' + + '一次激活,永久有效
    ' + + '
    ' + chHtml + '
    ' + + '

    ' + + '
    ' + + '
    ' + + '' + + '' + + '
    '; + } + + function closeGate(fireStay) { + var idx = _gate.index; + var payIdx = _gate.payIndex; + var opts = _gate.gateOpts; + _gate = { index: null, payIndex: null, plan: null, gateOpts: null }; + if (typeof layer !== 'undefined') { + if (idx != null) layer.close(idx); + if (payIdx != null) layer.close(payIdx); + } + if (fireStay && opts && typeof opts.onStay === 'function') opts.onStay(); + } + + function selectedChannel(modal) { + var checked = modal.querySelector('input[name="activate_channel"]:checked'); + return checked ? checked.value : 'alipay'; + } + + function onPaySuccess() { + var gateIdx = _gate.index; + var opts = _gate.gateOpts; + _gate.payIndex = null; + if (typeof layer !== 'undefined' && gateIdx != null) layer.close(gateIdx); + _gate = { index: null, payIndex: null, plan: null, gateOpts: null }; + toast('恭喜您,会员已激活!', 'success'); + if (typeof window.soonLoadMembership === 'function') { + window.soonLoadMembership(true).then(function () { + if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity(); + if (opts && typeof opts.onSuccess === 'function') opts.onSuccess(); + }); + return; + } + if (opts && typeof opts.onSuccess === 'function') opts.onSuccess(); + } + + function updatePayButton(modal) { + var btn = modal.querySelector('[data-action="go-pay"]'); + var plan = _gate.plan; + if (!btn) return; + if (!plan) { + btn.disabled = true; + btn.textContent = '立即激活'; + return; + } + btn.disabled = false; + btn.textContent = '立即激活 ¥' + planPriceDisplay(plan); + } + + function loadPlan(modal) { + var statusEl = modal.querySelector('[data-role="plan-status"]'); + if (!pay || !pay.apiGet) { + if (statusEl) statusEl.textContent = '激活模块未加载,请刷新页面'; + updatePayButton(modal); + return; + } + if (statusEl) statusEl.textContent = '正在加载激活方案…'; + updatePayButton(modal); + pay.apiGet('/plans').then(function (ps) { + if (!ps.ok) { + if (statusEl) statusEl.textContent = ps.message || '方案加载失败'; + updatePayButton(modal); + return; + } + var items = (ps.data && ps.data.items) || []; + var plan = items[0] || null; + if (!plan) { + if (statusEl) statusEl.textContent = '暂无可用激活方案'; + updatePayButton(modal); + return; + } + _gate.plan = plan; + if (statusEl) statusEl.textContent = ''; + updatePayButton(modal); + }).catch(function () { + if (statusEl) statusEl.textContent = '网络错误,请稍后重试'; + updatePayButton(modal); + }); + } + + function bindModal(modal) { + modal.addEventListener('click', function (e) { + var stay = e.target.closest('[data-action="stay"]'); + if (stay) { + e.preventDefault(); + closeGate(true); + return; + } + var goPay = e.target.closest('[data-action="go-pay"]'); + if (goPay) { + e.preventDefault(); + if (goPay.disabled || !_gate.plan || !pay || !pay.openPayModal) return; + if (_gate.payIndex != null) { + try { layer.close(_gate.payIndex); } catch (err) { /* ignore */ } + _gate.payIndex = null; + } + _gate.payIndex = pay.openPayModal({ + planId: _gate.plan.id, + planName: _gate.plan.name, + priceDisplay: planPriceDisplay(_gate.plan), + channel: selectedChannel(modal), + shadeClose: true, + onPaid: onPaySuccess, + onClose: function () { + _gate.payIndex = null; + updatePayButton(modal); + }, + }); + } + var ch = e.target.closest('.soon-activate-channel'); + if (ch) { + modal.querySelectorAll('.soon-activate-channel').forEach(function (el) { + el.classList.remove('is-active'); + }); + ch.classList.add('is-active'); + var input = ch.querySelector('input[type="radio"]'); + if (input) input.checked = true; + } + }); + loadPlan(modal); + } + + function soonShowActivateGate(opts) { + opts = opts || {}; + if (!pay) { + toast('激活功能暂不可用,请刷新页面后重试', 'warn'); + return; + } + if (pay.loadDisplayChannels) pay.loadDisplayChannels(); + ensureLayer(function () { + if (_gate.index != null) { + try { layer.close(_gate.index); } catch (e) { /* ignore */ } + } + if (_gate.payIndex != null) { + try { layer.close(_gate.payIndex); } catch (e) { /* ignore */ } + } + _gate = { index: null, payIndex: null, plan: null, gateOpts: opts }; + var width = Math.min(520, window.innerWidth - 24); + layer.open({ + type: 1, + skin: 'soon-layer', + title: false, + closeBtn: 1, + shadeClose: true, + area: [width + 'px', 'auto'], + content: shellHtml(opts.reason, null), + success: function (layero, index) { + var layerEl = layero && layero[0] ? layero[0] : layero; + if (layerEl && layerEl.classList) layerEl.classList.add('soon-layer--subscribe'); + var content = layerEl && layerEl.querySelector ? layerEl.querySelector('.layui-layer-content') : null; + if (content) content.style.padding = '0'; + var modal = layerEl.querySelector('.soon-activate-modal'); + _gate.index = index; + _gate.gateOpts = opts; + if (modal) bindModal(modal); + }, + end: function () { + var payIdx = _gate.payIndex; + if (payIdx != null) { + try { layer.close(payIdx); } catch (e) { /* ignore */ } + } + _gate = { index: null, payIndex: null, plan: null, gateOpts: null }; + }, + }); + }); + } + + window.soonShowActivateGate = soonShowActivateGate; + + if (!window._soonActivateFocusBound) { + window._soonActivateFocusBound = true; + window.addEventListener('focus', function () { + if (typeof window.soonLoadMembership !== 'function') return; + window.soonLoadMembership(true).then(function () { + if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity(); + }); + }); + } +})(); diff --git a/frontend-web/js/common/member-login-gate.js b/frontend-web/js/common/member-login-gate.js new file mode 100644 index 0000000..a0b2e40 --- /dev/null +++ b/frontend-web/js/common/member-login-gate.js @@ -0,0 +1,152 @@ +(function () { + 'use strict'; + + var _gate = { index: null, opts: null }; + + function esc(s) { + if (s == null) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>'); + } + + function toast(msg, type) { + if (typeof window.soonToast === 'function') window.soonToast(msg, type); + } + + function apiBase() { + var cfg = window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base; + if (cfg) return String(cfg).replace(/\/+$/, ''); + if (window.location) return window.location.origin + '/api/v1'; + return '/api/v1'; + } + + function ensureLayer(cb) { + if (typeof layer !== 'undefined' && layer.open) { + if (layer.config) layer.config({ skin: 'soon-layer' }); + cb(); + return; + } + if (typeof layui !== 'undefined') { + layui.use(['layer'], function () { + window.layer = layui.layer; + layer.config({ skin: 'soon-layer' }); + cb(); + }); + return; + } + toast('登录功能暂不可用,请刷新页面后重试', 'warn'); + } + + function shellHtml(reason) { + return ''; + } + + function closeGate() { + var idx = _gate.index; + _gate = { index: null, opts: null }; + if (idx != null && typeof layer !== 'undefined') { + try { layer.close(idx); } catch (e) { /* ignore */ } + } + } + + function bindModal(modal) { + var form = modal.querySelector('[data-role="login-form"]'); + var errEl = modal.querySelector('[data-role="login-error"]'); + modal.addEventListener('click', function (e) { + var stay = e.target.closest('[data-action="stay"]'); + if (stay) { + e.preventDefault(); + closeGate(); + } + }); + if (!form) return; + form.addEventListener('submit', function (e) { + e.preventDefault(); + if (errEl) { + errEl.style.display = 'none'; + errEl.textContent = ''; + } + var email = (form.email && form.email.value || '').trim(); + var password = form.password ? form.password.value : ''; + fetch(apiBase() + '/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email, password: password }), + }).then(function (r) { return r.json(); }).then(function (j) { + if (!j.ok || !j.data || !j.data.access_token) { + if (errEl) { + errEl.textContent = (j && j.message) || '登录失败'; + errEl.style.display = 'block'; + } + return; + } + localStorage.setItem('soon_access', j.data.access_token); + if (j.data.refresh_token) localStorage.setItem('soon_refresh', j.data.refresh_token); + var opts = _gate.opts; + closeGate(); + var load = typeof window.soonLoadMembership === 'function' + ? window.soonLoadMembership(true) + : Promise.resolve(); + load.then(function () { + if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity(); + if (typeof window.soonReloadRecentFiles === 'function') window.soonReloadRecentFiles(); + if (opts && typeof opts.onSuccess === 'function') opts.onSuccess(); + }); + }).catch(function () { + if (errEl) { + errEl.textContent = '网络错误,请稍后重试'; + errEl.style.display = 'block'; + } + }); + }); + } + + function soonShowLoginGate(opts) { + opts = opts || {}; + ensureLayer(function () { + if (_gate.index != null) { + try { layer.close(_gate.index); } catch (e) { /* ignore */ } + } + _gate.opts = opts; + var width = Math.min(420, window.innerWidth - 24); + layer.open({ + type: 1, + skin: 'soon-layer', + title: false, + closeBtn: 1, + shadeClose: true, + area: [width + 'px', 'auto'], + content: shellHtml(opts.reason), + success: function (layero, index) { + var layerEl = layero && layero[0] ? layero[0] : layero; + if (layerEl && layerEl.classList) layerEl.classList.add('soon-layer--subscribe'); + var content = layerEl && layerEl.querySelector ? layerEl.querySelector('.layui-layer-content') : null; + if (content) content.style.padding = '0'; + var modal = layerEl.querySelector('.soon-login-gate'); + _gate.index = index; + _gate.opts = opts; + if (modal) bindModal(modal); + }, + end: function () { + _gate = { index: null, opts: null }; + }, + }); + }); + } + + window.soonShowLoginGate = soonShowLoginGate; +})(); diff --git a/frontend-web/js/common/member-pay-core.js b/frontend-web/js/common/member-pay-core.js index d58e80f..8b9d399 100644 --- a/frontend-web/js/common/member-pay-core.js +++ b/frontend-web/js/common/member-pay-core.js @@ -2,7 +2,7 @@ 'use strict'; var POLL_TIMEOUT_MS = 5 * 60 * 1000; - var PAY_STEP_LABELS = ['选择支付', '扫码付款', '订阅生效']; + var PAY_STEP_LABELS = ['选择支付', '扫码付款', '激活生效']; var _displayChannels = ['alipay']; var _settingsPromise = null; @@ -260,24 +260,7 @@ return '未获取到支付信息'; } - function monthlyPlan(items) { - var found = null; - (items || []).forEach(function (p) { - if (p.code === 'member_monthly' || p.code === 'pro_monthly') found = p; - }); - return found; - } - - function billingSavePercent(items, plan) { - var monthly = monthlyPlan(items); - if (!monthly || monthly.price_cents <= 0 || !plan || plan.duration_days <= 30) return null; - var months = plan.duration_days / 30; - var full = monthly.price_cents * months; - if (full <= plan.price_cents) return null; - return Math.round((1 - plan.price_cents / full) * 100); - } - - function paySheetHtml(opts) { + function payEmptyStateHtml(channel) { opts = opts || {}; var resumeOrderNo = opts.orderNo || null; var channels = opts.displayChannels || _displayChannels; @@ -291,7 +274,7 @@ var headBlock = embedded ? '' : '
    ' + '
    ' + - '' + (resumeOrderNo ? '继续支付' : '确认订阅') + '' + + '' + (resumeOrderNo ? '继续支付' : '确认激活') + '' + '

    ' + esc(opts.planName || '') + '

    ' + '
  • ' + CHECK_SVG + '' + esc(text) + '
  • '; - } - - function billingSavePercent(items, plan) { - if (window.SoonMemberPay && window.SoonMemberPay.billingSavePercent) { - return window.SoonMemberPay.billingSavePercent(items, plan); - } - return null; - } - - function planCardHtml(plan, ctx) { - ctx = ctx || {}; - var items = ctx.allPlans || []; - var mode = ctx.mode || 'page'; - var isMember = !!ctx.isMember; - var currentCode = ctx.currentCode || ''; - var selectedId = ctx.selectedId; - - var feat = plan.is_recommended ? ' soon-plan-card--featured' : ''; - var cur = plan.code === currentCode ? ' soon-plan-card--current' : ''; - var isGate = mode === 'gate'; - var isPicker = mode === 'picker' || isGate; - var picker = isPicker ? ' soon-plan-card--picker' : ''; - if (isGate) picker += ' soon-plan-card--gate'; - var selected = isPicker && plan.id === selectedId ? ' soon-plan-card--selected' : ''; - - var ribbon = plan.is_recommended - ? '推荐' - : (plan.code === currentCode ? '当前' : ''); - var savePct = billingSavePercent(items, plan); - if (savePct) ribbon += '省 ' + savePct + '%'; - var badgeCls = ribbon ? ' soon-plan-card--badged' : ''; - if (savePct) badgeCls += ' soon-plan-card--save'; - - var period = plan.duration_days > 0 - ? '/' + esc(plan.period_label || plan.duration_days + '天') + '' - : ''; - var features = (plan.features || []).filter(isPlanFeatureLine).map(featureItem).join(''); - var desc = plan.description || ''; - var price = esc(plan.price_display || (plan.price_cents / 100).toFixed(2)); - - var inner = - ribbon + - '
    ' + - '
    ' + esc(plan.name) + '
    ' + - (desc ? '

    ' + esc(desc) + '

    ' : '') + - '
    ' + - '¥' + - '' + price + '' + period + '
    '; - if (!isGate) { - inner += '
    ' + - '
      ' + features + '
    '; - } - - if (isPicker) { - return ''; - } - - var cta = ''; - return '
    ' + inner + - '
    ' + cta + '
    '; - } - - function plansGridHtml(plans, ctx) { - return (plans || []).map(function (p) { - return planCardHtml(p, ctx); - }).join(''); - } - - window.SoonMemberPlan = { - isPlanFeatureLine: isPlanFeatureLine, - planCardHtml: planCardHtml, - plansGridHtml: plansGridHtml, - }; -})(); diff --git a/frontend-web/js/common/member-subscribe.js b/frontend-web/js/common/member-subscribe.js deleted file mode 100644 index 76a3a1f..0000000 --- a/frontend-web/js/common/member-subscribe.js +++ /dev/null @@ -1,306 +0,0 @@ -(function () { - 'use strict'; - - var GATE_FEATURES = ['高清预览', '成品打印', '云端保存', '导出设计文件']; - var _gate = { index: null, payIndex: null, plans: [], selectedId: null, gateOpts: null }; - - var pay = window.SoonMemberPay; - var plansUi = window.SoonMemberPlan; - - function esc(s) { - if (pay && pay.esc) return pay.esc(s); - if (s == null) return ''; - return String(s).replace(/&/g, '&').replace(//g, '>'); - } - - function toast(msg, type) { - if (typeof window.soonToast === 'function') window.soonToast(msg, type); - } - - function emptyGate() { - return { index: null, payIndex: null, plans: [], selectedId: null, gateOpts: null }; - } - - function ensureLayer(cb) { - if (typeof layer !== 'undefined' && layer.open) { - if (layer.config) layer.config({ skin: 'soon-layer' }); - cb(); - return; - } - if (typeof layui !== 'undefined') { - layui.use(['layer'], function () { - window.layer = layui.layer; - layer.config({ skin: 'soon-layer' }); - cb(); - }); - return; - } - toast('订阅功能暂不可用,请刷新页面后重试', 'warn'); - } - - function actionCopy(actionLabel) { - var label = (actionLabel || '使用').trim(); - if (label.indexOf('预览') >= 0 || label.indexOf('导出') >= 0) return '预览、打印与导出成品'; - if (label.indexOf('保存') >= 0 || label.indexOf('另存') >= 0) return '保存或另存到云端'; - if (label.indexOf('下载') >= 0) return '下载设计文件'; - return label; - } - - function shellHtml(actionLabel) { - var feat = GATE_FEATURES.map(function (t) { - return '
  • ' + esc(t) + '
  • '; - }).join(''); - return '
    ' + - '
    ' + - '订阅功能' + - '

    开通订阅,解锁完整能力

    ' + - '

    免费版可使用设计工具;订阅后可' + esc(actionCopy(actionLabel)) + '。

    ' + - '
      ' + feat + '
    ' + - '
    ' + - '
    ' + - '
    ' + - '

    订阅方案

    ' + - '

    选择订阅周期,功能相同,随时续订

    ' + - '
    ' + - '

    正在加载订阅方案…

    ' + - '
    ' + - '
    ' + - '' + - '' + - '
    '; - } - - function findPlan(id) { - var pid = Number(id); - for (var i = 0; i < _gate.plans.length; i++) { - if (_gate.plans[i].id === pid) return _gate.plans[i]; - } - return null; - } - - function planPriceDisplay(plan) { - if (!plan) return ''; - return plan.price_display || (plan.price_cents / 100).toFixed(2); - } - - function renderPlanPicker(modal) { - var picker = modal.querySelector('[data-role="plan-picker"]'); - if (!picker || !plansUi) return; - if (!_gate.plans.length) { - picker.innerHTML = '

    暂无可用订阅方案

    '; - return; - } - picker.innerHTML = plansUi.plansGridHtml(_gate.plans, { - allPlans: _gate.plans, - selectedId: _gate.selectedId, - mode: 'gate', - }); - } - - function updatePayButton(modal) { - var btn = modal.querySelector('[data-action="go-pay"]'); - var plan = findPlan(_gate.selectedId); - if (!btn) return; - if (!plan) { - btn.disabled = true; - btn.textContent = '立即支付'; - return; - } - btn.disabled = false; - btn.textContent = '立即支付 ¥' + planPriceDisplay(plan); - } - - function selectPlan(modal, planId) { - var plan = findPlan(planId); - if (!plan) return; - _gate.selectedId = plan.id; - renderPlanPicker(modal); - updatePayButton(modal); - } - - function closeGate(fireStay) { - var idx = _gate.index; - var payIdx = _gate.payIndex; - var opts = _gate.gateOpts; - _gate = emptyGate(); - if (typeof layer !== 'undefined') { - if (idx != null) layer.close(idx); - if (payIdx != null) layer.close(payIdx); - } - if (fireStay && opts && typeof opts.onStay === 'function') opts.onStay(); - } - - function onPaySuccess() { - var gateIdx = _gate.index; - var opts = _gate.gateOpts; - _gate = emptyGate(); - if (typeof layer !== 'undefined') { - if (gateIdx != null) layer.close(gateIdx); - } - toast('恭喜您,订阅已成功生效!', 'success'); - if (typeof window.soonLoadMembership === 'function') { - window.soonLoadMembership(true).then(function () { - if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity(); - }); - } - if (opts && typeof opts.onSuccess === 'function') opts.onSuccess(); - } - - function openPayStep(modal) { - var plan = findPlan(_gate.selectedId); - if (!plan || !pay || !pay.openPayModal) { - toast('请先选择订阅方案', 'warn'); - return; - } - if (_gate.payIndex != null) { - try { layer.close(_gate.payIndex); } catch (e) { /* ignore */ } - _gate.payIndex = null; - } - var priceDisplay = planPriceDisplay(plan); - _gate.payIndex = pay.openPayModal({ - planId: plan.id, - planName: plan.name, - priceDisplay: priceDisplay, - shadeClose: true, - onPaid: onPaySuccess, - onClose: function () { - _gate.payIndex = null; - updatePayButton(modal); - }, - }); - } - - function renderPlansError(modal, message) { - var picker = modal.querySelector('[data-role="plan-picker"]'); - if (!picker) return; - picker.innerHTML = '

    ' + esc(message) + - '

    '; - updatePayButton(modal); - } - - function loadPlans(modal) { - var picker = modal.querySelector('[data-role="plan-picker"]'); - if (!picker) return; - if (!pay || !plansUi) { - renderPlansError(modal, '订阅模块未加载,请刷新页面'); - return; - } - picker.innerHTML = '

    正在加载订阅方案…

    '; - updatePayButton(modal); - pay.apiGet('/plans').then(function (ps) { - if (!ps.ok) { - renderPlansError(modal, ps.message || '方案加载失败'); - return; - } - var paid = ((ps.data && ps.data.items) || []).filter(function (p) { - return p.code !== 'free' && p.price_cents > 0; - }); - if (!paid.length) { - renderPlansError(modal, '暂无可用订阅方案'); - return; - } - _gate.plans = paid; - var def = paid[0]; - for (var i = 0; i < paid.length; i++) { - if (paid[i].is_recommended) { def = paid[i]; break; } - } - selectPlan(modal, def.id); - }).catch(function () { - renderPlansError(modal, '网络错误,请稍后重试'); - }); - } - - function bindModal(modal) { - modal.addEventListener('click', function (e) { - var stay = e.target.closest('[data-action="stay"]'); - if (stay) { - e.preventDefault(); - closeGate(true); - return; - } - var retry = e.target.closest('[data-action="retry-plans"]'); - if (retry) { - e.preventDefault(); - loadPlans(modal); - return; - } - var goPay = e.target.closest('[data-action="go-pay"]'); - if (goPay) { - e.preventDefault(); - if (!goPay.disabled) openPayStep(modal); - return; - } - var pick = e.target.closest('[data-action="pick-plan"]'); - if (pick) { - e.preventDefault(); - var id = Number(pick.dataset.id); - if (id) selectPlan(modal, id); - } - }); - loadPlans(modal); - } - - function openGate(opts) { - opts = opts || {}; - if (_gate.index != null) { - try { layer.close(_gate.index); } catch (e) { /* ignore */ } - } - if (_gate.payIndex != null) { - try { layer.close(_gate.payIndex); } catch (e) { /* ignore */ } - } - _gate = emptyGate(); - _gate.gateOpts = opts; - - var width = Math.min(1000, window.innerWidth - 24); - layer.open({ - type: 1, - skin: 'soon-layer', - title: false, - closeBtn: 1, - shadeClose: true, - area: [width + 'px', 'auto'], - content: shellHtml(opts.action), - success: function (layero, index) { - var layerEl = layero && layero[0] ? layero[0] : layero; - if (layerEl && layerEl.classList) layerEl.classList.add('soon-layer--subscribe'); - var content = layerEl && layerEl.querySelector ? layerEl.querySelector('.layui-layer-content') : null; - if (content) content.style.padding = '0'; - var modal = layerEl.querySelector('.soon-subscribe-modal'); - if (!modal) return; - _gate.index = index; - _gate.gateOpts = opts; - bindModal(modal); - }, - end: function () { - var payIdx = _gate.payIndex; - var closedOpts = _gate.gateOpts; - if (payIdx != null) { - try { layer.close(payIdx); } catch (e) { /* ignore */ } - } - _gate = emptyGate(); - if (closedOpts && closedOpts.onClose) closedOpts.onClose(); - }, - }); - } - - function soonShowSubscribeGate(opts) { - if (!pay || !plansUi) { - toast('订阅功能暂不可用,请刷新页面后重试', 'warn'); - return false; - } - ensureLayer(function () { openGate(opts || {}); }); - return false; - } - - window.soonShowSubscribeGate = soonShowSubscribeGate; - - if (!window._soonSubscribeFocusBound) { - window._soonSubscribeFocusBound = true; - window.addEventListener('focus', function () { - if (typeof window.soonLoadMembership !== 'function') return; - window.soonLoadMembership(true).then(function () { - if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity(); - }); - }); - } -})(); diff --git a/frontend-web/js/common/portal-auth.js b/frontend-web/js/common/portal-auth.js index 130a30d..5ef213a 100644 --- a/frontend-web/js/common/portal-auth.js +++ b/frontend-web/js/common/portal-auth.js @@ -24,7 +24,6 @@ var tok = localStorage.getItem('soon_access') || ''; var login = document.getElementById('auth_login'); var reg = document.getElementById('auth_register'); - var member = document.getElementById('auth_member'); var admin = document.getElementById('auth_admin'); var logout = document.getElementById('auth_logout'); var avatar = document.getElementById('auth_avatar'); @@ -34,7 +33,6 @@ showAuthedNav(); if (login) login.style.display = 'none'; if (reg) reg.style.display = 'none'; - if (member) member.style.display = 'inline-flex'; if (logout) logout.style.display = 'inline-flex'; var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || ''; diff --git a/frontend-web/js/common/portal-topbar.js b/frontend-web/js/common/portal-topbar.js index a1be4d0..455547d 100644 --- a/frontend-web/js/common/portal-topbar.js +++ b/frontend-web/js/common/portal-topbar.js @@ -3,7 +3,6 @@ var STANDARD_LINKS = [ { id: 'home', href: 'index.web.html', label: '设计首页', show: 'always' }, - { id: 'member', href: 'member.web.html', label: '订阅', show: 'authed', elId: 'auth_member' }, { id: 'admin', href: 'admin/index.html', label: '管理', show: 'admin', elId: 'auth_admin' }, ]; @@ -22,7 +21,6 @@ '' + '
    ' + ''; @@ -30,7 +28,7 @@ function renderLinks(links, active) { return links.map(function (lnk) { - if (lnk.id === 'member' || lnk.id === 'admin') return ''; + if (lnk.id === 'admin') return ''; var cls = 'soon-portal-topbar__link' + (active === lnk.id ? ' is-active' : ''); return '' + esc(lnk.label) + ''; }).join(''); @@ -46,7 +44,7 @@ /** * @param {object} opts * @param {string} [opts.variant] standard | auth | design - * @param {string} [opts.active] home | member + * @param {string} [opts.active] home * @param {string} [opts.extraClass] */ function html(opts) { diff --git a/frontend-web/js/design1/output.js b/frontend-web/js/design1/output.js index 2bad78b..7ae2711 100644 --- a/frontend-web/js/design1/output.js +++ b/frontend-web/js/design1/output.js @@ -52,9 +52,7 @@ async function saveImageAsPNG(buffer) { } } -// 将 display_func 附加到 window 对象,确保全局可访问 window.display_func = function display_func(img1, img2, img3) { - if (typeof window.soonGuardMemberPreview === 'function' && !window.soonGuardMemberPreview()) return; $("#base_control").hide(); $("#line_control").hide(); $("#pic_control").hide(); @@ -418,16 +416,33 @@ window.display_func = function display_func(img1, img2, img3) { `, btn: [language_str("output"), "打印"],//'导出' - btn1: function (index, layero) { - saveImageAsPNG(buffer); + btn1: function () { + if (typeof window.soonGuardPreviewDeliver === 'function') { + window.soonGuardPreviewDeliver(language_str('output') || '导出', function () { + saveImageAsPNG(buffer); + }); + } else { + saveImageAsPNG(buffer); + } + return false; }, btn2: function () { - if (window.platformBridge && window.platformBridge.printPdf) { + if (typeof window.soonGuardPreviewDeliver === 'function') { + window.soonGuardPreviewDeliver('打印', function () { + if (window.platformBridge && window.platformBridge.printPdf) { + var blob = buffer instanceof Uint8Array ? new Blob([buffer], { type: 'image/png' }) : new Blob([buffer], { type: 'image/png' }); + window.platformBridge.printPdf(blob); + } else if (typeof printJS === 'function') { + printJS({ printable: url3, type: 'image', style: 'img { width: 100%; height: auto; }' }); + } + }); + } else if (window.platformBridge && window.platformBridge.printPdf) { var blob = buffer instanceof Uint8Array ? new Blob([buffer], { type: 'image/png' }) : new Blob([buffer], { type: 'image/png' }); window.platformBridge.printPdf(blob); } else if (typeof printJS === 'function') { printJS({ printable: url3, type: 'image', style: 'img { width: 100%; height: auto; }' }); } + return false; }, end: function () { if (printBlobUrl) try { URL.revokeObjectURL(printBlobUrl); } catch (e) {} @@ -456,7 +471,6 @@ window.display_func = function display_func(img1, img2, img3) { // 将 output 附加到 window 对象,确保全局可访问 window.output = function output(callback = null, _save = save) { - if (typeof window.soonGuardMemberExport === 'function' && !window.soonGuardMemberExport()) return; // 类型改变 fabric.Image.fromURL(soonAsset('op_1.png'), function (i1) { i1.left = background_image.left; @@ -1395,7 +1409,6 @@ function save(op1, callback) { } window.saveHistory = function saveHistory() { - if (typeof window.soonIsWebPortal === 'function' && window.soonIsWebPortal()) return; function doWrite(j) { var currentPath = openAs.name; if (!currentPath) return; diff --git a/frontend-web/js/design1/ui.js b/frontend-web/js/design1/ui.js index 0c82cb6..34f9929 100644 --- a/frontend-web/js/design1/ui.js +++ b/frontend-web/js/design1/ui.js @@ -1355,7 +1355,6 @@ $("#open").click(function () { OpenDialog(); }); function OpenDialog() { - if (typeof window.soonRequireLogin === 'function' && !window.soonRequireLogin('打开文件')) return; var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showOpenDialog ? { showOpenDialog: function(opts) { return window.platformBridge.showOpenDialog(opts); } } : null); if (!dialogApi) return; dialogApi.showOpenDialog({ @@ -1376,6 +1375,14 @@ $("#open").click(function () { } } function importAndOpen(j, fileName) { + var tok = typeof window.soonGetAccessToken === 'function' + ? window.soonGetAccessToken() + : (localStorage.getItem('soon_access') || ''); + if (!tok && typeof window.soonPutSoonSession === 'function') { + var localKey = window.soonPutSoonSession(j, fileName || 'design.soon'); + if (localKey) openWithKey(localKey, j); + return; + } if (!window.platformBridge || !window.platformBridge.importSoonFile) return; window.platformBridge.importSoonFile(fileName || 'design.soon', j).then(function(res) { if (res && res.fileKey) openWithKey(res.fileKey, j); @@ -2365,7 +2372,6 @@ $("#help").click(function() { // =========================================================== $('#display').on('click', function () { - if (typeof window.soonGuardMemberPreview === 'function' && !window.soonGuardMemberPreview()) return; // 类型改变 fabric.Image.fromURL(soonAsset('front_bg') + bg_version + '_1.png', function (i1) { i1.left = background_image.left; diff --git a/frontend-web/js/design2/output.js b/frontend-web/js/design2/output.js index 16958c0..71e2598 100644 --- a/frontend-web/js/design2/output.js +++ b/frontend-web/js/design2/output.js @@ -1,6 +1,4 @@ -// 将 display_func 附加到 window 对象,确保全局可访问 window.display_func = function display_func(img1, img2, img3) { - if (typeof window.soonGuardMemberPreview === 'function' && !window.soonGuardMemberPreview()) return; $('#base_control').hide() $('#line_control').hide() $('#pic_control').hide() @@ -356,29 +354,55 @@ window.display_func = function display_func(img1, img2, img3) { `, btn: btns, //'导出' - btn1: function (index, layero) { - if (typeof window.savePdf === 'function') { + btn1: function () { + if (typeof window.soonGuardPreviewDeliver === 'function') { + window.soonGuardPreviewDeliver('导出', function () { + if (typeof window.savePdf === 'function') window.savePdf(pdfBlob); + }); + } else if (typeof window.savePdf === 'function') { window.savePdf(pdfBlob); } + return false; }, btn2: function () { - if (window.platformBridge && window.platformBridge.printPdf && (printPath3.indexOf('data:') === 0 || printPath4.indexOf('data:') === 0)) { + if (typeof window.soonGuardPreviewDeliver === 'function') { + window.soonGuardPreviewDeliver('打印', function () { + if (window.platformBridge && window.platformBridge.printPdf && (printPath3.indexOf('data:') === 0 || printPath4.indexOf('data:') === 0)) { + if (btns[1] === '打印正面') window.platformBridge.printPdf(pdfBlob); + else if (btns[1] === '打印背面') window.platformBridge.printPdf(pdfBlob); + return; + } + if (btns[1] === '打印正面') { + printJS({ printable: printPath3, type: 'image', style: 'img { width: 100%; height: auto; }' }); + } else if (btns[1] === '打印背面') { + printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' }); + } + }); + } else if (window.platformBridge && window.platformBridge.printPdf && (printPath3.indexOf('data:') === 0 || printPath4.indexOf('data:') === 0)) { if (btns[1] === '打印正面') window.platformBridge.printPdf(pdfBlob); else if (btns[1] === '打印背面') window.platformBridge.printPdf(pdfBlob); - return; - } - if (btns[1] === '打印正面') { + } else if (btns[1] === '打印正面') { printJS({ printable: printPath3, type: 'image', style: 'img { width: 100%; height: auto; }' }); } else if (btns[1] === '打印背面') { printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' }); } + return false; }, btn3: function () { - if (window.platformBridge && window.platformBridge.printPdf) { + if (typeof window.soonGuardPreviewDeliver === 'function') { + window.soonGuardPreviewDeliver('打印', function () { + if (window.platformBridge && window.platformBridge.printPdf) { + window.platformBridge.printPdf(pdfBlob); + return; + } + printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' }); + }); + } else if (window.platformBridge && window.platformBridge.printPdf) { window.platformBridge.printPdf(pdfBlob); - return; + } else { + printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' }); } - printJS({ printable: printPath4, type: 'image', style: 'img { width: 100%; height: auto; }' }); + return false; }, end: function () { // 预览窗口关闭后,恢复所有对象的 selectable 和 evented 状态 @@ -406,7 +430,6 @@ window.display_func = function display_func(img1, img2, img3) { // 将 output 附加到 window 对象,确保全局可访问 window.output = function output(callback = null, _save = save) { - if (typeof window.soonGuardMemberExport === 'function' && !window.soonGuardMemberExport()) return; // 类型改变 fabric.Image.fromURL(soonAsset('op_2.png'), function (i1) { i1.left = background_image.left @@ -1215,7 +1238,6 @@ function save(op1, callback) { } window.saveHistory = function saveHistory() { - if (typeof window.soonIsWebPortal === 'function' && window.soonIsWebPortal()) return; function doWrite(j) { var currentPath = openAs.name; if (!currentPath) return; diff --git a/frontend-web/js/design2/ui.js b/frontend-web/js/design2/ui.js index 965f524..a0eb885 100644 --- a/frontend-web/js/design2/ui.js +++ b/frontend-web/js/design2/ui.js @@ -1311,7 +1311,6 @@ $('#open').click(function () { } ) function OpenDialog() { - if (typeof window.soonRequireLogin === 'function' && !window.soonRequireLogin('打开文件')) return; var dialogApi = (typeof dialog !== 'undefined' && dialog) ? dialog : (window.platformBridge && window.platformBridge.showOpenDialog ? { showOpenDialog: function(opts) { return window.platformBridge.showOpenDialog(opts); } } : null); if (!dialogApi) return; dialogApi.showOpenDialog({ title: '请选择文件', buttonLabel: language_str('comf'), filters: [{ name: 'Soon File Type', extensions: ['soon'] }] }) @@ -1327,6 +1326,14 @@ $('#open').click(function () { if (typeof window.openFile === 'function') window.openFile(fileKey, j); } function importAndOpen(j, fileName) { + var tok = typeof window.soonGetAccessToken === 'function' + ? window.soonGetAccessToken() + : (localStorage.getItem('soon_access') || ''); + if (!tok && typeof window.soonPutSoonSession === 'function') { + var localKey = window.soonPutSoonSession(j, fileName || 'design.soon'); + if (localKey) openWithKey(localKey, j); + return; + } if (!window.platformBridge || !window.platformBridge.importSoonFile) return; window.platformBridge.importSoonFile(fileName || 'design.soon', j).then(function(res) { if (res && res.fileKey) openWithKey(res.fileKey, j); @@ -2694,7 +2701,6 @@ $("#help").click(function() { // =========================================================== $('#display').off('click').on('click', function () { - if (typeof window.soonGuardMemberPreview === 'function' && !window.soonGuardMemberPreview()) return; // 类型改变 fabric.Image.fromURL(soonAsset('front_bg') + bg_version + '_2.png', function (i1) { i1.left = background_image.left diff --git a/frontend-web/js/index.js b/frontend-web/js/index.js index 5046d29..53617be 100644 --- a/frontend-web/js/index.js +++ b/frontend-web/js/index.js @@ -205,9 +205,111 @@ layui.use(['layer', 'form', 'jquery'], function () { : ('soondesign_file:' + id + ':v' + version); } - var fileListState = { page: 1, size: 12, total: 0 }; + var fileListState = { page: 1, size: 12, total: 0, items: [] }; var templateListState = { page: 1, size: 8, total: 0, items: [] }; + function displayNameFromPath(path) { + if (!path) return 'design.soon'; + var sessionPrefix = 'soondesign_session:'; + if (path.indexOf(sessionPrefix) === 0) return path.substring(sessionPrefix.length); + if (path.indexOf('soondesign_file:') === 0) { + var meta = window._soonFileMeta; + if (meta && meta.name) return meta.name; + return path.replace(/^soondesign_file:(\d+).*/, '文件 #$1'); + } + return get_filename(path) || 'design.soon'; + } + + function parseItemTime(value) { + if (!value) return 0; + var d = new Date(String(value).replace(' ', 'T')); + return isNaN(d.getTime()) ? 0 : d.getTime(); + } + + function mergeRecentItems(localHistory, cloudItems) { + var items = []; + var cloudById = {}; + var seenCloudIds = {}; + (cloudItems || []).forEach(function (c) { + if (c && c.id != null) cloudById[c.id] = c; + }); + + (localHistory || []).forEach(function (h) { + if (!h || !h.path) return; + var cloudMatch = String(h.path).match(/^soondesign_file:(\d+)/); + if (cloudMatch) { + var id = parseInt(cloudMatch[1], 10); + var cloud = cloudById[id]; + if (cloud) { + seenCloudIds[id] = true; + items.push({ + kind: 'cloud', + filePath: makeFileKey(cloud.id, cloud.version), + fileId: cloud.id, + name: cloud.name || ('文件 #' + cloud.id), + type: h.type, + sortTime: parseItemTime(cloud.updated_at || cloud.created_at || h.time), + }); + return; + } + } + items.push({ + kind: 'local', + filePath: h.path, + name: displayNameFromPath(h.path), + type: h.type, + sortTime: parseItemTime(h.time), + }); + }); + + (cloudItems || []).forEach(function (c) { + if (!c || c.id == null || seenCloudIds[c.id]) return; + items.push({ + kind: 'cloud', + filePath: makeFileKey(c.id, c.version), + fileId: c.id, + name: c.name || ('文件 #' + c.id), + type: null, + sortTime: parseItemTime(c.updated_at || c.created_at), + }); + }); + + items.sort(function (a, b) { return (b.sortTime || 0) - (a.sortTime || 0); }); + return items; + } + + async function fetchLocalHistory() { + if (!window.sysAPI || typeof window.sysAPI.readHistory !== 'function') return []; + try { + var j = await window.sysAPI.readHistory(); + return (j && Array.isArray(j.history)) ? j.history : []; + } catch (e) { + return []; + } + } + + async function fetchCloudItems() { + if (!hasCloudAuth() || !window.platformBridge || !window.platformBridge.listCloudFiles) return []; + try { + var data = await window.platformBridge.listCloudFiles({ page: 1, size: 50 }); + return (data && data.items) ? data.items : []; + } catch (e) { + return []; + } + } + + function removeLocalHistoryPath(path) { + if (!path || !window.sysAPI || typeof window.sysAPI.readHistory !== 'function') { + return Promise.resolve(); + } + return window.sysAPI.readHistory().then(function (j) { + var list = (j && Array.isArray(j.history)) ? j.history : []; + var next = list.filter(function (item) { return item.path !== path; }); + if (next.length === list.length) return null; + return window.sysAPI.writeHistory({ history: next }); + }).catch(function () { return null; }); + } + function renderFilePager() { var el = document.getElementById('filePager'); if (!el) return; @@ -247,41 +349,17 @@ layui.use(['layer', 'form', 'jquery'], function () { try { var recentCountEl = document.getElementById('recentCount'); - var filePagerEl = document.getElementById('filePager'); - - if (!hasCloudAuth()) { - - if (recentCountEl) recentCountEl.textContent = '0'; - if (filePagerEl) filePagerEl.innerHTML = ''; - - $(".card-list").html(soonEmptyBlock('登录后查看文件', '请先登录以管理云端设计文件', - - '去登录')); - - return; - - } - - if (!window.platformBridge || !window.platformBridge.listCloudFiles) { - - $(".card-list").html(""); - - return; - - } - - var data = await window.platformBridge.listCloudFiles({ - page: fileListState.page, - size: fileListState.size, - }); - - var items = (data && data.items) ? data.items : []; - fileListState.total = (data && data.total != null) ? data.total : items.length; - if (data && data.page) fileListState.page = data.page; + var localHistory = await fetchLocalHistory(); + var cloudItems = await fetchCloudItems(); + var merged = mergeRecentItems(localHistory, cloudItems); + fileListState.items = merged; + fileListState.total = merged.length; + var pages = Math.max(1, Math.ceil(fileListState.total / fileListState.size)); + if (fileListState.page > pages) fileListState.page = pages; if (recentCountEl) recentCountEl.textContent = String(fileListState.total); - if (!items.length) { + if (!merged.length) { $(".card-list").html(soonEmptyBlock('暂无文件', '点击「打开文件」导入,或新建模板开始设计')); renderFilePager(); @@ -289,20 +367,17 @@ layui.use(['layer', 'form', 'jquery'], function () { } + var start = (fileListState.page - 1) * fileListState.size; + var slice = merged.slice(start, start + fileListState.size); let h = ""; - for (let item of items) { - - let filePath = makeFileKey(item.id, item.version); + for (let item of slice) { + let filePath = item.filePath; let src = ""; - let fileExists = true; - let imgStyle = ""; - - let realType = 1; - + let realType = item.type || 1; const soonData = await window.sysAPI.readJsonFile(filePath); if (soonData) { @@ -323,13 +398,13 @@ layui.use(['layer', 'form', 'jquery'], function () { fileExists = false; - src = soonAsset('bg_1.png'); + src = soonAsset((realType == 2 || realType == "2") ? 'bg_2.png' : 'bg_1.png'); imgStyle = "opacity: 0.6; filter: grayscale(100%);"; } - let displayName = item.name || ('文件 #' + item.id); + let displayName = item.name || displayNameFromPath(filePath); let cardTitle = displayName; @@ -349,27 +424,29 @@ layui.use(['layer', 'form', 'jquery'], function () { : ''; - var dlBtn = '' + + ''; + } else { + actions = ''; + } - + ''; - - var delBtn = ''; - - h += `
    + h += `
    -
    ${dlBtn}${delBtn}
    +
    ${actions}
    @@ -398,6 +475,8 @@ layui.use(['layer', 'form', 'jquery'], function () { } + window.soonReloadRecentFiles = loadHistory; + function renderTemplatesError(message) { @@ -430,33 +509,16 @@ layui.use(['layer', 'form', 'jquery'], function () { return soonAsset(t === 2 ? 'bg_2.png' : 'bg_1.png'); } - async function resolveTemplateItem(m) { + function resolveTemplateItem(m, base) { var type = Number(m.type) || 1; - var fallbackThumb = templateFallbackThumb(type); - var fileUrl = m.file_url || ''; - var out = { + var id = m.id; + return { + id: id, name: m.name || m.title || '模板', type: type, - file_url: fileUrl, - thumbSrc: fallbackThumb, - _soonData: null + thumbSrc: id ? (base + '/templates/' + id + '/thumb') : templateFallbackThumb(type), + file_url: id ? (base + '/templates/' + id + '/file') : (m.file_url || '') }; - if (!fileUrl) return out; - try { - var r = await fetch(fileUrl); - if (!r.ok) throw new Error('fetch'); - var soonData = await r.json(); - if (!soonData) return out; - out._soonData = soonData; - type = soonData.soonType ? soonData.soonType : (soonData.backBlackPic ? 2 : 1); - out.type = type; - fallbackThumb = templateFallbackThumb(type); - var src = typeof soonSafeImageUrl === 'function' - ? soonSafeImageUrl(soonData.frontDisplayPic, fallbackThumb) - : (soonData.frontDisplayPic || fallbackThumb); - out.thumbSrc = src || fallbackThumb; - } catch (e) { /* 与最近文件一致:失败时用默认图 */ } - return out; } function openTemplateItem(m) { @@ -474,10 +536,6 @@ layui.use(['layer', 'form', 'jquery'], function () { openDesign(type, ''); } } - if (m._soonData) { - openWithJson(m._soonData); - return; - } var fileUrl = m.file_url || ''; if (!fileUrl) { openDesign(type, ''); @@ -493,13 +551,14 @@ layui.use(['layer', 'form', 'jquery'], function () { function templateCardHtml(m, itemIndex) { var type = Number(m.type) || 1; - var thumbSrc = m.thumbSrc || templateFallbackThumb(type); + var fallback = templateFallbackThumb(type); + var thumbSrc = m.thumbSrc || fallback; var esc = typeof soonEscapeHtml === 'function' ? soonEscapeHtml : escapeAttr; var name = esc(m.name || m.title || '模板'); var rectClass = 'rect' + (type == 2 ? ' rect1' : ''); return '
    ' + '
    ' + - '' + + '' + '
    ' + name + '
    ' + '
    '; } @@ -579,7 +638,7 @@ layui.use(['layer', 'form', 'jquery'], function () { try { - var r = await fetch(base + '/soon-models'); + var r = await fetch(base + '/templates'); var j = await r.json(); @@ -592,12 +651,8 @@ layui.use(['layer', 'form', 'jquery'], function () { } var items = (j.data && j.data.items) ? j.data.items : []; - var resolved = []; - for (var ti = 0; ti < items.length; ti++) { - resolved.push(await resolveTemplateItem(items[ti])); - } - templateListState.items = resolved; - templateListState.total = resolved.length; + templateListState.items = items.map(function (it) { return resolveTemplateItem(it, base); }); + templateListState.total = templateListState.items.length; templateListState.page = 1; renderTemplatesPage(); @@ -631,19 +686,11 @@ layui.use(['layer', 'form', 'jquery'], function () { $("#openfile").click(function () { OpenDialog(); }); $("#new1").click(function () { - - if (typeof window.soonRequireLogin === 'function' && !window.soonRequireLogin('新建')) return; - openDesign(1, ''); - }); $("#new2").click(function () { - - if (typeof window.soonRequireLogin === 'function' && !window.soonRequireLogin('新建')) return; - openDesign(2, ''); - }); @@ -683,8 +730,6 @@ layui.use(['layer', 'form', 'jquery'], function () { var name = card.attr('data-name') || 'design.soon'; - if (typeof window.soonRequireMember === 'function' && !window.soonRequireMember('下载')) return; - if (id && window.platformBridge && window.platformBridge.downloadCloudFile) { window.platformBridge.downloadCloudFile(id, name).catch(function () {}); @@ -699,9 +744,42 @@ layui.use(['layer', 'form', 'jquery'], function () { var card = $(this).closest('.card'); + var kind = card.attr('data-kind') || 'cloud'; + var fileId = parseInt(card.attr('data-id'), 10); - if (!fileId) return; + var filePath = card.attr('data-file') || ''; + + if (kind === 'local' || !fileId) { + + layer.open({ + type: 1, + skin: 'soon-layer', + title: language_str("delTitle"), + content: '
    ' + language_str('deleteFileConfirm') + '
    ', + btn: [language_str("comfirm"), language_str("cancel")], + btnAlign: 'r', + area: ['320px', 'auto'], + resize: false, + shadeClose: true, + yes: function (index) { + removeLocalHistoryPath(filePath).then(function () { + if (filePath.indexOf('soondesign_session:') === 0) { + try { + sessionStorage.removeItem(filePath); + localStorage.removeItem(filePath); + } catch (err) { /* ignore */ } + } + layer.msg(language_str("deleted"), { icon: 1, time: 1000 }); + loadHistory(); + layer.close(index); + }); + } + }); + + return; + + } confirmDeleteCloudFile(fileId, language_str('deleteFileConfirm'), loadHistory); @@ -725,8 +803,26 @@ layui.use(['layer', 'form', 'jquery'], function () { } else { + var kind = $(this).attr('data-kind') || 'cloud'; + var fileId = parseInt($(this).attr('data-id'), 10); + if (kind === 'local' || !fileId) { + + removeLocalHistoryPath(filePath).then(function () { + if (filePath.indexOf('soondesign_session:') === 0) { + try { + sessionStorage.removeItem(filePath); + localStorage.removeItem(filePath); + } catch (err) { /* ignore */ } + } + loadHistory(); + }); + + return; + + } + confirmDeleteCloudFile(fileId, language_str("delContent"), loadHistory); } @@ -737,8 +833,6 @@ layui.use(['layer', 'form', 'jquery'], function () { function OpenDialog() { - if (typeof window.soonRequireLogin === 'function' && !window.soonRequireLogin('打开文件')) return; - var dialogApi = window.platformBridge && window.platformBridge.showOpenDialog ? { showOpenDialog: function (opts) { return window.platformBridge.showOpenDialog(opts); } } @@ -785,6 +879,15 @@ layui.use(['layer', 'form', 'jquery'], function () { } + var tok = typeof window.soonGetAccessToken === 'function' + ? window.soonGetAccessToken() + : (localStorage.getItem('soon_access') || ''); + + if (!tok && typeof window.soonOpenSoonJsonLocally === 'function') { + window.soonOpenSoonJsonLocally(soonData, fileName); + return; + } + if (!window.platformBridge || !window.platformBridge.importSoonFile) return; try { diff --git a/frontend-web/js/member.js b/frontend-web/js/member.js deleted file mode 100644 index c1d0595..0000000 --- a/frontend-web/js/member.js +++ /dev/null @@ -1,472 +0,0 @@ -(function () { - 'use strict'; - - var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || '/api/v1'; - var currentMembership = null; - var orderState = { page: 1, size: 8, total: 0 }; - - var esc = typeof soonEscapeHtml === 'function' ? soonEscapeHtml : function (s) { - if (s == null) return ''; - return String(s); - }; - - var STATUS_LABEL = { - pending: '待支付', - paid: '已支付', - cancelled: '已取消', - refunded: '已退款', - }; - - var CHANNEL_LABEL = { alipay: '支付宝', wechat: '微信' }; - - function apiFetch(url, opts) { - if (typeof soonAuthedFetch === 'function') return soonAuthedFetch(url, opts || {}); - opts = opts || {}; - var token = localStorage.getItem('soon_access') || ''; - opts.headers = Object.assign({}, opts.headers || {}); - if (token) opts.headers.Authorization = 'Bearer ' + token; - return fetch(url, opts); - } - - function parseApiJson(r) { - return r.text().then(function (text) { - try { - return JSON.parse(text); - } catch (e) { - return { ok: false, message: '服务暂时不可用,请稍后重试' }; - } - }); - } - - function apiGet(p) { - return apiFetch(base + p).then(function (r) { - if (!r.ok && r.status === 401) return { ok: false, message: '未登录或会话已过期' }; - return parseApiJson(r); - }); - } - - function apiPost(p, body) { - return apiFetch(base + p, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body || {}), - }).then(function (r) { - if (!r.ok && r.status === 401) return { ok: false, message: '未登录或会话已过期' }; - return parseApiJson(r); - }); - } - - function usageMetrics(usage, quotaMb) { - var bytes = (usage && usage.storage_bytes) || 0; - var totalBytes = (quotaMb || 0) * 1024 * 1024; - var pctNum = totalBytes > 0 ? (bytes / totalBytes) * 100 : 0; - var pctLabel = pctNum > 0 && pctNum < 1 ? '<1' : String(Math.min(100, Math.round(pctNum))); - var barWidth = bytes > 0 ? Math.max(pctNum < 1 ? 0.8 : pctNum, 0.8) : 0; - return { - pctNum: pctNum, - pctLabel: pctLabel, - barWidth: Math.min(100, barWidth), - usedDisplay: (usage && usage.used_display) || '0 MB', - }; - } - - function showPayBanner() { - var b = document.getElementById('payBanner'); - if (!b) return; - b.classList.add('is-visible'); - setTimeout(function () { b.classList.remove('is-visible'); }, 5000); - } - - function initLanguageSelect() { - var sel = document.getElementById('language_select'); - if (!sel) return; - sel.value = localStorage.getItem('lang') || 'zh'; - sel.onchange = function () { localStorage.setItem('lang', sel.value); }; - } - - function isPaidMember(m) { - if (!m) return false; - if (m.is_member === true) return true; - return m.tier === 'member' || m.tier === 'pro'; - } - - function statusPill(sub, isMember) { - if (!isMember) { - return '免费版'; - } - var st = (sub && sub.status) || 'active'; - var cls = 'soon-member-status-pill--active'; - var text = '生效中'; - if (st === 'expiring') { cls = 'soon-member-status-pill--expiring'; text = '即将到期'; } - return '' + text + ''; - } - - function storageRing(metrics, warn) { - var r = 52; - var c = 2 * Math.PI * r; - var offset = c - (c * Math.min(metrics.pctNum, 100) / 100); - var fillCls = warn ? ' soon-member-storage-ring__fill--warn' : ''; - return '
    ' + - '' + - '
    ' + - '' + metrics.pctLabel + '%' + - '已使用
    '; - } - - function renderMyPlan(m) { - var el = document.getElementById('myPlan'); - if (!el) return; - if (!m) { - el.innerHTML = '
    ' + - '

    加载失败,请重试

    ' + - '
    '; - var retry = document.getElementById('myPlanRetry'); - if (retry) retry.onclick = loadMembership; - return; - } - currentMembership = m; - var isMember = isPaidMember(m); - if (typeof window.soonApplyMembership === 'function') { - window.soonApplyMembership(m); - } else if (typeof window.soonLoadMembership === 'function') { - window.soonLoadMembership(true); - } - var usage = m.usage || {}; - var sub = m.subscription || {}; - - if (!isMember) { - el.innerHTML = - '
    ' + - '
    ' + - '
    ' + - '免费版' + statusPill(sub, false) + '
    ' + - '

    ' + esc(m.name || '免费版') + '

    ' + - '

    ' + esc(m.description || '免费体验设计与编辑') + '

    ' + - '

    订阅后可预览、打印、保存并导出作品

    ' + - '
    ' + - '' + - '
    '; - var goPlans = document.getElementById('myPlanGoPlans'); - if (goPlans) { - goPlans.onclick = function () { - var sec = document.getElementById('plans'); - if (sec) sec.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }; - } - return; - } - - var quota = m.quota_mb || 2048; - var metrics = usageMetrics(usage, quota); - var warn = metrics.pctNum >= 90; - var barCls = warn ? 'soon-member-progress__bar soon-member-progress__bar--warn' : 'soon-member-progress__bar'; - var expires = sub.expires_at - ? esc(String(sub.expires_at).slice(0, 16).replace('T', ' ')) - : '—'; - var days = sub.days_remaining != null ? sub.days_remaining + ' 天' : '—'; - - el.innerHTML = - '
    ' + - '
    ' + - '
    ' + - '
    ' + - '订阅版' + statusPill(sub, true) + '
    ' + - '

    ' + esc(m.name) + '

    ' + - '

    ' + esc(m.description || '已解锁完整交付能力') + '

    ' + - '

    有效期至 ' + expires + ' · 剩余 ' + esc(days) + '

    ' + - storageRing(metrics, warn) + '
    ' + - '
    ' + - '
    云端存储占用' + - esc(metrics.usedDisplay) + ' / ' + esc(m.quota_display || quota + ' MB') + - '(' + metrics.pctLabel + '%)
    ' + - '
    ' + - '
    ' + - '
    ' + - '
    ' + (usage.files_count || 0) + - '
    云端文件
    ' + - '
    ' + esc(days) + - '
    剩余天数
    ' + - '
    '; - } - - function orderBadge(status, refundStatus) { - if (refundStatus === 'pending') { - return '退款审核'; - } - var cls = 'soon-member-order-badge--' + (status || 'pending'); - return '' + esc(STATUS_LABEL[status] || status) + ''; - } - - function orderPagerHtml() { - var page = orderState.page; - var size = orderState.size; - var total = orderState.total; - var pages = Math.max(1, Math.ceil(total / size)); - if (total === 0) return ''; - return '
    ' + - '' + - '第 ' + page + ' / ' + pages + ' 页(共 ' + total + ' 条)' + - '
    '; - } - - function bindOrderPager() { - var prev = document.getElementById('orderPrev'); - var next = document.getElementById('orderNext'); - if (prev && !prev.disabled) { - prev.onclick = function () { - orderState.page = Math.max(1, orderState.page - 1); - loadOrders(); - }; - } - if (next && !next.disabled) { - next.onclick = function () { - var pages = Math.max(1, Math.ceil(orderState.total / orderState.size)); - orderState.page = Math.min(pages, orderState.page + 1); - loadOrders(); - }; - } - } - - function orderActionCell(o) { - var actions = []; - if (o.status === 'pending') { - actions.push(''); - actions.push(''); - } else if (o.status === 'paid' && (!o.refund_status || o.refund_status === 'none')) { - actions.push(''); - } - if (!actions.length) return ''; - return '
    ' + actions.join('') + '
    '; - } - - function bindOrderActions() { - document.querySelectorAll('.soon-member-order-act[data-act]').forEach(function (btn) { - btn.onclick = function () { - var act = btn.dataset.act; - var no = btn.dataset.no; - if (act === 'pay') { - openPayModal(null, btn.dataset.plan, btn.dataset.price, { - orderNo: no, - channel: btn.dataset.channel || (window.SoonMemberPay && SoonMemberPay.defaultChannel - ? SoonMemberPay.defaultChannel() : 'alipay'), - }); - return; - } - if (act === 'cancel') { - layer.confirm('确定取消该待支付订单?', { skin: 'soon-layer', title: '取消订单' }, function (idx) { - apiPost('/pay/orders/' + encodeURIComponent(no) + '/cancel', {}).then(function (r) { - layer.close(idx); - if (r.ok) { - soonToast('订单已取消', 'success'); - loadOrders(); - } else { - soonToast(r.message || '取消失败', 'warn'); - } - }); - }); - return; - } - if (act === 'refund') { - layer.prompt({ - skin: 'soon-layer', - title: '申请退款', - formType: 2, - value: '', - maxlength: 200, - }, function (reason, idx) { - reason = (reason || '').trim(); - if (!reason) { - soonToast('请填写退款原因', 'warn'); - return; - } - apiPost('/pay/orders/' + encodeURIComponent(no) + '/refund-request', { reason: reason }).then(function (r) { - layer.close(idx); - if (r.ok) { - soonToast('退款申请已提交', 'success'); - loadOrders(); - } else { - soonToast(r.message || '提交失败', 'warn'); - } - }); - }); - } - }; - }); - } - - function renderOrders(orders) { - var el = document.getElementById('orderHistory'); - if (!el) return; - if (!orders || !orders.length) { - el.innerHTML = '
    暂无订单,订阅后将显示在这里
    ' + - orderPagerHtml(); - bindOrderPager(); - return; - } - var rows = orders.map(function (o) { - return '' + esc(o.order_no) + '' + esc(o.plan_name) + '¥' + - (o.amount_cents / 100).toFixed(2) + '' + esc(CHANNEL_LABEL[o.channel] || o.channel) + - '' + orderBadge(o.status, o.refund_status) + '' + - esc(String(o.paid_at || o.created_at || '').slice(0, 16).replace('T', ' ')) + '' + - orderActionCell(o) + ''; - }).join(''); - el.innerHTML = '' + - rows + '
    订单号方案金额渠道状态时间操作
    ' + orderPagerHtml(); - bindOrderPager(); - bindOrderActions(); - } - - function loadOrders() { - apiGet('/pay/orders?page=' + orderState.page + '&size=' + orderState.size).then(function (res) { - if (!res.ok) { - renderOrders([]); - return; - } - var data = res.data || {}; - orderState.total = data.total || 0; - orderState.page = data.page || orderState.page; - orderState.size = data.size || orderState.size; - renderOrders(data.items || []); - }).catch(function () { - renderOrders([]); - }); - } - - function renderPlans(items) { - var grid = document.getElementById('plans'); - if (!grid || !window.SoonMemberPlan) return; - var paid = items.filter(function (p) { return p.code !== 'free' && p.price_cents > 0; }); - var currentCode = currentMembership && isPaidMember(currentMembership) ? currentMembership.code : ''; - if (!paid.length) { - grid.innerHTML = '
    暂无可用订阅方案
    '; - return; - } - grid.innerHTML = window.SoonMemberPlan.plansGridHtml(paid, { - allPlans: paid, - currentCode: currentCode, - isMember: isPaidMember(currentMembership), - mode: 'page', - }); - grid.querySelectorAll('.soon-plan-card__cta[data-id]').forEach(function (btn) { - btn.onclick = function () { - openPayModal(Number(btn.dataset.id), btn.dataset.name, btn.dataset.price); - }; - }); - } - - function renderPlansError(message, onRetry) { - var grid = document.getElementById('plans'); - if (!grid) return; - grid.innerHTML = '
    ' + - esc(message || '方案加载失败') + - '
    '; - var btn = document.getElementById('plansRetry'); - if (btn && onRetry) btn.onclick = onRetry; - } - - function loadPlans() { - apiGet('/plans').then(function (ps) { - if (!ps.ok) { - renderPlansError(ps.message || '方案加载失败', loadPlans); - return; - } - renderPlans((ps.data && ps.data.items) || []); - }).catch(function () { - renderPlansError('网络错误,请稍后重试', loadPlans); - }); - } - - function openPayModal(planId, planName, priceDisplay, opts) { - opts = opts || {}; - var payCore = window.SoonMemberPay; - if (!payCore || !payCore.openPayModal) { - soonToast('支付模块未加载,请刷新页面', 'warn'); - return; - } - payCore.openPayModal({ - planId: planId, - planName: planName, - priceDisplay: priceDisplay, - orderNo: opts.orderNo, - channel: opts.channel || (payCore.defaultChannel ? payCore.defaultChannel() : 'alipay'), - onPaid: function () { - showPayBanner(); - loadMembership(); - }, - }); - } - - function loadMembership() { - apiGet('/plans/me').then(function (my) { - if (!my.ok || !my.data) { - renderMyPlan(null); - return; - } - renderMyPlan(my.data.membership || my.data); - loadOrders(); - loadPlans(); - }).catch(function () { - renderMyPlan(null); - }); - } - - function init() { - if (!localStorage.getItem('soon_access')) { - location.href = 'login.web.html?redirect=' + encodeURIComponent('member.web.html'); - return; - } - if (window.SoonPortalTopbar) { - SoonPortalTopbar.mount('#portal-topbar', { active: 'member' }); - } else { - initLanguageSelect(); - } - soonPortalAuth.init({ logoutReload: false }); - apiGet('/auth/me').then(function (me) { - if (!me.ok) { - location.href = 'login.web.html?redirect=' + encodeURIComponent('member.web.html'); - return; - } - if (me.data && me.data.role === 'admin') { - var adminEl = document.getElementById('auth_admin'); - if (adminEl) adminEl.style.display = 'inline-flex'; - } - loadMembership(); - if (new URLSearchParams(location.search).get('paid') || sessionStorage.getItem('soon_pay_return')) { - sessionStorage.removeItem('soon_pay_return'); - showPayBanner(); - loadMembership(); - } - }).catch(function () { - renderMyPlan(null); - renderPlansError('网络错误,请稍后重试', function () { location.reload(); }); - var ordersEl = document.getElementById('orderHistory'); - if (ordersEl) { - ordersEl.innerHTML = '
    加载失败,
    '; - var ordersRetry = document.getElementById('ordersRetry'); - if (ordersRetry) ordersRetry.onclick = function () { location.reload(); }; - } - }); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -})(); diff --git a/frontend-web/js/platform/web.js b/frontend-web/js/platform/web.js index ef60f44..29bb357 100644 --- a/frontend-web/js/platform/web.js +++ b/frontend-web/js/platform/web.js @@ -124,7 +124,14 @@ } function listCloudFiles(pageOrLimit, sizeOrOffset) { - if (!requireCloudAuth('查看文件')) return Promise.reject(new Error('unauthorized')); + if (!getAccessToken()) { + var empty = { items: [], total: 0 }; + if (typeof pageOrLimit === 'object' && pageOrLimit) { + empty.page = pageOrLimit.page || 1; + empty.size = pageOrLimit.size || 12; + } + return Promise.resolve(empty); + } var url; var fallback = { items: [], total: 0 }; if (typeof pageOrLimit === 'object' && pageOrLimit) { @@ -145,9 +152,6 @@ function createCloudFile(name, jsonStr) { if (!requireCloudAuth('保存')) return Promise.reject(new Error('unauthorized')); - if (typeof window.soonRequireMember === 'function' && !window.soonRequireMember('保存')) { - return Promise.reject(new Error('membership_required')); - } return authedFetch('files', { method: 'POST', headers: JSON_HEADERS, @@ -159,9 +163,6 @@ function updateCloudFile(id, name, jsonStr, version) { if (!requireCloudAuth('保存')) return Promise.reject(new Error('unauthorized')); - if (typeof window.soonRequireMember === 'function' && !window.soonRequireMember('保存')) { - return Promise.reject(new Error('membership_required')); - } var body = { name: normalizeSoonName(name), json: jsonStr }; if (version != null) body.version = version; return authedFetch('files/' + id, { @@ -186,9 +187,6 @@ function downloadCloudFile(id, fileName) { if (!requireCloudAuth('下载')) return Promise.reject(new Error('unauthorized')); - if (typeof window.soonRequireMember === 'function' && !window.soonRequireMember('下载')) { - return Promise.reject(new Error('membership_required')); - } return authedFetch('files/' + id + '/download', { headers: { Accept: 'application/octet-stream' } }) .then(function (response) { if (!response.ok) { @@ -328,14 +326,31 @@ pathOrHandle = typeof pathOrHandle.name === 'string' ? pathOrHandle.name : 'design.soon'; } - if (!requireCloudAuth('保存')) { - return Promise.reject(new Error('unauthorized')); - } - var name = typeof pathOrHandle === 'string' ? pathOrHandle : 'design.soon'; var cloudRef = parseCloudRef(name); var fileName = normalizeSoonName(name); + if (!getAccessToken()) { + if (cloudRef && cloudRef.id) { + requireCloudAuth('保存'); + return Promise.reject(new Error('unauthorized')); + } + var sessionKey = name.indexOf('soondesign_session:') === 0 + ? name + : 'soondesign_session:' + fileName.replace(/\.soon$/i, '') + '-' + Date.now(); + try { + sessionStorage.setItem(sessionKey, str); + try { localStorage.setItem(sessionKey, str); } catch (e2) { /* ignore quota */ } + return Promise.resolve({ fileKey: sessionKey, name: fileName, version: 0 }); + } catch (e) { + return Promise.reject(new Error('save_failed')); + } + } + + if (!requireCloudAuth('保存')) { + return Promise.reject(new Error('unauthorized')); + } + if (cloudRef && cloudRef.id) { var ver = cloudRef.version; if (ver == null && window._soonFileMeta && window._soonFileMeta.id === cloudRef.id) { diff --git a/frontend-web/pages/admin/assets/js/core/router.js b/frontend-web/pages/admin/assets/js/core/router.js index 6cc3d7d..fab7359 100644 --- a/frontend-web/pages/admin/assets/js/core/router.js +++ b/frontend-web/pages/admin/assets/js/core/router.js @@ -11,6 +11,7 @@ '/users': 'users', '/orders': 'orders', '/plans': 'plans', + '/templates': 'templates', '/settings': 'settings', '/audits': 'audits', '/payment': 'payment', @@ -21,6 +22,7 @@ users: '/users', orders: '/orders', plans: '/plans', + templates: '/templates', settings: '/settings', audits: '/audits', payment: '/payment', diff --git a/frontend-web/pages/admin/assets/js/views/dashboard.js b/frontend-web/pages/admin/assets/js/views/dashboard.js index a42a7a9..e755717 100644 --- a/frontend-web/pages/admin/assets/js/views/dashboard.js +++ b/frontend-web/pages/admin/assets/js/views/dashboard.js @@ -33,6 +33,7 @@ { href: '#/users', label: '用户管理' }, { href: '#/orders', label: '订单管理' }, { href: '#/plans', label: '套餐配置' }, + { href: '#/templates', label: '模板库' }, { href: '#/payment', label: '支付密钥' }, { href: '#/settings', label: '系统设置' }, { href: '#/audits', label: '审计日志' }, @@ -96,7 +97,7 @@ statCard('订单总数', d.orders, { href: '#/orders', hint: '含全部状态' }) + statCard('今日订单', d.orders_today, { href: '#/orders', hint: '按创建日统计' }) + statCard('待支付', d.orders_pending || 0, { href: '#/orders?status=pending', hint: '需关注超时未付' }) + - statCard('活跃订阅', d.active_subscriptions, { href: '#/users', hint: '有效会员数' }) + + statCard('活跃会员', d.active_subscriptions, { href: '#/users', hint: '有效会员数' }) + statCard('今日收入', I().formatCents(d.revenue_today_cents), { href: '#/orders', hint: '已支付订单' }) + statCard('云端文件', d.files_total, { hint: '未删除的设计文件' }) + statCard('退款待审', d.refunds_pending || 0, { href: '#/orders?refund_status=pending', hint: '需在订单页处理' }); diff --git a/frontend-web/pages/admin/assets/js/views/orders.js b/frontend-web/pages/admin/assets/js/views/orders.js index 0979150..414d335 100644 --- a/frontend-web/pages/admin/assets/js/views/orders.js +++ b/frontend-web/pages/admin/assets/js/views/orders.js @@ -202,11 +202,11 @@ var subHtml; if (sub) { - subHtml = Ui().detailRow('订阅状态', I().badgeStatus(sub.status)) + + subHtml = Ui().detailRow('会员状态', I().badgeStatus(sub.status)) + Ui().detailRow('开始', I().esc(I().formatDateTime(sub.started_at))) + Ui().detailRow('到期', I().esc(I().formatDateTime(sub.expires_at))); } else { - subHtml = '

    无关联订阅

    '; + subHtml = '

    无关联会员记录

    '; } var drawerActions = rowActions(o).replace(/]*order-detail-btn[^>]*>[\s\S]*?<\/button>/, ''); diff --git a/frontend-web/pages/admin/assets/js/views/plans.js b/frontend-web/pages/admin/assets/js/views/plans.js index 3c0beb8..d5cf258 100644 --- a/frontend-web/pages/admin/assets/js/views/plans.js +++ b/frontend-web/pages/admin/assets/js/views/plans.js @@ -24,6 +24,10 @@ return plan && plan.code !== 'free' && plan.price_cents > 0; } + function isLifetimePlan(plan) { + return plan && plan.code === 'member_lifetime'; + } + function editableFeatures(plan) { if (plan.features_editable && plan.features_editable.length) { return plan.features_editable.slice(); @@ -96,9 +100,10 @@ if (isFreePlan(plan)) return ''; var days = parseInt(Ui().el('peDays') && Ui().el('peDays').value, 10); if (!days || days <= 0) days = plan.duration_days || 0; - if (days <= 0) return '付费方案保存后将自动追加订阅周期说明。'; + if (isLifetimePlan(plan)) return '永久激活方案,duration_days 固定为 0。'; + if (days <= 0) return '付费方案保存后将自动追加周期说明。'; var label = days >= 365 ? '1 年' : (days === 90 ? '1 季' : (days >= 30 && days % 30 === 0 ? (days / 30) + ' 个月' : days + ' 天')); - return '保存后将自动追加订阅周期说明(' + label + '),用户端卡片中不展示该行'; + return '保存后将自动追加周期说明(' + label + '),用户端不展示该行'; } function openEditModal(plan) { @@ -112,6 +117,7 @@ Form().field('简介', Form().input('peDesc', { value: plan.description || '' })) ); + var lifetime = isLifetimePlan(plan); var pricePanel = Form().panel('价格与周期', '
    ' + Form().field('价格(元)', @@ -121,8 +127,8 @@ disabled: free, })) + Form().field('有效天数', - Form().input('peDays', { type: 'number', value: plan.duration_days }), - free ? '免费版填 0' : '月付 30 / 季付 90 / 年付 365') + + Form().input('peDays', { type: 'number', value: plan.duration_days, disabled: lifetime }), + free ? '免费版填 0' : (lifetime ? '永久激活固定为 0' : '月付 30 / 季付 90 / 年付 365')) + Form().field('排序', Form().input('peSort', { type: 'number', value: plan.sort_order })) + '
    ' + @@ -140,12 +146,12 @@ Form().field('存储(MB)', Form().input('peQuota', { type: 'number', value: plan.quota_mb })) + Form().field('文件数上限', Form().input('peMaxFiles', { type: 'number', value: plan.max_files })) + '
    ', - '云端存储与文件数量上限;预览/保存/导出仍受订阅状态约束。' + '云端存储与文件数量上限。' ); - var benefitTitle = free ? '免费版展示' : '订阅包含'; + var benefitTitle = free ? '免费版展示' : '会员权益'; var benefitHint = free - ? '展示在会员中心免费版区域;补充说明写在简介中。' + ? '展示在用户端身份说明中。' : periodHint(plan); var benefitPanel = Form().panel(benefitTitle, '
    ' + featureRowsHtml(features) + '
    ' + @@ -158,7 +164,7 @@ Form().actions('保存', 'adminModalSave') + '
    '; - Ui().openFormModal((free ? '免费版' : '订阅方案') + ' · ' + plan.name, form, function () { + Ui().openFormModal((free ? '免费版' : '会员激活') + ' · ' + plan.name, form, function () { var name = Ui().el('peName').value.trim(); if (!name) { soonToast('名称不能为空', 'warn'); @@ -177,7 +183,7 @@ price_cents: Math.round(yuan * 100), quota_mb: parseInt(Ui().el('peQuota').value, 10) || 0, max_files: parseInt(Ui().el('peMaxFiles').value, 10) || 0, - duration_days: parseInt(Ui().el('peDays').value, 10) || 0, + duration_days: lifetime ? 0 : (parseInt(Ui().el('peDays').value, 10) || 0), sort_order: parseInt(Ui().el('peSort').value, 10) || 0, is_recommended: free ? 0 : (Ui().el('peRec').checked ? 1 : 0), is_active: Ui().el('peActive').checked ? 1 : 0, @@ -215,7 +221,7 @@ var panel = Ui().el('panel-plans'); if (!panel) return; panel.innerHTML = '
    ' + - Ui().pageHeader('订阅方案', '配置价格、周期、配额与用户端展示文案') + + Ui().pageHeader('会员激活', '配置激活价格、配额与用户端展示文案') + Ui().skeletonTable() + '
    '; Api().get('plans').then(function (j) { @@ -230,7 +236,7 @@ }).join(''); panel.innerHTML = '
    ' + - Ui().pageHeader('订阅方案', '配置价格、周期、配额与用户端展示文案') + + Ui().pageHeader('会员激活', '配置激活价格、配额与用户端展示文案') + Ui().dataTable( ['代码', '名称', '价格', '权益', '存储', '有效期', '推荐', '状态', '操作'], rows diff --git a/frontend-web/pages/admin/assets/js/views/templates.js b/frontend-web/pages/admin/assets/js/views/templates.js new file mode 100644 index 0000000..11ded4d --- /dev/null +++ b/frontend-web/pages/admin/assets/js/views/templates.js @@ -0,0 +1,196 @@ +(function () { + 'use strict'; + + var Api = function () { return window.AdminApi; }; + var Ui = function () { return window.AdminUi; }; + var I = function () { return window.AdminI18n; }; + var Form = function () { return window.AdminForm; }; + + function activeBadge(v) { + var on = v === true || parseInt(v, 10) === 1; + return I().badgeStatus(on ? 'active' : 'disabled'); + } + + function typeLabel(type) { + return Number(type) === 2 ? '双面' : '单面'; + } + + function formatBytes(n) { + n = parseInt(n, 10) || 0; + if (!n) return '—'; + if (n < 1024) return n + ' B'; + if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; + return (n / 1048576).toFixed(1) + ' MB'; + } + + function readFileText(input) { + return new Promise(function (resolve, reject) { + var file = input && input.files && input.files[0]; + if (!file) { + reject(new Error('no_file')); + return; + } + var reader = new FileReader(); + reader.onload = function () { resolve(String(reader.result || '')); }; + reader.onerror = function () { reject(new Error('read_failed')); }; + reader.readAsText(file, 'UTF-8'); + }); + } + + function defaultNameFromFile(input) { + var file = input && input.files && input.files[0]; + if (!file) return ''; + return file.name.replace(/\.soon$/i, '').replace(/[_-]+/g, ' ').trim(); + } + + function uploadFields(required) { + return Form().panel('模板文件', + Form().field('选择 .soon 文件', + '' + + (required ? '' : '

    留空则不替换已有文件

    '), + required ? '须为有效的 .soon JSON 文件' : '') + ); + } + + function metaFields(plan) { + plan = plan || {}; + return Form().panel('展示设置', + Form().field('名称', Form().input('tplName', { value: plan.name || '' })) + + '
    ' + + Form().field('排序', Form().input('tplSort', { type: 'number', value: plan.sort_order != null ? plan.sort_order : 0 })) + + Form().field('类型', '') + + '
    ' + + '' + ); + } + + function openCreateModal() { + var form = '
    ' + uploadFields(true) + metaFields() + + Form().actions('上传', 'adminModalSave') + '
    '; + + Ui().openFormModal('上传模板', form, function () { + var nameEl = Ui().el('tplName'); + var fileEl = Ui().el('tplFile'); + var name = nameEl.value.trim() || defaultNameFromFile(fileEl); + if (!name) { + soonToast('请填写名称或选择文件', 'warn'); + return false; + } + return readFileText(fileEl).then(function (content) { + return Api().postJson('templates', { + name: name, + content: content, + sort_order: parseInt(Ui().el('tplSort').value, 10) || 0, + is_active: Ui().el('tplActive').checked ? 1 : 0, + }).then(function (r) { + if (r) { + layer.closeAll(); + soonToast('已上传', 'success'); + render(); + } + }); + }).catch(function (e) { + if (e && e.message === 'no_file') soonToast('请选择 .soon 文件', 'warn'); + else soonToast('文件读取失败', 'error'); + }); + }, { area: ['480px', 'auto'] }); + } + + function openEditModal(plan) { + var form = '
    ' + metaFields(plan) + uploadFields(false) + + Form().actions('保存', 'adminModalSave') + '
    '; + + Ui().openFormModal('编辑模板 · ' + plan.name, form, function () { + var name = Ui().el('tplName').value.trim(); + if (!name) { + soonToast('名称不能为空', 'warn'); + return false; + } + var body = { + name: name, + sort_order: parseInt(Ui().el('tplSort').value, 10) || 0, + is_active: Ui().el('tplActive').checked ? 1 : 0, + }; + var fileEl = Ui().el('tplFile'); + var hasFile = fileEl && fileEl.files && fileEl.files[0]; + var save = function (content) { + if (content) body.content = content; + return Api().putJson('templates/' + plan.id, body).then(function (r) { + if (r) { + layer.closeAll(); + soonToast('已保存', 'success'); + render(); + } + }); + }; + if (hasFile) { + return readFileText(fileEl).then(save).catch(function () { + soonToast('文件读取失败', 'error'); + }); + } + return save(null); + }, { area: ['480px', 'auto'] }); + } + + function deleteTemplate(plan) { + Ui().confirm('确定删除模板「' + plan.name + '」?', function () { + Api().del('templates/' + plan.id).then(function (r) { + if (r) { + soonToast('已删除', 'success'); + render(); + } + }); + }); + } + + function render() { + var panel = Ui().el('panel-templates'); + if (!panel) return; + panel.innerHTML = '
    ' + + Ui().pageHeader('模板库', '管理首页展示的 .soon 模板(缩略图与名称轻量加载)', + '') + + Ui().skeletonTable() + '
    '; + + Api().get('templates').then(function (j) { + if (!j) return; + var items = j.data.items || []; + var rows = items.map(function (p) { + return '' + p.id + '' + I().esc(p.name) + + '' + typeLabel(p.type) + '' + formatBytes(p.file_size) + '' + + p.sort_order + '' + (p.has_thumb ? '有' : '—') + '' + + activeBadge(p.is_active) + '' + + ' ' + + ''; + }).join(''); + + panel.innerHTML = '
    ' + + Ui().pageHeader('模板库', '管理首页展示的 .soon 模板(缩略图与名称轻量加载)', + '') + + Ui().dataTable( + ['ID', '名称', '类型', '大小', '排序', '缩略图', '状态', '操作'], + rows + ) + '
    '; + + var planMap = {}; + items.forEach(function (p) { planMap[p.id] = p; }); + + panel.querySelector('#tplCreate').onclick = openCreateModal; + panel.querySelectorAll('.tpl-edit').forEach(function (btn) { + btn.onclick = function () { + var id = parseInt(btn.closest('tr').dataset.id, 10); + if (planMap[id]) openEditModal(planMap[id]); + }; + }); + panel.querySelectorAll('.tpl-del').forEach(function (btn) { + btn.onclick = function () { + var id = parseInt(btn.closest('tr').dataset.id, 10); + if (planMap[id]) deleteTemplate(planMap[id]); + }; + }); + }); + } + + window.AdminViews = window.AdminViews || {}; + window.AdminViews.templates = { render: render }; +})(); diff --git a/frontend-web/pages/admin/assets/js/views/users.js b/frontend-web/pages/admin/assets/js/views/users.js index 6e8d13e..2e480d1 100644 --- a/frontend-web/pages/admin/assets/js/views/users.js +++ b/frontend-web/pages/admin/assets/js/views/users.js @@ -205,7 +205,7 @@ Ui().detailRow('开始时间', I().esc(I().formatDateTime(sub.started_at))) + Ui().detailRow('到期时间', I().esc(I().formatDateTime(sub.expires_at))); } else { - subHtml = '

    免费版 / 无有效订阅

    '; + subHtml = '

    普通用户 / 未激活

    '; } var usageHtml = Ui().detailRow('文件数', I().esc(usage.files_count || 0)) + @@ -263,7 +263,7 @@ var inline = [ { label: '详情', action: 'detail', dataset: ds }, { label: '编辑', action: 'edit', dataset: editDs, disabled: editDisabled }, - { label: '订阅', action: 'sub', dataset: ds }, + { label: '会员', action: 'sub', dataset: ds }, ]; var more = [ { label: '改密', action: 'pwd', dataset: ds }, diff --git a/frontend-web/pages/admin/index.html b/frontend-web/pages/admin/index.html index a5bb3ae..cd992c1 100644 --- a/frontend-web/pages/admin/index.html +++ b/frontend-web/pages/admin/index.html @@ -19,6 +19,7 @@
    用户
    订单
    套餐
    +
    模板库
    设置
    审计
    支付密钥
    @@ -28,6 +29,7 @@
    +
    @@ -55,6 +57,7 @@ + diff --git a/frontend-web/pages/design1.web.html b/frontend-web/pages/design1.web.html index b15f972..40a7e3b 100644 --- a/frontend-web/pages/design1.web.html +++ b/frontend-web/pages/design1.web.html @@ -12,7 +12,6 @@ -