永久会员与模板库后台化:预览门控、轻量首页与 Admin CRUD

- 会员改为永久激活方案;云端文件不再拦截非会员;预览弹窗内导出/打印才校验
- 首页最近文件支持本地记录,登录后与云端合并;移除独立会员页与订阅页
- 模板库入库管理:soon_templates 表、Admin 上传 CRUD、/templates 轻量列表与按需下载

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24kycj
2026-06-08 23:52:56 +08:00
parent 152228d41f
commit ebe191b06d
45 changed files with 1616 additions and 1259 deletions
+1
View File
@@ -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,
+6
View File
@@ -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']);
+5 -1
View File
@@ -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/')) {
+21 -4
View File
@@ -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,
@@ -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);
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Admin\Controllers;
use Soon\Api\Core\Json;
use Soon\Api\Services\AuditService;
use Soon\Api\Services\TemplateService;
final class TemplatesController
{
public function list(int $adminId): void
{
AuditService::log($adminId, 'templates.list', 'soon_templates');
Json::ok(['items' => 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]);
}
}
@@ -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)'
@@ -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'];
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Controllers;
use Soon\Api\Core\Json;
use Soon\Api\Services\TemplateService;
final class TemplateController
{
public function index(): void
{
$items = TemplateService::listPublic();
Json::ok(['items' => $items, 'total' => count($items)]);
}
public function thumb(int $id): void
{
TemplateService::outputThumb($id);
}
public function file(int $id): void
{
TemplateService::outputFile($id);
}
}
+1 -1
View File
@@ -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',
+24 -13
View File
@@ -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);
$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' => $row['subscription_started_at'] ?? null,
'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<int, array<string, mixed>> */
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;
}
+9 -1
View File
@@ -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(
+1 -1
View File
@@ -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}',
];
}
@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Services;
use Soon\Api\Core\Config;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
final class TemplateService
{
private const THUMB_MAX_BYTES = 524288;
public static function storageDir(): string
{
$dir = (string)Config::get('storage.templates_dir', '');
if ($dir === '') {
$dir = SOON_SERVER_ROOT . '/storage/templates';
}
$dir = rtrim($dir, '/\\');
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return $dir;
}
/** @return list<array<string, mixed>> */
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<array<string, mixed>> */
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<string, mixed>|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);
}
}
}
+23 -4
View File
@@ -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), '
. '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);
+1
View File
@@ -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' => [
+5 -1
View File
@@ -81,6 +81,7 @@ Authorization: Bearer <admin_token>
| 路径 | 说明 |
|------|------|
| `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` |
---
@@ -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;
}
+97 -52
View File
@@ -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 soonGuardPreviewDeliver(actionLabel, onAllowed) {
if (typeof onAllowed !== 'function') return;
if (!soonIsWebPortal()) {
onAllowed();
return;
}
function soonRequireMember(actionLabel) {
if (!soonGetAccessToken()) return soonRequireLogin(actionLabel);
if (soonIsMember()) return true;
if (typeof window.soonShowSubscribeGate === 'function') {
window.soonShowSubscribeGate({ action: actionLabel || '使用' });
if (!soonGetAccessToken()) {
if (typeof window.soonShowLoginGate === 'function') {
window.soonShowLoginGate({
reason: actionLabel || '导出或打印',
onSuccess: function () {
soonGuardPreviewDeliver(actionLabel, onAllowed);
},
});
} else {
soonToast('订阅后可' + (actionLabel || '使用'), 'warn');
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();
}
})();
+249
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 '<li>' + esc(t) + '</li>';
}).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 '<label class="soon-activate-channel' + (i === 0 ? ' is-active' : '') + '">' +
'<input type="radio" name="activate_channel" value="' + esc(ch) + '"' + (i === 0 ? ' checked' : '') + '>' +
esc(label) + '</label>';
}).join('');
return '<div class="soon-subscribe-modal soon-activate-modal">' +
'<header class="soon-subscribe-modal__head">' +
'<span class="soon-subscribe-modal__eyebrow">会员激活</span>' +
'<h3 class="soon-subscribe-modal__title">激活会员</h3>' +
'<p class="soon-subscribe-modal__desc">普通用户可使用全部设计工具;预览内' + esc(actionLabel || '导出或打印') + '需激活会员。</p>' +
'<ul class="soon-subscribe-modal__features">' + feat + '</ul>' +
'</header>' +
'<div class="soon-subscribe-modal__body">' +
'<div class="soon-activate-price"><span class="soon-activate-price__label">激活价格</span>' +
'<span class="soon-activate-price__value">¥' + esc(price) + '</span>' +
'<span class="soon-activate-price__hint">一次激活,永久有效</span></div>' +
'<div class="soon-activate-channels" data-role="channels">' + chHtml + '</div>' +
'<p class="soon-activate-load" data-role="plan-status"></p>' +
'</div>' +
'<footer class="soon-subscribe-modal__foot">' +
'<button type="button" class="soon-subscribe-modal__stay" data-action="stay">继续设计</button>' +
'<button type="button" class="soon-btn soon-subscribe-modal__pay" data-action="go-pay" disabled>立即激活</button>' +
'</footer></div>';
}
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();
});
});
}
})();
+152
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 '<div class="soon-login-gate">' +
'<header class="soon-subscribe-modal__head">' +
'<span class="soon-subscribe-modal__eyebrow">登录</span>' +
'<h3 class="soon-subscribe-modal__title">登录后继续</h3>' +
'<p class="soon-subscribe-modal__desc">预览内' + esc(reason || '导出或打印') + '需先登录账号。</p>' +
'</header>' +
'<form class="soon-login-gate__form" data-role="login-form">' +
'<div class="soon-input-wrap"><input type="email" name="email" required class="soon-input" placeholder="邮箱" autocomplete="email"></div>' +
'<div class="soon-input-wrap"><input type="password" name="password" required class="soon-input" placeholder="密码" autocomplete="current-password"></div>' +
'<p class="soon-login-gate__error" data-role="login-error" style="display:none"></p>' +
'<button type="submit" class="soon-btn soon-btn--block">登录</button>' +
'</form>' +
'<p class="soon-login-gate__links">还没有账号?<a href="register.web.html" target="_blank" rel="noopener">立即注册</a></p>' +
'<footer class="soon-subscribe-modal__foot">' +
'<button type="button" class="soon-subscribe-modal__stay" data-action="stay">继续设计</button>' +
'</footer></div>';
}
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;
})();
+4 -23
View File
@@ -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 ? '' :
'<header class="soon-pay-sheet__head">' +
'<div class="soon-pay-sheet__head-main">' +
'<span class="soon-pay-sheet__eyebrow">' + (resumeOrderNo ? '继续支付' : '确认订阅') + '</span>' +
'<span class="soon-pay-sheet__eyebrow">' + (resumeOrderNo ? '继续支付' : '确认激活') + '</span>' +
'<h3 class="soon-pay-sheet__title">' + esc(opts.planName || '') + '</h3>' +
'<p class="soon-pay-sheet__order" id="payOrderNo"' +
(resumeOrderNo ? '' : ' style="display:none"') + '>' +
@@ -381,7 +364,7 @@
function handlePaid() {
stopPoll();
setStep(3);
setStatus('支付成功,正在更新订阅…', 'ok');
setStatus('支付成功,正在更新会员状态…', 'ok');
setTimeout(onPaid, 600);
}
@@ -538,8 +521,6 @@
apiGet: apiGet,
apiPost: apiPost,
esc: esc,
monthlyPlan: monthlyPlan,
billingSavePercent: billingSavePercent,
paySheetHtml: paySheetHtml,
bindPaySheet: bindPaySheet,
openPayModal: openPayModal,
-104
View File
@@ -1,104 +0,0 @@
(function () {
'use strict';
var CHECK_SVG = '<svg viewBox="0 0 12 12" fill="none" stroke-width="2" stroke="currentColor">' +
'<path d="M2 6l3 3 5-6"/></svg>';
function esc(s) {
if (window.SoonMemberPay && window.SoonMemberPay.esc) return window.SoonMemberPay.esc(s);
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function isPlanFeatureLine(line) {
if (!line) return false;
return line.indexOf('到期') < 0
&& line.indexOf('订阅有效期') < 0
&& line.indexOf('订阅周期') < 0
&& line.indexOf('权益一致') < 0;
}
function featureItem(text) {
return '<li><span class="soon-plan-card__check">' + CHECK_SVG + '</span><span>' + esc(text) + '</span></li>';
}
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
? '<span class="soon-plan-card__ribbon soon-plan-card__ribbon--rec">推荐</span>'
: (plan.code === currentCode ? '<span class="soon-plan-card__ribbon soon-plan-card__ribbon--current">当前</span>' : '');
var savePct = billingSavePercent(items, plan);
if (savePct) ribbon += '<span class="soon-plan-card__save">省 ' + savePct + '%</span>';
var badgeCls = ribbon ? ' soon-plan-card--badged' : '';
if (savePct) badgeCls += ' soon-plan-card--save';
var period = plan.duration_days > 0
? '<span class="soon-plan-card__period">/' + esc(plan.period_label || plan.duration_days + '天') + '</span>'
: '';
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 +
'<div class="soon-plan-card__head">' +
'<div class="soon-plan-card__name">' + esc(plan.name) + '</div>' +
(desc ? '<p class="soon-plan-card__desc">' + esc(desc) + '</p>' : '') +
'<div class="soon-plan-card__price-row">' +
'<span class="soon-plan-card__currency">¥</span>' +
'<span class="soon-plan-card__price">' + price + '</span>' + period + '</div></div>';
if (!isGate) {
inner += '<div class="soon-plan-card__body">' +
'<ul class="soon-plan-card__features">' + features + '</ul></div>';
}
if (isPicker) {
return '<button type="button" class="soon-plan-card' + feat + cur + badgeCls + picker + selected + '" ' +
'data-action="pick-plan" data-id="' + plan.id + '" aria-pressed="' + (plan.id === selectedId ? 'true' : 'false') + '">' +
inner + '</button>';
}
var cta = '<button type="button" class="soon-btn soon-plan-card__cta" data-id="' + plan.id +
'" data-name="' + esc(plan.name) + '" data-price="' + price + '">' +
(isMember ? '续订' : '立即订阅') + '</button>';
return '<article class="soon-plan-card' + feat + cur + badgeCls + '">' + inner +
'<div class="soon-plan-card__foot">' + cta + '</div></article>';
}
function plansGridHtml(plans, ctx) {
return (plans || []).map(function (p) {
return planCardHtml(p, ctx);
}).join('');
}
window.SoonMemberPlan = {
isPlanFeatureLine: isPlanFeatureLine,
planCardHtml: planCardHtml,
plansGridHtml: plansGridHtml,
};
})();
-306
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 '<li>' + esc(t) + '</li>';
}).join('');
return '<div class="soon-subscribe-modal">' +
'<header class="soon-subscribe-modal__head">' +
'<span class="soon-subscribe-modal__eyebrow">订阅功能</span>' +
'<h3 class="soon-subscribe-modal__title">开通订阅,解锁完整能力</h3>' +
'<p class="soon-subscribe-modal__desc">免费版可使用设计工具;订阅后可' + esc(actionCopy(actionLabel)) + '。</p>' +
'<ul class="soon-subscribe-modal__features">' + feat + '</ul>' +
'</header>' +
'<div class="soon-subscribe-modal__body">' +
'<div class="soon-subscribe-modal__section-head">' +
'<h4 class="soon-subscribe-modal__section-title"><span class="soon-subscribe-modal__section-mark"></span>订阅方案</h4>' +
'<p class="soon-subscribe-modal__section-sub">选择订阅周期,功能相同,随时续订</p></div>' +
'<section class="soon-plans-grid soon-subscribe-plans-grid" data-role="plan-picker">' +
'<p class="soon-subscribe-plans__loading">正在加载订阅方案…</p></section>' +
'</div>' +
'<footer class="soon-subscribe-modal__foot">' +
'<button type="button" class="soon-subscribe-modal__stay" data-action="stay">继续设计</button>' +
'<button type="button" class="soon-btn soon-subscribe-modal__pay" data-action="go-pay" disabled>立即支付</button>' +
'</footer></div>';
}
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 = '<p class="soon-subscribe-plans__empty">暂无可用订阅方案</p>';
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 = '<p class="soon-subscribe-plans__empty">' + esc(message) +
'<br><button type="button" class="soon-btn soon-btn--sm" data-action="retry-plans">重试</button></p>';
updatePayButton(modal);
}
function loadPlans(modal) {
var picker = modal.querySelector('[data-role="plan-picker"]');
if (!picker) return;
if (!pay || !plansUi) {
renderPlansError(modal, '订阅模块未加载,请刷新页面');
return;
}
picker.innerHTML = '<p class="soon-subscribe-plans__loading">正在加载订阅方案…</p>';
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();
});
});
}
})();
-2
View File
@@ -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) || '';
+2 -4
View File
@@ -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 @@
'<span id="auth_identity" class="soon-portal-identity" style="display:none"></span>' +
'</div>' +
'<nav class="soon-portal-topbar__session" id="auth_session" style="display:none" aria-label="账户">' +
'<a href="member.web.html" id="auth_member" class="soon-portal-topbar__link" style="display:none">订阅</a>' +
'<a href="admin/index.html" id="auth_admin" class="soon-portal-topbar__link" style="display:none">管理</a>' +
'<a href="#" id="auth_logout" class="soon-portal-topbar__link" style="display:none">退出</a>' +
'</nav>';
@@ -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 '<a href="' + esc(lnk.href) + '" class="' + cls + '">' + esc(lnk.label) + '</a>';
}).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) {
+18 -5
View File
@@ -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) {
</div>
</div>`,
btn: [language_str("output"), "打印"],//'导出'
btn1: function (index, layero) {
btn1: function () {
if (typeof window.soonGuardPreviewDeliver === 'function') {
window.soonGuardPreviewDeliver(language_str('output') || '导出', function () {
saveImageAsPNG(buffer);
});
} else {
saveImageAsPNG(buffer);
}
return false;
},
btn2: function () {
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;
+8 -2
View File
@@ -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;
+28 -6
View File
@@ -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,12 +354,19 @@ window.display_func = function display_func(img1, img2, img3) {
</div>
</div>`,
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 (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);
@@ -372,13 +377,32 @@ window.display_func = function display_func(img1, img2, img3) {
} 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);
} 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 (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);
} else {
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;
+8 -2
View File
@@ -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
+207 -104
View File
@@ -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('登录后查看文件', '请先登录以管理云端设计文件',
'<a href="login.web.html" class="soon-btn soon-btn--sm soon-empty__action">去登录</a>'));
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 = '<button type="button" class="soon-file-card__action soon-file-card__download" title="下载 .soon" aria-label="下载">'
var actions = '';
if (item.kind === 'cloud' && item.fileId) {
actions = '<button type="button" class="soon-file-card__action soon-file-card__download" title="下载 .soon" aria-label="下载">'
+ '<svg class="soon-file-card__icon" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">'
+ '<path d="M12 4v10m0 0l3.5-3.5M12 14l-3.5-3.5M5 20h14"/></svg></button>';
var delBtn = '<button type="button" class="soon-file-card__action soon-file-card__delete" title="' + escapeAttr(language_str('deleteBtn')) + '" aria-label="' + escapeAttr(language_str('deleteBtn')) + '">'
+ '<path d="M12 4v10m0 0l3.5-3.5M12 14l-3.5-3.5M5 20h14"/></svg></button>'
+ '<button type="button" class="soon-file-card__action soon-file-card__delete" title="' + escapeAttr(language_str('deleteBtn')) + '" aria-label="' + escapeAttr(language_str('deleteBtn')) + '">'
+ '<svg class="soon-file-card__icon" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">'
+ '<path d="M4 7h16"/><path d="M7 7l1.1 11.1a.9.9 0 00.9.9h6a.9.9 0 00.9-.9L17 7"/>'
+ '<path d="M9.5 7V5.6A1.6 1.6 0 0111.1 4h1.8a1.6 1.6 0 011.6 1.6V7"/>'
+ '<path d="M10 11v5.5"/><path d="M14 11v5.5"/></svg></button>';
} else {
actions = '<button type="button" class="soon-file-card__action soon-file-card__delete" title="' + escapeAttr(language_str('deleteBtn')) + '" aria-label="' + escapeAttr(language_str('deleteBtn')) + '">'
+ '<svg class="soon-file-card__icon" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">'
+ '<path d="M4 7h16"/><path d="M7 7l1.1 11.1a.9.9 0 00.9.9h6a.9.9 0 00.9-.9L17 7"/>'
+ '<path d="M9.5 7V5.6A1.6 1.6 0 0111.1 4h1.8a1.6 1.6 0 011.6 1.6V7"/>'
+ '<path d="M10 11v5.5"/><path d="M14 11v5.5"/></svg></button>';
}
h += `<div class="${cardClass}" data-file="${escapeAttr(filePath)}" data-id="${item.id}" data-name="${escapeAttr(displayName)}" title="${escapeAttr(cardTitle)}">
h += `<div class="${cardClass}" data-file="${escapeAttr(filePath)}" data-kind="${item.kind}"${item.fileId ? (' data-id="' + item.fileId + '"') : ''} data-name="${escapeAttr(displayName)}" title="${escapeAttr(cardTitle)}">
<div class="rect ${realType == 2 ? 'rect1' : ''}">
<div class="soon-file-card__actions">${dlBtn}${delBtn}</div>
<div class="soon-file-card__actions">${actions}</div>
<img src="${escapeAttr(src)}" style="${imgStyle}" alt="">
@@ -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 '<div class="card" data-type="' + type + '" data-index="' + itemIndex + '" title="' + name + '">' +
'<div class="' + rectClass + '">' +
'<img src="' + esc(thumbSrc) + '" alt="">' +
'<img src="' + esc(thumbSrc) + '" alt="" loading="lazy" onerror="this.onerror=null;this.src=\'' + esc(fallback) + '\'">' +
'<div class="tip">' + name + '</div>' +
'</div></div>';
}
@@ -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: '<div class="soon-dialog-body">' + language_str('deleteFileConfirm') + '</div>',
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 {
-472
View File
@@ -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 '<span class="soon-member-status-pill soon-member-status-pill--free">免费版</span>';
}
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 '<span class="soon-member-status-pill ' + cls + '">' + text + '</span>';
}
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 '<div class="soon-member-storage-ring">' +
'<svg viewBox="0 0 120 120" aria-hidden="true">' +
'<defs>' +
'<linearGradient id="memberRingGrad" x1="0%" y1="0%" x2="100%" y2="0%">' +
'<stop offset="0%" stop-color="#00897b"/><stop offset="100%" stop-color="#4db6ac"/>' +
'</linearGradient>' +
'<linearGradient id="memberRingWarn" x1="0%" y1="0%" x2="100%" y2="0%">' +
'<stop offset="0%" stop-color="#f57c00"/><stop offset="100%" stop-color="#ffb74d"/>' +
'</linearGradient></defs>' +
'<circle class="soon-member-storage-ring__track" cx="60" cy="60" r="' + r + '"/>' +
'<circle class="soon-member-storage-ring__fill' + fillCls + '" cx="60" cy="60" r="' + r + '" ' +
'stroke-dasharray="' + c + '" stroke-dashoffset="' + offset + '"/></svg>' +
'<div class="soon-member-storage-ring__center">' +
'<span class="soon-member-storage-ring__pct">' + metrics.pctLabel + '%</span>' +
'<span class="soon-member-storage-ring__label">已使用</span></div></div>';
}
function renderMyPlan(m) {
var el = document.getElementById('myPlan');
if (!el) return;
if (!m) {
el.innerHTML = '<div class="soon-member-dash"><div class="soon-member-dash__body">' +
'<p class="soon-member-dash__desc">加载失败,请重试</p>' +
'<button type="button" class="soon-btn soon-btn--sm" id="myPlanRetry">重试</button></div></div>';
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 =
'<div class="soon-member-dash">' +
'<div class="soon-member-dash__body soon-member-dash__body--guest">' +
'<div class="soon-member-dash__badge-row">' +
'<span class="soon-member-tier soon-member-tier--free">免费版</span>' + statusPill(sub, false) + '</div>' +
'<h2 class="soon-member-dash__title">' + esc(m.name || '免费版') + '</h2>' +
'<p class="soon-member-dash__desc">' + esc(m.description || '免费体验设计与编辑') + '</p>' +
'<p class="soon-member-dash__hint">订阅后可预览、打印、保存并导出作品</p>' +
'<div class="soon-member-dash__actions">' +
'<button type="button" class="soon-btn soon-btn--sm" id="myPlanGoPlans">查看订阅方案</button>' +
'</div></div></div>';
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 =
'<div class="soon-member-dash">' +
'<div class="soon-member-dash__body">' +
'<div class="soon-member-dash__top">' +
'<div><div class="soon-member-dash__badge-row">' +
'<span class="soon-member-tier">订阅版</span>' + statusPill(sub, true) + '</div>' +
'<h2 class="soon-member-dash__title">' + esc(m.name) + '</h2>' +
'<p class="soon-member-dash__desc">' + esc(m.description || '已解锁完整交付能力') + '</p>' +
'<p class="soon-member-dash__expire">有效期至 <strong>' + expires + '</strong> · 剩余 ' + esc(days) + '</p></div>' +
storageRing(metrics, warn) + '</div>' +
'<div class="soon-member-dash__usage">' +
'<div class="soon-member-dash__usage-head"><span>云端存储占用</span><strong>' +
esc(metrics.usedDisplay) + ' / ' + esc(m.quota_display || quota + ' MB') +
'' + metrics.pctLabel + '%</strong></div>' +
'<div class="soon-member-progress"><div class="' + barCls + '" style="width:' + metrics.barWidth + '%"></div></div>' +
'</div></div>' +
'<div class="soon-member-dash__stats">' +
'<div class="soon-member-dash__stat"><div class="soon-member-dash__stat-value">' + (usage.files_count || 0) +
'</div><div class="soon-member-dash__stat-label">云端文件</div></div>' +
'<div class="soon-member-dash__stat"><div class="soon-member-dash__stat-value">' + esc(days) +
'</div><div class="soon-member-dash__stat-label">剩余天数</div></div>' +
'</div></div>';
}
function orderBadge(status, refundStatus) {
if (refundStatus === 'pending') {
return '<span class="soon-member-order-badge soon-member-order-badge--pending">退款审核</span>';
}
var cls = 'soon-member-order-badge--' + (status || 'pending');
return '<span class="soon-member-order-badge ' + cls + '">' + esc(STATUS_LABEL[status] || status) + '</span>';
}
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 '<div class="soon-member-pager">' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="orderPrev"' +
(page <= 1 ? ' disabled' : '') + '>上一页</button>' +
'<span>第 ' + page + ' / ' + pages + ' 页(共 ' + total + ' 条)</span>' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="orderNext"' +
(page >= pages ? ' disabled' : '') + '>下一页</button></div>';
}
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('<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-member-order-act" ' +
'data-act="pay" data-no="' + esc(o.order_no) + '" data-plan="' + esc(o.plan_name) + '" data-price="' +
(o.amount_cents / 100).toFixed(2) + '" data-channel="' + esc(o.channel) + '">继续支付</button>');
actions.push('<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-member-order-act soon-member-order-act--muted" ' +
'data-act="cancel" data-no="' + esc(o.order_no) + '">取消</button>');
} else if (o.status === 'paid' && (!o.refund_status || o.refund_status === 'none')) {
actions.push('<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-member-order-act" ' +
'data-act="refund" data-no="' + esc(o.order_no) + '">申请退款</button>');
}
if (!actions.length) return '<span class="soon-member-order-act--empty">—</span>';
return '<div class="soon-member-order-actions">' + actions.join('') + '</div>';
}
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 = '<div class="soon-member-orders-empty">暂无订单,订阅后将显示在这里</div>' +
orderPagerHtml();
bindOrderPager();
return;
}
var rows = orders.map(function (o) {
return '<tr><td>' + esc(o.order_no) + '</td><td>' + esc(o.plan_name) + '</td><td class="soon-member-orders__amount">¥' +
(o.amount_cents / 100).toFixed(2) + '</td><td>' + esc(CHANNEL_LABEL[o.channel] || o.channel) +
'</td><td>' + orderBadge(o.status, o.refund_status) + '</td><td>' +
esc(String(o.paid_at || o.created_at || '').slice(0, 16).replace('T', ' ')) + '</td><td>' +
orderActionCell(o) + '</td></tr>';
}).join('');
el.innerHTML = '<table><thead><tr><th>订单号</th><th>方案</th><th>金额</th><th>渠道</th><th>状态</th><th>时间</th><th>操作</th></tr></thead><tbody>' +
rows + '</tbody></table>' + 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 = '<div class="soon-member-orders-empty" style="grid-column:1/-1">暂无可用订阅方案</div>';
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 = '<div class="soon-member-orders-empty" style="grid-column:1/-1">' +
esc(message || '方案加载失败') +
'<br><button type="button" class="soon-btn soon-btn--sm" style="margin-top:14px" id="plansRetry">重试</button></div>';
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 = '<div class="soon-member-orders-empty">加载失败,<button type="button" class="soon-btn soon-btn--sm" id="ordersRetry">重试</button></div>';
var ordersRetry = document.getElementById('ordersRetry');
if (ordersRetry) ordersRetry.onclick = function () { location.reload(); };
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+29 -14
View File
@@ -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) {
@@ -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',
@@ -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: '需在订单页处理' });
@@ -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 = '<p class="soon-admin-detail-empty">无关联订阅</p>';
subHtml = '<p class="soon-admin-detail-empty">无关联会员记录</p>';
}
var drawerActions = rowActions(o).replace(/<button[^>]*order-detail-btn[^>]*>[\s\S]*?<\/button>/, '');
@@ -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('价格与周期',
'<div class="soon-admin-form-grid">' +
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 })) +
'</div>' +
@@ -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 })) +
'</div>',
'云端存储与文件数量上限;预览/保存/导出仍受订阅状态约束。'
'云端存储与文件数量上限。'
);
var benefitTitle = free ? '免费版展示' : '订阅包含';
var benefitTitle = free ? '免费版展示' : '会员权益';
var benefitHint = free
? '展示在会员中心免费版区域;补充说明写在简介中。'
? '展示在用户端身份说明中。'
: periodHint(plan);
var benefitPanel = Form().panel(benefitTitle,
'<div class="soon-admin-feature-list" id="peFeatures">' + featureRowsHtml(features) + '</div>' +
@@ -158,7 +164,7 @@
Form().actions('保存', 'adminModalSave') +
'</div>';
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 = '<div class="soon-admin-page">' +
Ui().pageHeader('订阅方案', '配置价格、周期、配额与用户端展示文案') +
Ui().pageHeader('会员激活', '配置激活价格、配额与用户端展示文案') +
Ui().skeletonTable() + '</div>';
Api().get('plans').then(function (j) {
@@ -230,7 +236,7 @@
}).join('');
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('订阅方案', '配置价格、周期、配额与用户端展示文案') +
Ui().pageHeader('会员激活', '配置激活价格、配额与用户端展示文案') +
Ui().dataTable(
['代码', '名称', '价格', '权益', '存储', '有效期', '推荐', '状态', '操作'],
rows
@@ -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 文件',
'<input type="file" id="tplFile" class="soon-input soon-input--sm" accept=".soon,application/json">' +
(required ? '' : '<p class="soon-admin-form-hint soon-admin-form-hint--tight">留空则不替换已有文件</p>'),
required ? '须为有效的 .soon JSON 文件' : '')
);
}
function metaFields(plan) {
plan = plan || {};
return Form().panel('展示设置',
Form().field('名称', Form().input('tplName', { value: plan.name || '' })) +
'<div class="soon-admin-form-grid">' +
Form().field('排序', Form().input('tplSort', { type: 'number', value: plan.sort_order != null ? plan.sort_order : 0 })) +
Form().field('类型', '<input class="soon-input soon-input--sm" value="' + I().esc(typeLabel(plan.type)) + '" disabled>') +
'</div>' +
'<label class="soon-admin-check"><input type="checkbox" id="tplActive"' +
(plan.is_active !== false ? ' checked' : '') + '> 上架展示</label>'
);
}
function openCreateModal() {
var form = '<div class="soon-admin-form">' + uploadFields(true) + metaFields() +
Form().actions('上传', 'adminModalSave') + '</div>';
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 = '<div class="soon-admin-form">' + metaFields(plan) + uploadFields(false) +
Form().actions('保存', 'adminModalSave') + '</div>';
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 = '<div class="soon-admin-page">' +
Ui().pageHeader('模板库', '管理首页展示的 .soon 模板(缩略图与名称轻量加载)',
'<button type="button" class="soon-btn soon-btn--sm" id="tplCreate">上传模板</button>') +
Ui().skeletonTable() + '</div>';
Api().get('templates').then(function (j) {
if (!j) return;
var items = j.data.items || [];
var rows = items.map(function (p) {
return '<tr data-id="' + p.id + '"><td>' + p.id + '</td><td>' + I().esc(p.name) +
'</td><td>' + typeLabel(p.type) + '</td><td>' + formatBytes(p.file_size) + '</td><td>' +
p.sort_order + '</td><td>' + (p.has_thumb ? '有' : '—') + '</td><td>' +
activeBadge(p.is_active) + '</td><td>' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost tpl-edit">编辑</button> ' +
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost tpl-del">删除</button></td></tr>';
}).join('');
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('模板库', '管理首页展示的 .soon 模板(缩略图与名称轻量加载)',
'<button type="button" class="soon-btn soon-btn--sm" id="tplCreate">上传模板</button>') +
Ui().dataTable(
['ID', '名称', '类型', '大小', '排序', '缩略图', '状态', '操作'],
rows
) + '</div>';
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 };
})();
@@ -205,7 +205,7 @@
Ui().detailRow('开始时间', I().esc(I().formatDateTime(sub.started_at))) +
Ui().detailRow('到期时间', I().esc(I().formatDateTime(sub.expires_at)));
} else {
subHtml = '<p class="soon-admin-detail-empty">免费版 / 无有效订阅</p>';
subHtml = '<p class="soon-admin-detail-empty">普通用户 / 未激活</p>';
}
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 },
+3
View File
@@ -19,6 +19,7 @@
<div class="soon-admin-nav-item" data-tab="users"><img src="../../assets/images/web/admin/users.svg" alt=""><span>用户</span></div>
<div class="soon-admin-nav-item" data-tab="orders"><img src="../../assets/images/web/admin/orders.svg" alt=""><span>订单</span></div>
<div class="soon-admin-nav-item" data-tab="plans"><img src="../../assets/images/web/admin/plans.svg" alt=""><span>套餐</span></div>
<div class="soon-admin-nav-item" data-tab="templates"><img src="../../assets/images/web/admin/plans.svg" alt=""><span>模板库</span></div>
<div class="soon-admin-nav-item" data-tab="settings" data-require="full"><img src="../../assets/images/web/admin/settings.svg" alt=""><span>设置</span></div>
<div class="soon-admin-nav-item" data-tab="audits" data-require="full"><img src="../../assets/images/web/admin/audits.svg" alt=""><span>审计</span></div>
<div class="soon-admin-nav-item" data-tab="payment" data-require="full"><img src="../../assets/images/web/admin/payment.svg" alt=""><span>支付密钥</span></div>
@@ -28,6 +29,7 @@
<div id="panel-users" class="panel"></div>
<div id="panel-orders" class="panel"></div>
<div id="panel-plans" class="panel"></div>
<div id="panel-templates" class="panel"></div>
<div id="panel-settings" class="panel"></div>
<div id="panel-audits" class="panel"></div>
<div id="panel-payment" class="panel"></div>
@@ -55,6 +57,7 @@
<script src="assets/js/views/users.js"></script>
<script src="assets/js/views/orders.js"></script>
<script src="assets/js/views/plans.js"></script>
<script src="assets/js/views/templates.js"></script>
<script src="assets/js/views/settings.js"></script>
<script src="assets/js/views/audits.js"></script>
<script src="assets/js/views/payment.js"></script>
+2 -3
View File
@@ -12,7 +12,6 @@
<link rel="stylesheet" href="../assets/css/portal.css">
<link rel="stylesheet" href="../assets/css/design.css">
<link rel="stylesheet" href="../assets/css/subscribe-gate.css">
<link rel="stylesheet" href="../assets/css/member.css">
<style>
/* ==================================================
1. 全局布局 (Flex 自适应:完美兼容 Windows/Mac)
@@ -879,7 +878,6 @@
<script src="../js/common/auth-redirect.js"></script>
<script src="../js/common/auth-session.js"></script>
<script src="../js/common/member-pay-core.js"></script>
<script src="../js/common/member-plan-core.js"></script>
<script src="../js/common/cloud-files.js"></script>
<script src="../js/common/asset-base.js"></script>
<script src="../js/common/portal-topbar.js"></script>
@@ -887,7 +885,8 @@
<script src="../vendor/layui/layui.js"></script>
<script src="../vendor/js/qrcode.min.js"></script>
<script src="../js/common/soon-toast.js"></script>
<script src="../js/common/member-subscribe.js"></script>
<script src="../js/common/member-login-gate.js"></script>
<script src="../js/common/member-activate.js"></script>
<script src="../js/platform/web.js?v=basepath2"></script>
<script src="../vendor/js/jr-qrcode.js"></script>
<script src="../vendor/js/JsBarcode.all.min.js"></script>
+2 -3
View File
@@ -12,7 +12,6 @@
<link rel="stylesheet" href="../assets/css/portal.css">
<link rel="stylesheet" href="../assets/css/design.css">
<link rel="stylesheet" href="../assets/css/subscribe-gate.css">
<link rel="stylesheet" href="../assets/css/member.css">
<style>
/* 标尺样式 */
.ruler-container {
@@ -669,7 +668,6 @@
<script src="../js/common/auth-redirect.js"></script>
<script src="../js/common/auth-session.js"></script>
<script src="../js/common/member-pay-core.js"></script>
<script src="../js/common/member-plan-core.js"></script>
<script src="../js/common/cloud-files.js"></script>
<script src="../js/common/asset-base.js"></script>
<script src="../js/common/portal-topbar.js"></script>
@@ -677,7 +675,8 @@
<script src="../vendor/layui/layui.js"></script>
<script src="../vendor/js/qrcode.min.js"></script>
<script src="../js/common/soon-toast.js"></script>
<script src="../js/common/member-subscribe.js"></script>
<script src="../js/common/member-login-gate.js"></script>
<script src="../js/common/member-activate.js"></script>
<script src="../js/platform/web.js?v=basepath2"></script>
<script src="../vendor/js/jr-qrcode.js"></script>
<script src="../vendor/js/JsBarcode.all.min.js"></script>
+1 -7
View File
@@ -19,8 +19,6 @@
<link rel="stylesheet" href="../assets/css/layer-soon.css">
<link rel="stylesheet" href="../assets/css/index.css">
<link rel="stylesheet" href="../assets/css/subscribe-gate.css">
<link rel="stylesheet" href="../assets/css/member.css">
</head>
@@ -141,14 +139,9 @@
<script src="../js/common/auth-redirect.js"></script>
<script src="../js/common/auth-session.js"></script>
<script src="../js/common/member-pay-core.js"></script>
<script src="../js/common/member-plan-core.js"></script>
<script src="../js/common/cloud-files.js"></script>
<script src="../vendor/layui/layui.js"></script>
<script src="../vendor/js/qrcode.min.js"></script>
<script src="../js/common/soon-toast.js"></script>
<script src="../js/common/member-subscribe.js"></script>
<script src="../js/common/asset-base.js"></script>
@@ -166,6 +159,7 @@
if (window.soonLoadMembership) {
window.soonLoadMembership().then(function () {
if (window.soonRefreshPortalIdentity) window.soonRefreshPortalIdentity();
if (window.soonReloadRecentFiles) window.soonReloadRecentFiles();
});
}
</script>
-71
View File
@@ -1,71 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="SoonDesign 订阅与账户">
<link rel="icon" href="../assets/images/favicon.ico">
<title>订阅中心 · SoonDesign</title>
<link rel="stylesheet" href="../vendor/layui/css/layui.css">
<link rel="stylesheet" href="../assets/css/layer-soon.css">
<link rel="stylesheet" href="../assets/css/portal.css">
<link rel="stylesheet" href="../assets/css/member.css">
</head>
<body class="soon-member-page">
<div class="soon-pay-banner" id="payBanner">支付成功,订阅已生效</div>
<div id="portal-topbar"></div>
<div class="soon-member-wrap">
<header class="soon-member-pagehead">
<div>
<p class="soon-member-pagehead__eyebrow">订阅服务</p>
<h1 class="soon-member-pagehead__title">订阅中心</h1>
<p class="soon-member-pagehead__sub">免费体验设计工具;订阅后解锁预览、打印与云端交付</p>
</div>
</header>
<section class="soon-member-dashboard" id="myPlan">
<div class="soon-skeleton soon-skeleton--h160"></div>
</section>
<section class="soon-member-section">
<div class="soon-member-section__head">
<h2><span class="soon-member-section__mark"></span>订阅方案</h2>
<p>选择适合您的订阅周期,功能相同,随时续订</p>
</div>
<div class="soon-plans-grid" id="plans">
<div class="soon-skeleton soon-skeleton--h320" style="grid-column:1/-1"></div>
</div>
</section>
<section class="soon-member-section soon-member-section--last">
<div class="soon-member-section__head">
<h2><span class="soon-member-section__mark"></span>订单记录</h2>
<p>订阅与支付记录,支持分页浏览</p>
</div>
<div class="soon-member-orders" id="orderHistory">
<div class="soon-member-orders-empty">加载中...</div>
</div>
</section>
</div>
<script src="../config/local.js"></script>
<script src="../js/common/auth-redirect.js"></script>
<script src="../js/common/auth-session.js"></script>
<script src="../js/common/member-pay-core.js"></script>
<script src="../js/common/member-plan-core.js"></script>
<script src="../js/common/cloud-files.js"></script>
<script src="../js/common/portal-topbar.js"></script>
<script src="../js/common/portal-auth.js"></script>
<script src="../vendor/js/qrcode.min.js"></script>
<script src="../vendor/layui/layui.js"></script>
<script>
layui.use(['layer'], function () {
window.layer = layui.layer;
layer.config({ skin: 'soon-layer' });
});
</script>
<script src="../js/member.js"></script>
</body>
</html>