Lufaneng 4 months ago
commit e38053487d

28
.gitignore vendored

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
release/
.codex-tmp
*.local
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
.env.prod
backend/uploads/*
!backend/uploads/.gitkeep

@ -0,0 +1,46 @@
NODE_ENV=production
PORT=3100
PUBLIC_BASE_URL=http://127.0.0.1:3100
# Optional. Leave empty to use backend/public/pc-admin.
PC_ADMIN_DIST_DIR=
# Reserved H5 preview config. Keep disabled for the current PC-only release.
ENABLE_H5_PREVIEW=false
# Optional. When H5 preview is enabled later, leave empty to use backend/public/h5.
H5_DIST_DIR=
# Signed login session. Replace with a long random value before production deployment.
AUTH_SESSION_SECRET=replace_with_at_least_32_random_characters
# Default: 168 hours (7 days).
AUTH_SESSION_TTL_HOURS=168
DB_CLIENT=mysql
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=lifanghe
MYSQL_PASSWORD=lifanghe123
MYSQL_DATABASE=lifanghe_education
MYSQL_CONNECTION_LIMIT=10
# WeChat Mini Program config for login, phone binding, and mini program code generation.
WECHAT_APP_ID=wx_your_app_id
WECHAT_APP_SECRET=your_wechat_app_secret
# Use release for production, trial for experience version, develop for development version.
WECHAT_ENV_VERSION=release
# SMS verification config.
# In production set SMS_ALLOW_TEST_CODE=false so the fixed test code cannot pass.
SMS_PROVIDER=aliyun
SMS_ALLOW_TEST_CODE=true
SMS_TEST_CODE=111111
SMS_CODE_LENGTH=6
SMS_CODE_TTL_SECONDS=300
SMS_CODE_COOLDOWN_SECONDS=60
# Aliyun SMS OpenAPI config. The SMS template must contain a variable named ${code}
# unless ALIYUN_SMS_TEMPLATE_PARAM_KEY is changed.
ALIYUN_ACCESS_KEY_ID=your_aliyun_access_key_id
ALIYUN_ACCESS_KEY_SECRET=your_aliyun_access_key_secret
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
ALIYUN_SMS_SIGN_NAME=your_sms_sign_name
ALIYUN_SMS_TEMPLATE_CODE=SMS_000000000
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code

@ -0,0 +1,24 @@
# 立方和教育推广机制后端
Node + Express 分层接口,用内存仓库模拟数据库,方便后续替换为 MySQL、PostgreSQL 或 MongoDB。
## Scripts
```bash
npm install
npm run dev
```
默认服务地址:
```text
http://localhost:3100/api
```
## Main Modules
- `controllers/`: HTTP 入参和出参
- `services/`: 推荐、积分、课程、管理台业务规则
- `repositories/`: 数据访问层
- `data/seed.js`: 初始模拟数据
- `routes/`: API 路由聚合

File diff suppressed because it is too large Load Diff

@ -0,0 +1,16 @@
{
"name": "lifanghe-education-backend",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"morgan": "^1.10.0",
"mysql2": "^3.22.5"
}
}

@ -0,0 +1,220 @@
CREATE DATABASE IF NOT EXISTS lifanghe_education DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE lifanghe_education;
CREATE TABLE IF NOT EXISTS roles (
role VARCHAR(40) PRIMARY KEY,
name VARCHAR(80) NOT NULL,
permissions JSON NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(40) PRIMARY KEY,
username VARCHAR(80) NOT NULL UNIQUE,
password_hash CHAR(64) NULL,
role VARCHAR(40) NOT NULL,
role_name VARCHAR(80) NOT NULL,
name VARCHAR(80) NOT NULL,
phone VARCHAR(30) NULL UNIQUE,
wechat_openid VARCHAR(80) NULL UNIQUE,
wechat_unionid VARCHAR(80) NULL UNIQUE,
wechat_nickname VARCHAR(80) NULL,
wechat_avatar VARCHAR(500) NULL,
login_provider VARCHAR(30) NOT NULL DEFAULT 'password',
phone_bound TINYINT(1) NOT NULL DEFAULT 1,
phone_bound_at VARCHAR(32) NULL,
wechat_bound_at VARCHAR(32) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
last_login_at VARCHAR(32) NULL,
inviter_code VARCHAR(40) NULL UNIQUE,
child_id VARCHAR(40) NULL,
child_name VARCHAR(80) NULL,
child_grade VARCHAR(40) NULL,
child_focus VARCHAR(80) NULL,
trial_booking_limit INT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_users_role (role),
INDEX idx_users_phone (phone),
INDEX idx_users_login_provider (login_provider),
CONSTRAINT fk_users_role FOREIGN KEY (role) REFERENCES roles(role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS point_accounts (
user_id VARCHAR(40) PRIMARY KEY,
available INT NOT NULL DEFAULT 0,
pending INT NOT NULL DEFAULT 0,
frozen INT NOT NULL DEFAULT 0,
expiring INT NOT NULL DEFAULT 0,
CONSTRAINT fk_point_accounts_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS point_transactions (
id VARCHAR(40) PRIMARY KEY,
user_id VARCHAR(40) NOT NULL,
title VARCHAR(200) NOT NULL,
amount INT NOT NULL,
status VARCHAR(30) NOT NULL,
reference_type VARCHAR(40) NULL,
reference_id VARCHAR(60) NULL,
created_at VARCHAR(32) NOT NULL,
INDEX idx_point_transactions_user (user_id),
INDEX idx_point_transactions_reference (reference_type, reference_id),
CONSTRAINT fk_point_transactions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS point_expiry_rule (
id INT PRIMARY KEY DEFAULT 1,
title VARCHAR(100) NOT NULL,
valid_days INT NOT NULL,
description VARCHAR(500) NOT NULL,
reminder VARCHAR(500) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS point_expiry_schedules (
id VARCHAR(40) PRIMARY KEY,
user_id VARCHAR(40) NOT NULL,
points INT NOT NULL,
expire_at VARCHAR(32) NOT NULL,
source VARCHAR(200) NOT NULL,
INDEX idx_point_expiry_user (user_id),
CONSTRAINT fk_point_expiry_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS referral_rules (
event VARCHAR(40) PRIMARY KEY,
title VARCHAR(100) NOT NULL,
inviter INT NOT NULL,
invitee INT NOT NULL,
sort_order INT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS referrals (
id VARCHAR(40) PRIMARY KEY,
inviter_id VARCHAR(40) NOT NULL,
invitee_id VARCHAR(40) NULL,
name VARCHAR(80) NOT NULL,
phone VARCHAR(30) NOT NULL,
child_grade VARCHAR(40) NOT NULL,
status VARCHAR(40) NOT NULL,
reward INT NOT NULL DEFAULT 0,
channel VARCHAR(80) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_referrals_inviter (inviter_id),
CONSTRAINT fk_referrals_inviter FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS courses (
id VARCHAR(40) PRIMARY KEY,
name VARCHAR(120) NOT NULL,
tags JSON NOT NULL,
next_time VARCHAR(32) NOT NULL,
points_back INT NOT NULL DEFAULT 0,
seats INT NOT NULL DEFAULT 0,
campus VARCHAR(80) NOT NULL,
teacher VARCHAR(80) NOT NULL,
trial_minutes INT NOT NULL DEFAULT 45,
status VARCHAR(30) NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS appointments (
id VARCHAR(40) PRIMARY KEY,
user_id VARCHAR(40) NOT NULL,
child_id VARCHAR(40) NULL,
course_id VARCHAR(40) NOT NULL,
status VARCHAR(30) NOT NULL,
created_at VARCHAR(40) NOT NULL,
INDEX idx_appointments_user (user_id),
INDEX idx_appointments_course (course_id),
CONSTRAINT fk_appointments_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_appointments_course FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS rewards (
id VARCHAR(40) PRIMARY KEY,
name VARCHAR(120) NOT NULL,
cost INT NOT NULL,
type VARCHAR(60) NOT NULL,
stock INT NOT NULL DEFAULT 0,
description VARCHAR(500) NOT NULL DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS redemption_orders (
id VARCHAR(40) PRIMARY KEY,
user_id VARCHAR(40) NOT NULL,
item_id VARCHAR(40) NOT NULL,
status VARCHAR(40) NOT NULL,
cost_points INT NOT NULL DEFAULT 0,
voucher_code VARCHAR(60) NULL UNIQUE,
voucher_status VARCHAR(40) NOT NULL DEFAULT 'not_issued',
created_at VARCHAR(40) NOT NULL,
reviewed_by VARCHAR(40) NULL,
reviewed_at VARCHAR(40) NULL,
rejected_reason VARCHAR(255) NULL,
expire_at VARCHAR(40) NULL,
used_at VARCHAR(40) NULL,
used_by VARCHAR(40) NULL,
verify_note VARCHAR(255) NULL,
INDEX idx_redemption_user (user_id),
INDEX idx_redemption_status (status),
INDEX idx_redemption_voucher (voucher_code),
CONSTRAINT fk_redemption_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_redemption_reward FOREIGN KEY (item_id) REFERENCES rewards(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS leads (
id VARCHAR(40) PRIMARY KEY,
referral_id VARCHAR(40) NULL,
invitee_id VARCHAR(40) NULL,
parent VARCHAR(80) NOT NULL,
source VARCHAR(100) NOT NULL,
stage VARCHAR(40) NOT NULL,
advisor VARCHAR(80) NOT NULL,
risk VARCHAR(40) NOT NULL DEFAULT '正常',
channel VARCHAR(80) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_leads_referral (referral_id),
INDEX idx_leads_invitee (invitee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS channel_stats (
channel VARCHAR(80) PRIMARY KEY,
leads INT NOT NULL DEFAULT 0,
attended INT NOT NULL DEFAULT 0,
purchased INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO roles (role, name, permissions) VALUES
('parent', '家长', JSON_ARRAY()),
('super_admin', '超级管理员', JSON_ARRAY('*:*:*')),
('campus_admin', '校区管理员', JSON_ARRAY('dashboard:overview:view', 'system:user:view', 'system:role:view', 'leads:referral:view', 'leads:referral:advance', 'points:account:view', 'points:rule:edit', 'points:redemption:view', 'points:redemption:review', 'points:voucher:verify', 'courses:trial:view', 'courses:trial:add', 'courses:trial:edit', 'statistics:channel:view')),
('advisor', '课程顾问', JSON_ARRAY('dashboard:overview:view', 'leads:referral:view', 'leads:referral:advance', 'courses:trial:view', 'points:account:view', 'points:redemption:view')),
('teacher', '老师', JSON_ARRAY('dashboard:overview:view', 'leads:referral:view', 'courses:trial:view')),
('finance', '财务', JSON_ARRAY('dashboard:overview:view', 'points:account:view', 'points:account:adjust', 'points:rule:edit', 'points:reward:edit', 'points:redemption:view', 'points:redemption:review', 'points:voucher:verify', 'statistics:channel:view'))
ON DUPLICATE KEY UPDATE name = VALUES(name), permissions = VALUES(permissions);
INSERT INTO users (
id, username, password_hash, role, role_name, name, phone,
wechat_openid, wechat_unionid, wechat_nickname, wechat_avatar,
login_provider, phone_bound, phone_bound_at, wechat_bound_at,
status, last_login_at, inviter_code, child_id, child_name, child_grade, child_focus
) VALUES
('admin_001', 'admin', '240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9', 'super_admin', '超级管理员', '系统管理员', '13800009999', NULL, NULL, NULL, NULL, 'password', 1, NULL, NULL, 'active', NULL, NULL, NULL, NULL, NULL, NULL)
ON DUPLICATE KEY UPDATE username = VALUES(username), password_hash = VALUES(password_hash), role = VALUES(role), role_name = VALUES(role_name), name = VALUES(name), phone = VALUES(phone), login_provider = VALUES(login_provider), phone_bound = VALUES(phone_bound), status = VALUES(status);
INSERT INTO point_expiry_rule (id, title, valid_days, description, reminder) VALUES
(1, '积分有效期说明', 180, '积分自实际到账日起 180 天内有效,仅可兑换课程、测评和学习服务权益,不可提现或转赠。', '系统会在到期前 30 天进入“将过期”口径,运营可通过积分管理页提醒家长尽快兑换。')
ON DUPLICATE KEY UPDATE title = VALUES(title), valid_days = VALUES(valid_days), description = VALUES(description), reminder = VALUES(reminder);
INSERT INTO referral_rules (event, title, inviter, invitee, sort_order) VALUES
('registered', '新家长注册', 20, 20, 1),
('booked', '预约试听', 50, 50, 2),
('attended', '确认到课', 150, 100, 3),
('purchased', '报名正价课', 800, 500, 4)
ON DUPLICATE KEY UPDATE title = VALUES(title), inviter = VALUES(inviter), invitee = VALUES(invitee), sort_order = VALUES(sort_order);
INSERT INTO rewards (id, name, cost, type, stock, description) VALUES
('reward_001', '精品体验课 1 次', 800, '课程权益', 30, '适用于校区公开试听课,审核通过后生成课程券码,30 天内到店核销。'),
('reward_002', '学习规划咨询', 500, '服务权益', 50, '由课程顾问提供一次学习规划沟通,适合新生测评后使用。'),
('reward_003', '正价课抵扣券 100 元', 1000, '抵扣券', 20, '报名正价课程时抵扣 100 元,不可叠加现金活动使用。'),
('reward_004', '阶段测评报告', 300, '测评', 80, '兑换后安排一次阶段测评报告解读,帮助家长了解学习状态。')
ON DUPLICATE KEY UPDATE name = VALUES(name), cost = VALUES(cost), type = VALUES(type), stock = VALUES(stock), description = VALUES(description);

@ -0,0 +1,145 @@
USE lifanghe_education;
ALTER TABLE users MODIFY password_hash CHAR(64) NULL;
ALTER TABLE users MODIFY phone VARCHAR(30) NULL;
SET @schema_name = DATABASE();
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE users ADD COLUMN wechat_openid VARCHAR(80) NULL AFTER phone',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_openid'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE users ADD COLUMN wechat_unionid VARCHAR(80) NULL AFTER wechat_openid',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_unionid'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE users ADD COLUMN wechat_nickname VARCHAR(80) NULL AFTER wechat_unionid',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_nickname'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE users ADD COLUMN wechat_avatar VARCHAR(500) NULL AFTER wechat_nickname',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_avatar'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE users ADD COLUMN login_provider VARCHAR(30) NOT NULL DEFAULT ''password'' AFTER wechat_avatar',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'login_provider'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE users ADD COLUMN phone_bound TINYINT(1) NOT NULL DEFAULT 1 AFTER login_provider',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'phone_bound'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE users ADD COLUMN phone_bound_at VARCHAR(32) NULL AFTER phone_bound',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'phone_bound_at'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE users ADD COLUMN wechat_bound_at VARCHAR(32) NULL AFTER phone_bound_at',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_bound_at'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE users ADD UNIQUE KEY uk_users_wechat_openid (wechat_openid)',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_openid' AND NON_UNIQUE = 0
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE users ADD UNIQUE KEY uk_users_wechat_unionid (wechat_unionid)',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'wechat_unionid' AND NON_UNIQUE = 0
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE users ADD INDEX idx_users_login_provider (login_provider)',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'users' AND INDEX_NAME = 'idx_users_login_provider'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE users
SET
login_provider = COALESCE(login_provider, 'password'),
phone_bound = IF(phone IS NULL OR phone = '', 0, 1),
phone_bound_at = IF((phone IS NULL OR phone = ''), NULL, COALESCE(phone_bound_at, last_login_at))
WHERE id IS NOT NULL;

@ -0,0 +1,12 @@
USE lifanghe_education;
DELETE FROM redemption_orders WHERE user_id IN ('parent_001', 'parent_002', 'parent_003');
DELETE FROM appointments WHERE user_id IN ('parent_001', 'parent_002', 'parent_003');
DELETE FROM referrals WHERE id IN ('rel_001', 'rel_002', 'rel_003');
DELETE FROM point_expiry_schedules WHERE id IN ('exp_001', 'exp_002', 'exp_003');
DELETE FROM point_transactions WHERE id IN ('pt_001', 'pt_002', 'pt_003', 'pt_004', 'pt_005');
DELETE FROM point_accounts WHERE user_id IN ('parent_001', 'parent_002', 'parent_003');
DELETE FROM leads WHERE id IN ('lead_001', 'lead_002', 'lead_003', 'lead_004', 'lead_005');
DELETE FROM courses WHERE id IN ('course_001', 'course_002', 'course_003', 'course_004');
DELETE FROM channel_stats WHERE channel IN ('朋友圈海报', '微信好友', 'H5链接', '顾问二维码');
DELETE FROM users WHERE id IN ('parent_001', 'parent_002', 'parent_003', 'campus_001', 'advisor_001', 'teacher_001', 'finance_001');

@ -0,0 +1,5 @@
USE lifanghe_education;
UPDATE roles
SET permissions = JSON_ARRAY()
WHERE role = 'parent';

@ -0,0 +1,7 @@
USE lifanghe_education;
UPDATE roles
SET permissions = JSON_ARRAY_APPEND(permissions, '$', 'points:rule')
WHERE role IN ('campus_admin', 'finance')
AND JSON_CONTAINS(permissions, JSON_QUOTE('points:rule')) = 0
AND JSON_CONTAINS(permissions, JSON_QUOTE('*:*:*')) = 0;

@ -0,0 +1,35 @@
USE lifanghe_education;
CREATE TABLE IF NOT EXISTS appointment_duplicates_backup LIKE appointments;
INSERT INTO appointment_duplicates_backup
SELECT a.*
FROM appointments a
JOIN appointments b
ON a.user_id = b.user_id
AND (a.created_at < b.created_at OR (a.created_at = b.created_at AND a.id < b.id))
LEFT JOIN appointment_duplicates_backup bak ON bak.id = a.id
WHERE bak.id IS NULL;
DELETE a
FROM appointments a
JOIN appointments b
ON a.user_id = b.user_id
AND (a.created_at < b.created_at OR (a.created_at = b.created_at AND a.id < b.id));
SET @schema_name = DATABASE();
SET @has_unique := (
SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @schema_name
AND TABLE_NAME = 'appointments'
AND INDEX_NAME = 'uk_appointments_user'
);
SET @add_unique_sql := IF(
@has_unique = 0,
'ALTER TABLE appointments ADD UNIQUE KEY uk_appointments_user (user_id)',
'SELECT 1'
);
PREPARE add_unique_stmt FROM @add_unique_sql;
EXECUTE add_unique_stmt;
DEALLOCATE PREPARE add_unique_stmt;

@ -0,0 +1,57 @@
ALTER TABLE redemption_orders
ADD COLUMN cost_points INT NOT NULL DEFAULT 0 AFTER status,
ADD COLUMN voucher_code VARCHAR(60) NULL UNIQUE AFTER cost_points,
ADD COLUMN voucher_status VARCHAR(40) NOT NULL DEFAULT 'not_issued' AFTER voucher_code,
ADD COLUMN reviewed_by VARCHAR(40) NULL AFTER created_at,
ADD COLUMN reviewed_at VARCHAR(40) NULL AFTER reviewed_by,
ADD COLUMN rejected_reason VARCHAR(255) NULL AFTER reviewed_at,
ADD COLUMN expire_at VARCHAR(40) NULL AFTER rejected_reason,
ADD COLUMN used_at VARCHAR(40) NULL AFTER expire_at,
ADD COLUMN used_by VARCHAR(40) NULL AFTER used_at,
ADD COLUMN verify_note VARCHAR(255) NULL AFTER used_by,
ADD INDEX idx_redemption_status (status),
ADD INDEX idx_redemption_voucher (voucher_code);
UPDATE redemption_orders ro
LEFT JOIN rewards r ON r.id = ro.item_id
SET ro.cost_points = COALESCE(NULLIF(ro.cost_points, 0), r.cost, 0),
ro.voucher_status = CASE
WHEN ro.status = 'approved' THEN 'issued'
WHEN ro.status = 'rejected' THEN 'void'
ELSE ro.voucher_status
END;
UPDATE roles
SET permissions = JSON_ARRAY(
'dashboard:view',
'system:user:view',
'system:role:view',
'leads:view',
'leads:advance',
'points:view',
'points:redemption:view',
'points:redemption:review',
'points:voucher:verify',
'courses:view',
'courses:add',
'courses:edit',
'statistics:view'
)
WHERE role = 'campus_admin';
UPDATE roles
SET permissions = JSON_ARRAY('dashboard:view', 'leads:view', 'leads:advance', 'courses:view', 'points:view', 'points:redemption:view')
WHERE role = 'advisor';
UPDATE roles
SET permissions = JSON_ARRAY(
'dashboard:view',
'points:view',
'points:adjust',
'points:reward',
'points:redemption:view',
'points:redemption:review',
'points:voucher:verify',
'statistics:view'
)
WHERE role = 'finance';

@ -0,0 +1,18 @@
ALTER TABLE rewards
ADD COLUMN description VARCHAR(500) NOT NULL DEFAULT '' AFTER stock;
UPDATE rewards
SET stock = CASE id
WHEN 'reward_001' THEN 30
WHEN 'reward_002' THEN 50
WHEN 'reward_003' THEN 20
WHEN 'reward_004' THEN 80
ELSE stock
END,
description = CASE id
WHEN 'reward_001' THEN '适用于校区公开试听课,审核通过后生成课程券码,30 天内到店核销。'
WHEN 'reward_002' THEN '由课程顾问提供一次学习规划沟通,适合新生测评后使用。'
WHEN 'reward_003' THEN '报名正价课程时抵扣 100 元,不可叠加现金活动使用。'
WHEN 'reward_004' THEN '兑换后安排一次阶段测评报告解读,帮助家长了解学习状态。'
ELSE description
END;

@ -0,0 +1,37 @@
USE lifanghe_education;
UPDATE roles
SET permissions = JSON_ARRAY()
WHERE role = 'parent';
UPDATE roles
SET permissions = JSON_ARRAY('*:*:*')
WHERE role = 'super_admin';
UPDATE roles
SET permissions = JSON_ARRAY(
'dashboard:view',
'system:user:view',
'system:role:view',
'leads:view',
'leads:advance',
'points:view',
'points:rule',
'courses:view',
'courses:add',
'courses:edit',
'statistics:view'
)
WHERE role = 'campus_admin';
UPDATE roles
SET permissions = JSON_ARRAY('dashboard:view', 'leads:view', 'leads:advance', 'courses:view', 'points:view')
WHERE role = 'advisor';
UPDATE roles
SET permissions = JSON_ARRAY('dashboard:view', 'leads:view', 'courses:view')
WHERE role = 'teacher';
UPDATE roles
SET permissions = JSON_ARRAY('dashboard:view', 'points:view', 'points:adjust', 'points:rule', 'points:reward', 'statistics:view')
WHERE role = 'finance';

@ -0,0 +1,21 @@
ALTER TABLE leads
ADD COLUMN referral_id VARCHAR(40) NULL AFTER id,
ADD COLUMN invitee_id VARCHAR(40) NULL AFTER referral_id,
ADD INDEX idx_leads_referral (referral_id),
ADD INDEX idx_leads_invitee (invitee_id);
UPDATE leads l
JOIN referrals r
JOIN users inviter ON inviter.id = r.inviter_id
LEFT JOIN users invitee ON invitee.id = r.invitee_id
SET l.referral_id = r.id,
l.invitee_id = r.invitee_id,
l.parent = COALESCE(invitee.name, r.name)
WHERE l.referral_id IS NULL
AND (
l.parent = r.name
OR l.parent = COALESCE(invitee.name, r.name)
OR l.parent = CONCAT('家长', RIGHT(r.phone, 4))
)
AND (l.source = inviter.name OR l.source = inviter.id OR l.source = inviter.phone)
AND (l.channel = r.channel OR l.channel = '' OR r.channel = '');

@ -0,0 +1,37 @@
SET @schema_name = DATABASE();
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE point_transactions ADD COLUMN reference_type VARCHAR(40) NULL AFTER status',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'point_transactions' AND COLUMN_NAME = 'reference_type'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE point_transactions ADD COLUMN reference_id VARCHAR(60) NULL AFTER reference_type',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'point_transactions' AND COLUMN_NAME = 'reference_id'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE point_transactions ADD INDEX idx_point_transactions_reference (reference_type, reference_id)',
'SELECT 1'
)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'point_transactions' AND INDEX_NAME = 'idx_point_transactions_reference'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

@ -0,0 +1,47 @@
UPDATE roles
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'dashboard:view')), 'dashboard:overview:view')
WHERE JSON_SEARCH(permissions, 'one', 'dashboard:view') IS NOT NULL;
UPDATE roles
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'leads:view')), 'leads:referral:view')
WHERE JSON_SEARCH(permissions, 'one', 'leads:view') IS NOT NULL;
UPDATE roles
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'leads:advance')), 'leads:referral:advance')
WHERE JSON_SEARCH(permissions, 'one', 'leads:advance') IS NOT NULL;
UPDATE roles
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'points:view')), 'points:account:view')
WHERE JSON_SEARCH(permissions, 'one', 'points:view') IS NOT NULL;
UPDATE roles
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'points:adjust')), 'points:account:adjust')
WHERE JSON_SEARCH(permissions, 'one', 'points:adjust') IS NOT NULL;
UPDATE roles
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'points:rule')), 'points:rule:edit')
WHERE JSON_SEARCH(permissions, 'one', 'points:rule') IS NOT NULL;
UPDATE roles
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'points:reward')), 'points:reward:edit')
WHERE JSON_SEARCH(permissions, 'one', 'points:reward') IS NOT NULL;
UPDATE roles
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'courses:view')), 'courses:trial:view')
WHERE JSON_SEARCH(permissions, 'one', 'courses:view') IS NOT NULL;
UPDATE roles
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'courses:add')), 'courses:trial:add')
WHERE JSON_SEARCH(permissions, 'one', 'courses:add') IS NOT NULL;
UPDATE roles
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'courses:edit')), 'courses:trial:edit')
WHERE JSON_SEARCH(permissions, 'one', 'courses:edit') IS NOT NULL;
UPDATE roles
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'courses:delete')), 'courses:trial:delete')
WHERE JSON_SEARCH(permissions, 'one', 'courses:delete') IS NOT NULL;
UPDATE roles
SET permissions = JSON_REPLACE(permissions, JSON_UNQUOTE(JSON_SEARCH(permissions, 'one', 'statistics:view')), 'statistics:channel:view')
WHERE JSON_SEARCH(permissions, 'one', 'statistics:view') IS NOT NULL;

@ -0,0 +1,51 @@
USE lifanghe_education;
SET @schema_name = DATABASE();
SET @has_trial_booking_limit := (
SELECT COUNT(1)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema_name
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'trial_booking_limit'
);
SET @add_trial_booking_limit_sql := IF(
@has_trial_booking_limit = 0,
'ALTER TABLE users ADD COLUMN trial_booking_limit INT NOT NULL DEFAULT 1 AFTER child_focus',
'SELECT 1'
);
PREPARE add_trial_booking_limit_stmt FROM @add_trial_booking_limit_sql;
EXECUTE add_trial_booking_limit_stmt;
DEALLOCATE PREPARE add_trial_booking_limit_stmt;
SET @has_unique_user_index := (
SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @schema_name
AND TABLE_NAME = 'appointments'
AND INDEX_NAME = 'uk_appointments_user'
);
SET @drop_unique_user_index_sql := IF(
@has_unique_user_index > 0,
'ALTER TABLE appointments DROP INDEX uk_appointments_user',
'SELECT 1'
);
PREPARE drop_unique_user_index_stmt FROM @drop_unique_user_index_sql;
EXECUTE drop_unique_user_index_stmt;
DEALLOCATE PREPARE drop_unique_user_index_stmt;
SET @has_appointment_user_index := (
SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @schema_name
AND TABLE_NAME = 'appointments'
AND INDEX_NAME = 'idx_appointments_user'
);
SET @add_appointment_user_index_sql := IF(
@has_appointment_user_index = 0,
'ALTER TABLE appointments ADD INDEX idx_appointments_user (user_id)',
'SELECT 1'
);
PREPARE add_appointment_user_index_stmt FROM @add_appointment_user_index_sql;
EXECUTE add_appointment_user_index_stmt;
DEALLOCATE PREPARE add_appointment_user_index_stmt;

@ -0,0 +1,50 @@
const express = require('express')
const cors = require('cors')
const morgan = require('morgan')
const fs = require('fs')
const path = require('path')
const { env } = require('./config/env')
const { router } = require('./routes')
const { errorHandler } = require('./middleware/errorHandler')
const app = express()
app.disable('etag')
app.use(cors())
app.use(express.json())
app.use(morgan('dev'))
app.use('/uploads', express.static(path.resolve(__dirname, '../uploads')))
const pcAdminDistDir = env.pcAdminDistDir
? path.resolve(env.pcAdminDistDir)
: path.resolve(__dirname, '../public/pc-admin')
const pcAdminIndex = path.join(pcAdminDistDir, 'index.html')
const hasPcAdmin = fs.existsSync(pcAdminIndex)
if (hasPcAdmin) {
app.use(express.static(pcAdminDistDir))
}
app.get('/health', (req, res) => {
res.json({ ok: true, service: 'lifanghe-education-api' })
})
app.use('/api', (req, res, next) => {
res.set({
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
Pragma: 'no-cache',
Expires: '0',
'Surrogate-Control': 'no-store',
})
next()
}, router)
if (hasPcAdmin) {
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api') || req.path.startsWith('/uploads')) return next()
return res.sendFile(pcAdminIndex)
})
}
app.use(errorHandler)
module.exports = { app }

@ -0,0 +1,77 @@
const fs = require('fs')
const path = require('path')
function loadDotEnv(filePath) {
if (!fs.existsSync(filePath)) return
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/)
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)
if (!match) continue
const key = match[1]
let value = match[2].trim()
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
if (process.env[key] === undefined || process.env[key] === '') {
process.env[key] = value
}
}
}
loadDotEnv(path.resolve(__dirname, '../../.env'))
function readBool(value, fallback = false) {
if (value === undefined || value === '') return fallback
return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase())
}
const nodeEnv = process.env.NODE_ENV || 'development'
const sessionSecret = process.env.AUTH_SESSION_SECRET || 'lifanghe-development-session-secret'
if (nodeEnv === 'production' && (!process.env.AUTH_SESSION_SECRET || sessionSecret.length < 32)) {
throw new Error('生产环境必须配置至少 32 位的 AUTH_SESSION_SECRET')
}
const env = {
nodeEnv,
port: Number(process.env.PORT || 3100),
publicBaseUrl: process.env.PUBLIC_BASE_URL || '',
pcAdminDistDir: process.env.PC_ADMIN_DIST_DIR || '',
dbClient: process.env.DB_CLIENT || (process.env.MYSQL_HOST ? 'mysql' : 'memory'),
auth: {
sessionSecret,
sessionTtlHours: Number(process.env.AUTH_SESSION_TTL_HOURS || 168),
},
mysql: {
host: process.env.MYSQL_HOST || '127.0.0.1',
port: Number(process.env.MYSQL_PORT || 3306),
user: process.env.MYSQL_USER || 'lifanghe',
password: process.env.MYSQL_PASSWORD || 'lifanghe123',
database: process.env.MYSQL_DATABASE || 'lifanghe_education',
connectionLimit: Number(process.env.MYSQL_CONNECTION_LIMIT || 10),
},
wechat: {
appId: process.env.WECHAT_APP_ID || '',
appSecret: process.env.WECHAT_APP_SECRET || '',
envVersion: process.env.WECHAT_ENV_VERSION || 'release',
},
sms: {
provider: process.env.SMS_PROVIDER || 'aliyun',
allowTestCode: readBool(process.env.SMS_ALLOW_TEST_CODE, true),
testCode: process.env.SMS_TEST_CODE || '111111',
codeLength: Number(process.env.SMS_CODE_LENGTH || 6),
codeTtlSeconds: Number(process.env.SMS_CODE_TTL_SECONDS || 300),
codeCooldownSeconds: Number(process.env.SMS_CODE_COOLDOWN_SECONDS || 60),
aliyun: {
endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com',
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID || '',
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET || '',
signName: process.env.ALIYUN_SMS_SIGN_NAME || '',
templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE || '',
templateParamKey: process.env.ALIYUN_SMS_TEMPLATE_PARAM_KEY || 'code',
},
},
}
module.exports = { env }

@ -0,0 +1,160 @@
const PERMISSION_TREE = [
{
id: 'dashboard',
label: '数据驾驶舱',
children: [
{ id: 'dashboard:overview:view', label: '查看驾驶舱', permission: 'dashboard:overview:view' },
],
},
{
id: 'system',
label: '系统管理',
children: [
{
id: 'system:user',
label: '用户管理',
children: [
{ id: 'system:user:view', label: '查看用户', permission: 'system:user:view' },
{ id: 'system:user:add', label: '新增用户', permission: 'system:user:add' },
{ id: 'system:user:edit', label: '编辑用户', permission: 'system:user:edit' },
{ id: 'system:user:delete', label: '删除用户', permission: 'system:user:delete' },
{ id: 'system:user:role', label: '分配角色', permission: 'system:user:role' },
{ id: 'system:user:status', label: '启停账号', permission: 'system:user:status' },
],
},
{
id: 'system:role',
label: '角色权限',
children: [
{ id: 'system:role:view', label: '查看角色', permission: 'system:role:view' },
{ id: 'system:role:edit', label: '配置权限', permission: 'system:role:edit' },
],
},
],
},
{
id: 'leads',
label: '线索管理',
children: [
{ id: 'leads:referral:view', label: '查看线索', permission: 'leads:referral:view' },
{ id: 'leads:referral:advance', label: '推进线索', permission: 'leads:referral:advance' },
],
},
{
id: 'points',
label: '积分管理',
children: [
{ id: 'points:account:view', label: '查看积分', permission: 'points:account:view' },
{ id: 'points:account:adjust', label: '调整积分', permission: 'points:account:adjust' },
{ id: 'points:rule:edit', label: '维护积分规则', permission: 'points:rule:edit' },
{ id: 'points:reward:edit', label: '维护兑换项', permission: 'points:reward:edit' },
{ id: 'points:redemption:view', label: '查看兑换申请', permission: 'points:redemption:view' },
{ id: 'points:redemption:review', label: '审核兑换申请', permission: 'points:redemption:review' },
{ id: 'points:voucher:verify', label: '核销权益券码', permission: 'points:voucher:verify' },
],
},
{
id: 'courses',
label: '课程管理',
children: [
{ id: 'courses:trial:view', label: '查看课程', permission: 'courses:trial:view' },
{ id: 'courses:trial:add', label: '新增课程', permission: 'courses:trial:add' },
{ id: 'courses:trial:edit', label: '编辑课程', permission: 'courses:trial:edit' },
{ id: 'courses:trial:delete', label: '删除课程', permission: 'courses:trial:delete' },
],
},
{
id: 'statistics',
label: '数据统计',
children: [
{ id: 'statistics:channel:view', label: '查看统计', permission: 'statistics:channel:view' },
],
},
]
const ROLE_PERMISSIONS = {
parent: [],
super_admin: ['*:*:*'],
campus_admin: [
'dashboard:overview:view',
'system:user:view',
'system:role:view',
'leads:referral:view',
'leads:referral:advance',
'points:account:view',
'points:rule:edit',
'points:redemption:view',
'points:redemption:review',
'points:voucher:verify',
'courses:trial:view',
'courses:trial:add',
'courses:trial:edit',
'statistics:channel:view',
],
advisor: ['dashboard:overview:view', 'leads:referral:view', 'leads:referral:advance', 'courses:trial:view', 'points:account:view', 'points:redemption:view'],
teacher: ['dashboard:overview:view', 'leads:referral:view', 'courses:trial:view'],
finance: ['dashboard:overview:view', 'points:account:view', 'points:account:adjust', 'points:rule:edit', 'points:reward:edit', 'points:redemption:view', 'points:redemption:review', 'points:voucher:verify', 'statistics:channel:view'],
}
function flattenPermissions(nodes = PERMISSION_TREE) {
return nodes.flatMap((node) => {
const current = node.permission ? [node.permission] : []
return current.concat(flattenPermissions(node.children || []))
})
}
const ALL_PERMISSIONS = flattenPermissions()
const LEGACY_PERMISSION_MAP = {
'dashboard:view': ['dashboard:overview:view'],
'leads:view': ['leads:referral:view'],
'leads:advance': ['leads:referral:advance'],
'points:view': ['points:account:view'],
'points:adjust': ['points:account:adjust'],
'points:rule': ['points:rule:edit'],
'points:reward': ['points:reward:edit'],
'courses:view': ['courses:trial:view'],
'courses:add': ['courses:trial:add'],
'courses:edit': ['courses:trial:edit'],
'courses:delete': ['courses:trial:delete'],
'statistics:view': ['statistics:channel:view'],
账号权限: ['system:user:view', 'system:user:add', 'system:user:edit', 'system:user:delete', 'system:user:role', 'system:user:status', 'system:role:view', 'system:role:edit'],
活动配置: ['courses:trial:view', 'courses:trial:add', 'courses:trial:edit', 'courses:trial:delete'],
积分规则: ['points:account:view', 'points:account:adjust', 'points:rule:edit', 'points:reward:edit', 'points:redemption:view', 'points:redemption:review', 'points:voucher:verify'],
财务数据: ['points:account:view', 'statistics:channel:view'],
线索分配: ['leads:referral:view', 'leads:referral:advance'],
校区数据: ['dashboard:overview:view', 'statistics:channel:view', 'courses:trial:view'],
兑换审核: ['points:account:view', 'points:redemption:view', 'points:redemption:review', 'points:voucher:verify'],
线索跟进: ['leads:referral:view', 'leads:referral:advance'],
预约管理: ['courses:trial:view'],
到课确认: ['leads:referral:view', 'courses:trial:view'],
课消记录: ['courses:trial:view', 'statistics:channel:view'],
订单核对: ['statistics:channel:view'],
积分冲正: ['points:account:view', 'points:account:adjust'],
}
function normalizePermissions(permissions = []) {
const normalized = permissions.flatMap((item) => LEGACY_PERMISSION_MAP[item] || item)
return sanitizePermissions(normalized)
}
function hasPermission(account, required = []) {
const permissions = account?.permissions || []
if (!required.length) return true
if (permissions.includes('*:*:*')) return true
return required.some((item) => permissions.includes(item))
}
function sanitizePermissions(permissions = []) {
const allowed = new Set(['*:*:*', ...ALL_PERMISSIONS])
return [...new Set(permissions.filter((item) => allowed.has(item)))]
}
module.exports = {
ALL_PERMISSIONS,
PERMISSION_TREE,
ROLE_PERMISSIONS,
hasPermission,
normalizePermissions,
sanitizePermissions,
}

@ -0,0 +1,121 @@
const { adminService } = require('../services')
async function getDashboard(req, res, next) {
try {
res.json({ ok: true, data: await adminService.getDashboard() })
} catch (error) {
next(error)
}
}
async function getLeads(req, res, next) {
try {
res.json({ ok: true, data: await adminService.listLeads(req.query) })
} catch (error) {
next(error)
}
}
async function updateLeadStage(req, res, next) {
try {
res.json({ ok: true, data: await adminService.updateLeadStage(req.params.id, req.body.stage) })
} catch (error) {
next(error)
}
}
async function confirmLeadPurchase(req, res, next) {
try {
res.json({ ok: true, data: await adminService.confirmLeadPurchase(req.params.id) })
} catch (error) {
next(error)
}
}
async function getPcOverview(req, res, next) {
try {
res.json({ ok: true, data: await adminService.getPcOverview(req.query) })
} catch (error) {
next(error)
}
}
async function getAccounts(req, res, next) {
try {
res.json({ ok: true, data: await adminService.getAccounts(req.query) })
} catch (error) {
next(error)
}
}
async function getRoles(req, res, next) {
try {
res.json({ ok: true, data: await adminService.getRoles() })
} catch (error) {
next(error)
}
}
async function updateRolePermissions(req, res, next) {
try {
res.json({ ok: true, data: await adminService.updateRolePermissions(req.params.role, req.body) })
} catch (error) {
next(error)
}
}
async function createAccount(req, res, next) {
try {
res.status(201).json({ ok: true, data: await adminService.createAccount(req.body) })
} catch (error) {
next(error)
}
}
async function updateAccount(req, res, next) {
try {
res.json({ ok: true, data: await adminService.updateAccount(req.params.id, req.body, req.operator) })
} catch (error) {
next(error)
}
}
async function deleteAccount(req, res, next) {
try {
res.json({ ok: true, data: await adminService.deleteAccount(req.params.id) })
} catch (error) {
next(error)
}
}
async function updateAccountRole(req, res, next) {
try {
res.json({ ok: true, data: await adminService.updateAccountRole(req.params.id, req.body.role) })
} catch (error) {
next(error)
}
}
async function updateAccountStatus(req, res, next) {
try {
res.json({ ok: true, data: await adminService.updateAccountStatus(req.params.id, req.body.status) })
} catch (error) {
next(error)
}
}
module.exports = {
getDashboard,
getLeads,
confirmLeadPurchase,
updateLeadStage,
getPcOverview,
getAccounts,
getRoles,
createAccount,
updateAccount,
deleteAccount,
updateAccountRole,
updateAccountStatus,
updateRolePermissions,
}

@ -0,0 +1,86 @@
const { authService } = require('../services')
async function login(req, res, next) {
try {
res.json({ ok: true, data: await authService.login(req.body) })
} catch (error) {
next(error)
}
}
async function phoneLogin(req, res, next) {
try {
res.json({ ok: true, data: await authService.loginByPhone(req.body) })
} catch (error) {
next(error)
}
}
async function sendSmsCode(req, res, next) {
try {
const data = await authService.sendSmsCode(req.body)
res.json({ ok: true, data, message: data.message || '验证码已发送' })
} catch (error) {
next(error)
}
}
async function wechatLogin(req, res, next) {
try {
res.json({ ok: true, data: await authService.loginByWechat(req.body) })
} catch (error) {
next(error)
}
}
async function bindWechatPhone(req, res, next) {
try {
res.json({ ok: true, data: await authService.bindWechatPhone(req.body) })
} catch (error) {
next(error)
}
}
async function updateProfile(req, res, next) {
try {
res.json({ ok: true, data: await authService.updateProfile(req.account.id, req.body) })
} catch (error) {
next(error)
}
}
async function updatePassword(req, res, next) {
try {
res.json({ ok: true, data: await authService.updatePassword(req.account.id, req.body) })
} catch (error) {
next(error)
}
}
async function updatePhone(req, res, next) {
try {
res.json({ ok: true, data: await authService.updatePhone(req.account.id, req.body) })
} catch (error) {
next(error)
}
}
async function current(req, res, next) {
try {
res.json({ ok: true, data: await authService.current(req.account.id) })
} catch (error) {
next(error)
}
}
module.exports = {
login,
phoneLogin,
sendSmsCode,
wechatLogin,
bindWechatPhone,
updateProfile,
updatePassword,
updatePhone,
current,
}

@ -0,0 +1,33 @@
const { adminService, courseService, referralService, repository } = require('../services')
const { hasPermission } = require('../constants/permissions')
async function getBootstrap(req, res, next) {
try {
const profile = await repository.findUserById(req.account.id)
if (!profile) {
const error = new Error('账号不存在')
error.status = 404
throw error
}
const safeProfile = await repository.withPermissions(profile)
const account = await repository.getPointAccount(profile.id)
const referralCenter = await referralService.getCenter(profile.id)
res.json({
ok: true,
data: {
profile: { ...safeProfile, points: account },
points: account,
stats: referralCenter.stats,
referralStats: referralCenter.stats,
rules: referralCenter.rules,
courses: await courseService.listCourses(),
admin: hasPermission(safeProfile, ['dashboard:overview:view']) ? await adminService.getDashboard() : null,
},
})
} catch (error) {
next(error)
}
}
module.exports = { getBootstrap }

@ -0,0 +1,94 @@
const { courseService } = require('../services')
async function listCourses(req, res, next) {
try {
res.json({ ok: true, data: await courseService.listCourses(req.query) })
} catch (error) {
next(error)
}
}
async function listAdminCourses(req, res, next) {
try {
res.json({ ok: true, data: await courseService.listAdminCourses(req.query) })
} catch (error) {
next(error)
}
}
async function getAdminCourse(req, res, next) {
try {
res.json({ ok: true, data: await courseService.getAdminCourse(req.params.id) })
} catch (error) {
next(error)
}
}
async function listAppointments(req, res, next) {
try {
res.json({ ok: true, data: await courseService.listAppointments(req.query) })
} catch (error) {
next(error)
}
}
async function listUserAppointments(req, res, next) {
try {
res.json({ ok: true, data: await courseService.listUserAppointments(req.account.id) })
} catch (error) {
next(error)
}
}
async function attendAppointment(req, res, next) {
try {
res.json({ ok: true, data: await courseService.attendAppointment(req.params.id) })
} catch (error) {
next(error)
}
}
async function createCourse(req, res, next) {
try {
res.status(201).json({ ok: true, data: await courseService.createCourse(req.body) })
} catch (error) {
next(error)
}
}
async function updateCourse(req, res, next) {
try {
res.json({ ok: true, data: await courseService.updateCourse(req.params.id, req.body) })
} catch (error) {
next(error)
}
}
async function deleteCourse(req, res, next) {
try {
res.json({ ok: true, data: await courseService.deleteCourse(req.params.id) })
} catch (error) {
next(error)
}
}
async function bookCourse(req, res, next) {
try {
res.status(201).json({ ok: true, data: await courseService.book({ ...req.body, userId: req.account.id }) })
} catch (error) {
next(error)
}
}
module.exports = {
listCourses,
listAdminCourses,
listAppointments,
listUserAppointments,
attendAppointment,
bookCourse,
createCourse,
deleteCourse,
getAdminCourse,
updateCourse,
}

@ -0,0 +1,184 @@
const { pointService } = require('../services')
async function getPointCenter(req, res, next) {
try {
res.json({ ok: true, data: await pointService.getCenter(req.account.id) })
} catch (error) {
next(error)
}
}
async function redeemReward(req, res, next) {
try {
res.status(201).json({ ok: true, data: await pointService.redeem(req.account.id, req.body.itemId) })
} catch (error) {
next(error)
}
}
async function listUserRewards(req, res, next) {
try {
res.json({ ok: true, data: await pointService.listUserRewards(req.account.id, req.query) })
} catch (error) {
next(error)
}
}
async function listUserTransactions(req, res, next) {
try {
res.json({ ok: true, data: await pointService.listUserTransactions(req.account.id, req.query) })
} catch (error) {
next(error)
}
}
async function listUserRedemptions(req, res, next) {
try {
res.json({ ok: true, data: await pointService.listUserRedemptions(req.account.id, req.query) })
} catch (error) {
next(error)
}
}
async function getAdminPointManager(req, res, next) {
try {
res.json({ ok: true, data: await pointService.getAdminManager() })
} catch (error) {
next(error)
}
}
async function listPointAccounts(req, res, next) {
try {
res.json({ ok: true, data: await pointService.listPointAccounts(req.query) })
} catch (error) {
next(error)
}
}
async function listPointTransactions(req, res, next) {
try {
res.json({ ok: true, data: await pointService.listPointTransactions(req.query) })
} catch (error) {
next(error)
}
}
async function adjustPoints(req, res, next) {
try {
res.status(201).json({
ok: true,
data: await pointService.adjust(req.body.userId, req.body.amount, req.body.title, req.body.status),
})
} catch (error) {
next(error)
}
}
async function settlePendingPoints(req, res, next) {
try {
res.json({
ok: true,
data: await pointService.settlePending(req.body.userId, req.body.amount, req.body.title),
})
} catch (error) {
next(error)
}
}
async function updateReward(req, res, next) {
try {
res.json({ ok: true, data: await pointService.updateReward(req.params.id, req.body) })
} catch (error) {
next(error)
}
}
async function updateReferralRule(req, res, next) {
try {
res.json({ ok: true, data: await pointService.updateReferralRule(req.params.event, req.body) })
} catch (error) {
next(error)
}
}
async function createReward(req, res, next) {
try {
res.status(201).json({ ok: true, data: await pointService.createReward(req.body) })
} catch (error) {
next(error)
}
}
async function listRewards(req, res, next) {
try {
res.json({ ok: true, data: await pointService.listRewards(req.query) })
} catch (error) {
next(error)
}
}
async function getRewardDetail(req, res, next) {
try {
res.json({ ok: true, data: await pointService.getRewardDetail(req.params.id) })
} catch (error) {
next(error)
}
}
async function listRedemptions(req, res, next) {
try {
res.json({ ok: true, data: await pointService.listRedemptions(req.query) })
} catch (error) {
next(error)
}
}
async function approveRedemption(req, res, next) {
try {
res.json({ ok: true, data: await pointService.approveRedemption(req.params.id, req.operator?.id || req.body.operatorId, req.body) })
} catch (error) {
next(error)
}
}
async function rejectRedemption(req, res, next) {
try {
res.json({ ok: true, data: await pointService.rejectRedemption(req.params.id, req.operator?.id || req.body.operatorId, req.body) })
} catch (error) {
next(error)
}
}
async function verifyVoucher(req, res, next) {
try {
res.json({ ok: true, data: await pointService.verifyVoucher({
...req.body,
operatorId: req.operator?.id || req.body.operatorId,
}) })
} catch (error) {
next(error)
}
}
module.exports = {
adjustPoints,
approveRedemption,
createReward,
getAdminPointManager,
getPointCenter,
getRewardDetail,
listPointAccounts,
listPointTransactions,
listRedemptions,
listRewards,
listUserRedemptions,
listUserRewards,
listUserTransactions,
redeemReward,
rejectRedemption,
settlePendingPoints,
updateReward,
updateReferralRule,
verifyVoucher,
}

@ -0,0 +1,40 @@
const { referralService } = require('../services')
async function getReferralCenter(req, res, next) {
try {
res.json({ ok: true, data: await referralService.getCenter(req.account.id, req.query) })
} catch (error) {
next(error)
}
}
async function createShareMaterial(req, res, next) {
try {
res.json({ ok: true, data: { share: await referralService.createShare(req.body.channel || '默认', req.account.id) } })
} catch (error) {
next(error)
}
}
async function createMiniProgramCode(req, res, next) {
try {
res.json({ ok: true, data: await referralService.createMiniProgramCode(req.account.id) })
} catch (error) {
next(error)
}
}
async function bindReferral(req, res, next) {
try {
res.status(201).json({ ok: true, data: await referralService.bindReferral(req.body) })
} catch (error) {
next(error)
}
}
module.exports = {
getReferralCenter,
createShareMaterial,
createMiniProgramCode,
bindReferral,
}

@ -0,0 +1,107 @@
const fs = require('fs')
const path = require('path')
const { createId } = require('../utils/ids')
const { env } = require('../config/env')
const MAX_AVATAR_SIZE = 2 * 1024 * 1024
const AVATAR_DIR = path.resolve(__dirname, '../../uploads/avatars')
const MIME_EXTENSIONS = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
}
function ensureAvatarDir() {
fs.mkdirSync(AVATAR_DIR, { recursive: true })
}
function collectRequest(req) {
return new Promise((resolve, reject) => {
const chunks = []
let size = 0
req.on('data', (chunk) => {
size += chunk.length
if (size > MAX_AVATAR_SIZE + 1024 * 1024) {
reject(Object.assign(new Error('头像文件不能超过 2MB'), { status: 413 }))
req.destroy()
return
}
chunks.push(chunk)
})
req.on('end', () => resolve(Buffer.concat(chunks)))
req.on('error', reject)
})
}
function parseMultipart(buffer, contentType) {
const boundaryMatch = String(contentType || '').match(/boundary=(?:"([^"]+)"|([^;]+))/i)
if (!boundaryMatch) {
const error = new Error('上传格式不正确')
error.status = 400
throw error
}
const boundary = Buffer.from(`--${boundaryMatch[1] || boundaryMatch[2]}`)
let cursor = buffer.indexOf(boundary)
while (cursor >= 0) {
const next = buffer.indexOf(boundary, cursor + boundary.length)
if (next < 0) break
const part = buffer.subarray(cursor + boundary.length + 2, next - 2)
const headerEnd = part.indexOf(Buffer.from('\r\n\r\n'))
if (headerEnd > 0) {
const header = part.subarray(0, headerEnd).toString('utf8')
const body = part.subarray(headerEnd + 4)
const fileNameMatch = header.match(/filename="([^"]+)"/i)
const contentTypeMatch = header.match(/Content-Type:\s*([^\r\n]+)/i)
if (fileNameMatch && contentTypeMatch) {
return {
filename: fileNameMatch[1],
mime: contentTypeMatch[1].trim().toLowerCase(),
data: body,
}
}
}
cursor = next
}
const error = new Error('没有找到上传文件')
error.status = 400
throw error
}
async function uploadAvatar(req, res, next) {
try {
const buffer = await collectRequest(req)
const file = parseMultipart(buffer, req.headers['content-type'])
const extension = MIME_EXTENSIONS[file.mime]
if (!extension) {
const error = new Error('仅支持 JPG、PNG、WEBP、GIF 图片')
error.status = 400
throw error
}
if (!file.data.length || file.data.length > MAX_AVATAR_SIZE) {
const error = new Error('头像文件不能超过 2MB')
error.status = 413
throw error
}
ensureAvatarDir()
const filename = `${createId('avatar')}.${extension}`
const absolutePath = path.join(AVATAR_DIR, filename)
fs.writeFileSync(absolutePath, file.data)
const pathUrl = `/uploads/avatars/${filename}`
const baseUrl = env.publicBaseUrl || `${req.protocol}://${req.get('host')}`
res.status(201).json({
ok: true,
data: {
filename,
path: pathUrl,
url: `${baseUrl}${pathUrl}`,
},
})
} catch (error) {
next(error)
}
}
module.exports = { uploadAvatar }

@ -0,0 +1,63 @@
const { ROLE_PERMISSIONS } = require('../constants/permissions')
const seed = {
roles: [
{ role: 'parent', name: '家长', permissions: ROLE_PERMISSIONS.parent },
{ role: 'super_admin', name: '超级管理员', permissions: ROLE_PERMISSIONS.super_admin },
{ role: 'campus_admin', name: '校区管理员', permissions: ROLE_PERMISSIONS.campus_admin },
{ role: 'advisor', name: '课程顾问', permissions: ROLE_PERMISSIONS.advisor },
{ role: 'teacher', name: '老师', permissions: ROLE_PERMISSIONS.teacher },
{ role: 'finance', name: '财务', permissions: ROLE_PERMISSIONS.finance },
],
users: [
{
id: 'admin_001',
username: 'admin',
passwordHash: '240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9',
role: 'super_admin',
roleName: '超级管理员',
name: '系统管理员',
phone: '13800009999',
status: 'active',
trialBookingLimit: 1,
lastLoginAt: '',
wechatOpenid: null,
wechatUnionid: null,
wechatNickname: null,
wechatAvatar: null,
loginProvider: 'password',
phoneBound: true,
phoneBoundAt: '',
wechatBoundAt: null,
},
],
pointAccounts: [],
pointTransactions: [],
pointExpiryRule: {
title: '积分有效期说明',
validDays: 180,
description: '积分自实际到账日起 180 天内有效,仅可兑换课程、测评和学习服务权益,不可提现或转赠。',
reminder: '系统会在到期前 30 天进入“将过期”口径,运营可通过积分管理页提醒家长尽快兑换。',
},
pointExpirySchedules: [],
referralRules: [
{ event: 'registered', title: '新家长注册', inviter: 20, invitee: 20 },
{ event: 'booked', title: '预约试听', inviter: 50, invitee: 50 },
{ event: 'attended', title: '确认到课', inviter: 150, invitee: 100 },
{ event: 'purchased', title: '报名正价课', inviter: 800, invitee: 500 },
],
referrals: [],
courses: [],
rewards: [
{ id: 'reward_001', name: '精品体验课 1 次', cost: 800, type: '课程权益', stock: 30, description: '适用于校区公开试听课,审核通过后生成课程券码,30 天内到店核销。' },
{ id: 'reward_002', name: '学习规划咨询', cost: 500, type: '服务权益', stock: 50, description: '由课程顾问提供一次学习规划沟通,适合新生测评后使用。' },
{ id: 'reward_003', name: '正价课抵扣券 100 元', cost: 1000, type: '抵扣券', stock: 20, description: '报名正价课程时抵扣 100 元,不可叠加现金活动使用。' },
{ id: 'reward_004', name: '阶段测评报告', cost: 300, type: '测评', stock: 80, description: '兑换后安排一次阶段测评报告解读,帮助家长了解学习状态。' },
],
appointments: [],
redemptionOrders: [],
leads: [],
channelStats: [],
}
module.exports = { seed }

@ -0,0 +1,14 @@
function errorHandler(error, req, res, next) {
if (res.headersSent) {
next(error)
return
}
const status = error.status || 500
res.status(status).json({
ok: false,
message: error.message || '服务器异常',
})
}
module.exports = { errorHandler }

@ -0,0 +1,21 @@
const { hasPermission } = require('../constants/permissions')
const { authenticateRequest } = require('./session')
function requirePermission(...required) {
return async (req, res, next) => {
try {
const account = await authenticateRequest(req)
if (!hasPermission(account, required)) {
const error = new Error('没有操作权限')
error.status = 403
throw error
}
req.operator = account
next()
} catch (error) {
next(error)
}
}
}
module.exports = { requirePermission }

@ -0,0 +1,50 @@
const { repository } = require('../repositories')
const { verifySessionToken } = require('../utils/sessionToken')
function readSessionToken(req) {
const authorization = String(req.get('authorization') || '').trim()
if (authorization.toLowerCase().startsWith('bearer ')) return authorization.slice(7).trim()
return String(req.get('x-session-token') || '').trim()
}
async function authenticateRequest(req) {
if (req.account) return req.account
const payload = verifySessionToken(readSessionToken(req))
if (!payload) {
const error = new Error('登录状态无效或已过期,请重新登录')
error.status = 401
throw error
}
const account = await repository.withPermissions(await repository.findUserById(payload.sub))
if (!account || account.status !== 'active') {
const error = new Error('账号不存在或已停用')
error.status = 403
throw error
}
req.account = account
return account
}
async function requireSession(req, res, next) {
try {
await authenticateRequest(req)
next()
} catch (error) {
next(error)
}
}
function requireSelf(source, key) {
return (req, res, next) => {
const targetId = String(req[source]?.[key] || '').trim()
if (targetId && targetId !== req.account?.id) {
const error = new Error('不能访问或修改其他用户的数据')
error.status = 403
next(error)
return
}
next()
}
}
module.exports = { authenticateRequest, requireSelf, requireSession }

@ -0,0 +1,7 @@
const { env } = require('../config/env')
const { repository: memoryRepository } = require('./memoryRepository')
const { MySqlRepository } = require('./mysqlRepository')
const repository = env.dbClient === 'mysql' ? new MySqlRepository() : memoryRepository
module.exports = { repository }

@ -0,0 +1,549 @@
const { seed } = require('../data/seed')
const { hashPassword } = require('../utils/password')
const { normalizePermissions } = require('../constants/permissions')
class MemoryRepository {
constructor(initialState) {
this.state = structuredClone(initialState)
}
findUserById(id) {
return this.state.users.find((user) => user.id === id)
}
findUserByInviteCode(inviterCode) {
return this.state.users.find((user) => user.inviterCode === inviterCode)
}
findUserByPhone(phone) {
return this.state.users.find((user) => user.phone === phone)
}
findUserByLoginAccount(account) {
const value = String(account || '').trim()
return this.state.users.find((user) => [user.username, user.phone, user.id, user.wechatOpenid, user.wechatUnionid].filter(Boolean).includes(value))
}
listAccounts() {
return this.state.users
.map((user) => this.withPermissions(user))
}
listParentUsers() {
return this.state.users
.filter((user) => user.role === 'parent')
.map((user) => this.withPermissions(user))
}
getRoleMatrix() {
return this.state.roles.filter((role) => role.role !== 'parent').map((role) => ({
...role,
permissions: normalizePermissions(role.permissions || []),
}))
}
findRole(roleName) {
return this.state.roles.find((item) => item.role === roleName)
}
updateRolePermissions(roleName, permissions) {
const role = this.findRole(roleName)
if (!role) return null
role.permissions = [...(permissions || [])]
return role
}
withPermissions(user) {
const role = this.findRole(user.role)
const hasPassword = Boolean(user.passwordHash)
return {
...user,
trialBookingLimit: Number.isInteger(Number(user.trialBookingLimit)) ? Number(user.trialBookingLimit) : 1,
passwordHash: undefined,
hasPassword,
roleName: role?.name || user.roleName || user.role,
permissions: user.role === 'parent' ? [] : normalizePermissions(role?.permissions || []),
}
}
touchLogin(id) {
const user = this.findUserById(id)
if (!user) return null
user.lastLoginAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
return this.withPermissions(user)
}
addUser(user) {
if (!user.username) user.username = user.phone || user.id
if (!user.passwordHash && user.password) user.passwordHash = hashPassword(user.password)
if (user.passwordHash === undefined) user.passwordHash = null
user.phone = user.phone || null
user.wechatOpenid = user.wechatOpenid || null
user.wechatUnionid = user.wechatUnionid || null
user.wechatNickname = user.wechatNickname || null
user.wechatAvatar = user.wechatAvatar || null
user.loginProvider = user.loginProvider || (user.wechatOpenid ? 'wechat' : 'password')
user.phoneBound = user.phoneBound === undefined ? Boolean(user.phone) : Boolean(user.phoneBound)
user.phoneBoundAt = user.phoneBoundAt || (user.phone ? new Date().toISOString().slice(0, 16).replace('T', ' ') : null)
user.wechatBoundAt = user.wechatBoundAt || (user.wechatOpenid ? new Date().toISOString().slice(0, 16).replace('T', ' ') : null)
user.trialBookingLimit = Number.isInteger(Number(user.trialBookingLimit)) ? Number(user.trialBookingLimit) : 1
user.createdAt = user.createdAt || new Date().toISOString().slice(0, 16).replace('T', ' ')
delete user.password
this.state.users.push(user)
return user
}
updateUser(id, patch) {
const user = this.findUserById(id)
if (!user) return null
if (patch.passwordHash !== undefined) user.passwordHash = patch.passwordHash
if (patch.username !== undefined) user.username = patch.username
if (patch.role !== undefined) user.role = patch.role
if (patch.roleName !== undefined) user.roleName = patch.roleName
if (patch.name !== undefined) user.name = patch.name
if (patch.phone !== undefined) user.phone = patch.phone || null
if (patch.wechatOpenid !== undefined) user.wechatOpenid = patch.wechatOpenid || null
if (patch.wechatUnionid !== undefined) user.wechatUnionid = patch.wechatUnionid || null
if (patch.wechatNickname !== undefined) user.wechatNickname = patch.wechatNickname || null
if (patch.wechatAvatar !== undefined) user.wechatAvatar = patch.wechatAvatar || null
if (patch.loginProvider !== undefined) user.loginProvider = patch.loginProvider
if (patch.phoneBound !== undefined) user.phoneBound = Boolean(patch.phoneBound)
if (patch.phoneBoundAt !== undefined) user.phoneBoundAt = patch.phoneBoundAt
if (patch.wechatBoundAt !== undefined) user.wechatBoundAt = patch.wechatBoundAt
if (patch.status !== undefined) user.status = patch.status
if (patch.inviterCode !== undefined) user.inviterCode = patch.inviterCode
if (patch.trialBookingLimit !== undefined) user.trialBookingLimit = Number(patch.trialBookingLimit)
if (patch.child !== undefined) user.child = patch.child
return user
}
deleteUser(id) {
const index = this.state.users.findIndex((user) => user.id === id)
if (index < 0) return null
const [user] = this.state.users.splice(index, 1)
this.state.pointAccounts = this.state.pointAccounts.filter((item) => item.userId !== id)
this.state.pointTransactions = this.state.pointTransactions.filter((item) => item.userId !== id)
this.state.referrals = this.state.referrals.filter((item) => item.inviterId !== id && item.inviteeId !== id)
this.state.appointments = this.state.appointments.filter((item) => item.userId !== id)
this.state.redemptionOrders = this.state.redemptionOrders.filter((item) => item.userId !== id)
return user
}
updateAccountRole(id, roleName) {
const user = this.findUserById(id)
const role = this.findRole(roleName)
if (!user || !role) return null
user.role = role.role
user.roleName = role.name
return this.withPermissions(user)
}
updateAccountStatus(id, status) {
const user = this.findUserById(id)
if (!user) return null
user.status = status
return this.withPermissions(user)
}
getPointAccount(userId) {
let account = this.state.pointAccounts.find((item) => item.userId === userId)
if (!account) {
account = { userId, available: 0, pending: 0, frozen: 0, expiring: 0 }
this.state.pointAccounts.push(account)
}
return account
}
updatePointAccount(userId, patch) {
const account = this.getPointAccount(userId)
Object.assign(account, patch)
return account
}
addPointTransaction(transaction) {
this.state.pointTransactions.unshift(transaction)
return transaction
}
listPointTransactions() {
return this.state.pointTransactions
}
getPointTransactions(userId) {
return this.state.pointTransactions.filter((item) => item.userId === userId)
}
getPointExpiryRule() {
return this.state.pointExpiryRule
}
listPointExpirySchedules(userId) {
return this.state.pointExpirySchedules.filter((item) => !userId || item.userId === userId)
}
getReferralRules() {
return this.state.referralRules
}
updateReferralRule(event, patch) {
const rule = this.state.referralRules.find((item) => item.event === event)
if (!rule) return null
Object.assign(rule, patch)
return rule
}
getReferralsByInviter(inviterId) {
return this.state.referrals.filter((item) => item.inviterId === inviterId)
}
findReferralByInvitee(inviteeId) {
return this.state.referrals.find((item) => item.inviteeId === inviteeId)
}
findReferralById(id) {
return this.state.referrals.find((item) => item.id === id)
}
findReferralForLead(lead = {}) {
if (lead.referralId) {
const referral = this.findReferralById(lead.referralId)
if (referral) return referral
}
if (lead.inviteeId) {
const referral = this.findReferralByInvitee(lead.inviteeId)
if (referral) return referral
}
const parent = String(lead.parent || '').trim()
if (!parent) return null
const source = String(lead.source || '').trim()
const channel = String(lead.channel || '').trim()
const candidates = this.state.referrals
.map((referral) => {
const inviter = this.findUserById(referral.inviterId)
const invitee = referral.inviteeId ? this.findUserById(referral.inviteeId) : null
const fallbackNames = [
{ value: invitee?.name, score: 40 },
{ value: referral.name, score: 30 },
{ value: invitee?.phone ? `家长${String(invitee.phone).slice(-4)}` : '', score: 20 },
{ value: referral.phone ? `家长${String(referral.phone).slice(-4)}` : '', score: 20 },
].filter((item) => item.value)
const parentHit = fallbackNames.find((item) => item.value === parent)
if (!parentHit) return null
const sourceValues = [inviter?.name, inviter?.id, inviter?.phone].filter(Boolean)
const sourceScore = !source || sourceValues.includes(source) ? 8 : 0
const channelScore = !channel || !referral.channel || referral.channel === channel ? 4 : 0
return { referral, score: parentHit.score + sourceScore + channelScore }
})
.filter(Boolean)
.sort((a, b) => b.score - a.score)
return candidates[0]?.referral || null
}
addReferral(referral) {
this.state.referrals.unshift(referral)
return referral
}
updateReferral(id, patch) {
const referral = this.state.referrals.find((item) => item.id === id)
if (!referral) return null
Object.assign(referral, patch)
return referral
}
updateReferralInviteeInfo(inviteeId, patch = {}) {
const updated = []
this.state.referrals.forEach((referral) => {
if (referral.inviteeId !== inviteeId) return
if (patch.name !== undefined) referral.name = patch.name
if (patch.phone !== undefined) referral.phone = patch.phone
if (patch.childGrade !== undefined) referral.childGrade = patch.childGrade
updated.push(referral)
})
return updated
}
getCourses() {
return this.state.courses
}
findCourse(id) {
return this.state.courses.find((course) => course.id === id)
}
addCourse(course) {
this.state.courses.unshift(course)
return course
}
updateCourse(id, patch) {
const course = this.findCourse(id)
if (!course) return null
Object.assign(course, patch)
return course
}
deleteCourse(id) {
const index = this.state.courses.findIndex((course) => course.id === id)
if (index < 0) return null
const [course] = this.state.courses.splice(index, 1)
return course
}
addAppointment(appointment) {
this.state.appointments.unshift(appointment)
return appointment
}
findAppointmentById(id) {
return this.decorateAppointment(this.state.appointments.find((appointment) => appointment.id === id))
}
updateAppointment(id, patch) {
const appointment = this.state.appointments.find((item) => item.id === id)
if (!appointment) return null
Object.assign(appointment, patch)
return this.decorateAppointment(appointment)
}
decorateAppointment(appointment) {
if (!appointment) return null
const user = this.findUserById(appointment.userId)
const course = this.findCourse(appointment.courseId)
return {
...appointment,
user: user
? {
id: user.id,
name: user.name,
phone: user.phone,
}
: undefined,
course: course
? {
id: course.id,
name: course.name,
campus: course.campus,
teacher: course.teacher,
nextTime: course.nextTime,
pointsBack: Number(course.pointsBack || 0),
}
: undefined,
}
}
findAppointmentByUserId(userId) {
return this.decorateAppointment(this.state.appointments.find((appointment) => appointment.userId === userId))
}
listAppointments(filters = {}) {
const keyword = String(filters.keyword || '').trim()
return this.state.appointments
.map((appointment) => this.decorateAppointment(appointment))
.filter((appointment) => {
const matchUser = !filters.userId || appointment.userId === filters.userId
const matchCourse = !filters.courseId || appointment.courseId === filters.courseId
const matchStatus = !filters.status || filters.status === 'all' || appointment.status === filters.status
const matchKeyword = !keyword || [appointment.user?.name, appointment.user?.phone, appointment.course?.name, appointment.course?.campus, appointment.course?.teacher].some((value) => String(value || '').includes(keyword))
return matchUser && matchCourse && matchStatus && matchKeyword
})
}
getRewards() {
return this.state.rewards
}
listRewards(filters = {}) {
const keyword = String(filters.keyword || '').trim()
return this.state.rewards.filter((reward) => {
const matchKeyword = !keyword || [reward.name, reward.type, reward.description].some((value) => String(value || '').includes(keyword))
const matchType = !filters.type || filters.type === 'all' || reward.type === filters.type
const matchStock = !filters.stockStatus
|| filters.stockStatus === 'all'
|| (filters.stockStatus === 'in_stock' && Number(reward.stock || 0) > 0)
|| (filters.stockStatus === 'sold_out' && Number(reward.stock || 0) <= 0)
return matchKeyword && matchType && matchStock
})
}
findReward(id) {
return this.state.rewards.find((reward) => reward.id === id)
}
addReward(reward) {
this.state.rewards.unshift(reward)
return reward
}
updateReward(id, patch) {
const reward = this.findReward(id)
if (!reward) return null
Object.assign(reward, patch)
return reward
}
decorateRedemption(order) {
if (!order) return null
const user = this.findUserById(order.userId)
const reward = this.findReward(order.itemId)
return {
...order,
costPoints: Number(order.costPoints || reward?.cost || 0),
voucherCode: order.voucherCode || '',
voucherStatus: order.voucherStatus || 'not_issued',
reviewedBy: order.reviewedBy || '',
reviewedAt: order.reviewedAt || '',
rejectedReason: order.rejectedReason || '',
expireAt: order.expireAt || '',
usedAt: order.usedAt || '',
usedBy: order.usedBy || '',
verifyNote: order.verifyNote || '',
user: user
? {
id: user.id,
name: user.name,
phone: user.phone,
}
: undefined,
reward: reward
? {
id: reward.id,
name: reward.name,
type: reward.type,
cost: Number(reward.cost || 0),
}
: undefined,
}
}
addRedemption(order) {
const next = {
...order,
costPoints: Number(order.costPoints || 0),
voucherCode: order.voucherCode || '',
voucherStatus: order.voucherStatus || 'not_issued',
reviewedBy: order.reviewedBy || '',
reviewedAt: order.reviewedAt || '',
rejectedReason: order.rejectedReason || '',
expireAt: order.expireAt || '',
usedAt: order.usedAt || '',
usedBy: order.usedBy || '',
verifyNote: order.verifyNote || '',
}
this.state.redemptionOrders.unshift(next)
return this.decorateRedemption(next)
}
findRedemptionById(id) {
return this.decorateRedemption(this.state.redemptionOrders.find((order) => order.id === id))
}
findRedemptionByVoucherCode(voucherCode) {
return this.decorateRedemption(this.state.redemptionOrders.find((order) => order.voucherCode === voucherCode))
}
listRedemptions(filters = {}) {
const keyword = String(filters.keyword || '').trim()
return this.state.redemptionOrders
.map((order) => this.decorateRedemption(order))
.filter((order) => {
if (!order) return false
const matchUser = !filters.userId || order.userId === filters.userId
const matchItem = !filters.itemId || order.itemId === filters.itemId
const matchStatus = !filters.status || filters.status === 'all' || order.status === filters.status
const matchVoucherStatus = !filters.voucherStatus || filters.voucherStatus === 'all' || order.voucherStatus === filters.voucherStatus
const matchType = !filters.type || filters.type === 'all' || order.reward?.type === filters.type
const matchKeyword = !keyword || [order.id, order.voucherCode, order.user?.name, order.user?.phone, order.reward?.name].some((value) => String(value || '').includes(keyword))
return matchUser && matchItem && matchStatus && matchVoucherStatus && matchType && matchKeyword
})
}
updateRedemption(id, patch) {
const order = this.state.redemptionOrders.find((item) => item.id === id)
if (!order) return null
Object.assign(order, patch)
return this.decorateRedemption(order)
}
getLeads() {
return this.state.leads
}
getChannelStats() {
return this.state.channelStats
}
addLead(lead) {
const next = {
referralId: '',
inviteeId: '',
createdAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
...lead,
}
this.state.leads.unshift(next)
return next
}
updateLeadStage(id, stage) {
const lead = this.state.leads.find((item) => item.id === id)
if (!lead) return null
lead.stage = stage
return lead
}
updateLeadsByParent(parent, patch = {}) {
const updated = []
this.state.leads.forEach((lead) => {
if (lead.parent !== parent) return
Object.assign(lead, patch)
updated.push(lead)
})
return updated
}
findLeadForReferral(referral, previous = {}) {
if (!referral) return null
const inviter = this.findUserById(referral.inviterId)
const sources = [inviter?.name, inviter?.id, inviter?.phone].filter(Boolean)
const fallbackNames = [
referral.name,
previous.name,
referral.phone ? `家长${String(referral.phone).slice(-4)}` : '',
previous.phone ? `家长${String(previous.phone).slice(-4)}` : '',
].filter(Boolean)
const exactLead = this.state.leads.find((lead) => referral.id && lead.referralId === referral.id)
|| this.state.leads.find((lead) => referral.inviteeId && lead.inviteeId === referral.inviteeId)
if (exactLead) return exactLead
const candidates = this.state.leads
.map((lead) => {
if (!fallbackNames.includes(lead.parent)) return null
const sourceScore = !lead.source || sources.includes(lead.source) ? 8 : 0
const channelScore = !referral.channel || !lead.channel || lead.channel === referral.channel ? 4 : 0
return { lead, score: sourceScore + channelScore }
})
.filter(Boolean)
.sort((a, b) => b.score - a.score)
return candidates[0]?.lead || null
}
updateLeadInfoByReferral(referral, patch = {}, previous = {}) {
const lead = this.findLeadForReferral(referral, previous)
if (!lead) return []
Object.assign(lead, patch)
if (referral.id && !lead.referralId) lead.referralId = referral.id
if (referral.inviteeId && !lead.inviteeId) lead.inviteeId = referral.inviteeId
return [lead]
}
updateLeadStageByReferral(referral, stage) {
return this.updateLeadInfoByReferral(referral, { stage })
}
}
const repository = new MemoryRepository(seed)
module.exports = { repository }

File diff suppressed because it is too large Load Diff

@ -0,0 +1,112 @@
const express = require('express')
const {
getAccounts,
createAccount,
deleteAccount,
getDashboard,
getLeads,
confirmLeadPurchase,
getPcOverview,
getRoles,
updateAccount,
updateAccountRole,
updateAccountStatus,
updateLeadStage,
updateRolePermissions,
} = require('../controllers/adminController')
const { bindWechatPhone, current, login, phoneLogin, sendSmsCode, updatePassword, updatePhone, updateProfile, wechatLogin } = require('../controllers/authController')
const { getBootstrap } = require('../controllers/bootstrapController')
const { attendAppointment, bookCourse, createCourse, deleteCourse, getAdminCourse, listAdminCourses, listAppointments, listCourses, listUserAppointments, updateCourse } = require('../controllers/courseController')
const {
adjustPoints,
approveRedemption,
createReward,
getRewardDetail,
getAdminPointManager,
getPointCenter,
listPointAccounts,
listPointTransactions,
listRedemptions,
listRewards,
listUserRedemptions,
listUserRewards,
listUserTransactions,
redeemReward,
rejectRedemption,
settlePendingPoints,
updateReward,
updateReferralRule,
verifyVoucher,
} = require('../controllers/pointController')
const { bindReferral, createMiniProgramCode, createShareMaterial, getReferralCenter } = require('../controllers/referralController')
const { uploadAvatar } = require('../controllers/uploadController')
const { requirePermission } = require('../middleware/permission')
const { requireSelf, requireSession } = require('../middleware/session')
const router = express.Router()
router.get('/bootstrap', requireSession, requireSelf('query', 'accountId'), getBootstrap)
router.get('/auth/current', requireSession, current)
router.post('/auth/login', login)
router.post('/auth/sms-code', sendSmsCode)
router.post('/auth/phone-login', phoneLogin)
router.post('/auth/wechat-login', wechatLogin)
router.post('/auth/wechat-bind-phone', bindWechatPhone)
router.patch('/auth/profile/:id', requireSession, requireSelf('params', 'id'), updateProfile)
router.patch('/auth/password/:id', requireSession, requireSelf('params', 'id'), updatePassword)
router.patch('/auth/phone/:id', requireSession, requireSelf('params', 'id'), updatePhone)
router.post('/uploads/avatar', requireSession, uploadAvatar)
router.get('/referrals', requireSession, requireSelf('query', 'inviterId'), getReferralCenter)
router.post('/referrals/share', requireSession, requireSelf('body', 'inviterId'), createShareMaterial)
router.post('/referrals/mini-code', requireSession, requireSelf('body', 'inviterId'), createMiniProgramCode)
router.post('/referrals/bind', bindReferral)
router.get('/points/:userId/rewards', requireSession, requireSelf('params', 'userId'), listUserRewards)
router.get('/points/:userId/transactions', requireSession, requireSelf('params', 'userId'), listUserTransactions)
router.get('/points/:userId/redemptions', requireSession, requireSelf('params', 'userId'), listUserRedemptions)
router.get('/points/:userId', requireSession, requireSelf('params', 'userId'), getPointCenter)
router.post('/redemptions', requireSession, requireSelf('body', 'userId'), redeemReward)
router.get('/courses', listCourses)
router.get('/appointments', requireSession, requireSelf('query', 'userId'), listUserAppointments)
router.post('/appointments', requireSession, requireSelf('body', 'userId'), bookCourse)
router.get('/admin/dashboard', requirePermission('dashboard:overview:view'), getDashboard)
router.get('/admin/leads', requirePermission('leads:referral:view'), getLeads)
router.post('/admin/leads/:id/purchase', requirePermission('leads:referral:advance'), confirmLeadPurchase)
router.get('/admin/courses', requirePermission('courses:trial:view'), listAdminCourses)
router.get('/admin/courses/:id', requirePermission('courses:trial:view'), getAdminCourse)
router.get('/admin/appointments', requirePermission('courses:trial:view'), listAppointments)
router.patch('/admin/appointments/:id/attend', requirePermission('courses:trial:edit'), attendAppointment)
router.post('/admin/courses', requirePermission('courses:trial:add'), createCourse)
router.patch('/admin/courses/:id', requirePermission('courses:trial:edit'), updateCourse)
router.delete('/admin/courses/:id', requirePermission('courses:trial:delete'), deleteCourse)
router.get('/admin/pc-overview', requirePermission('dashboard:overview:view', 'statistics:channel:view'), getPcOverview)
router.get('/admin/accounts', requirePermission('system:user:view'), getAccounts)
router.post('/admin/accounts', requirePermission('system:user:add'), createAccount)
router.patch('/admin/accounts/:id', requirePermission('system:user:edit'), updateAccount)
router.delete('/admin/accounts/:id', requirePermission('system:user:delete'), deleteAccount)
router.get('/admin/roles', requirePermission('system:role:view', 'system:user:view'), getRoles)
router.patch('/admin/roles/:role/permissions', requirePermission('system:role:edit'), updateRolePermissions)
router.get('/admin/points', requirePermission('points:account:view'), getAdminPointManager)
router.get('/admin/point-accounts', requirePermission('points:account:view'), listPointAccounts)
router.get('/admin/point-transactions', requirePermission('points:account:view'), listPointTransactions)
router.post('/admin/points/adjust', requirePermission('points:account:adjust'), adjustPoints)
router.post('/admin/points/settle-pending', requirePermission('points:account:adjust'), settlePendingPoints)
router.patch('/admin/referral-rules/:event', requirePermission('points:rule:edit', 'points:account:adjust'), updateReferralRule)
router.get('/admin/redemptions', requirePermission('points:redemption:view', 'points:voucher:verify'), listRedemptions)
router.patch('/admin/redemptions/:id/approve', requirePermission('points:redemption:review'), approveRedemption)
router.patch('/admin/redemptions/:id/reject', requirePermission('points:redemption:review'), rejectRedemption)
router.post('/admin/vouchers/verify', requirePermission('points:voucher:verify'), verifyVoucher)
router.patch('/admin/leads/:id/stage', requirePermission('leads:referral:advance'), updateLeadStage)
router.patch('/admin/accounts/:id/role', requirePermission('system:user:role'), updateAccountRole)
router.patch('/admin/accounts/:id/status', requirePermission('system:user:status'), updateAccountStatus)
router.get('/admin/rewards', requirePermission('points:reward:edit', 'points:redemption:view'), listRewards)
router.post('/admin/rewards', requirePermission('points:reward:edit'), createReward)
router.get('/admin/rewards/:id', requirePermission('points:reward:edit', 'points:redemption:view'), getRewardDetail)
router.patch('/admin/rewards/:id', requirePermission('points:reward:edit'), updateReward)
module.exports = { router }

@ -0,0 +1,6 @@
const { app } = require('./app')
const { env } = require('./config/env')
app.listen(env.port, () => {
console.log(`LiFangHe Education API running at http://localhost:${env.port}/api`)
})

@ -0,0 +1,685 @@
const { createId } = require('../utils/ids')
const { hashPassword } = require('../utils/password')
const { PERMISSION_TREE, hasPermission, sanitizePermissions } = require('../constants/permissions')
const VALID_ROLES = ['parent', 'super_admin', 'campus_admin', 'advisor', 'teacher', 'finance']
const VALID_STATUSES = ['active', 'disabled']
const LEAD_STAGE_FLOW = ['待跟进', '已注册', '已预约', '已到课', '已报名']
const REFERRAL_STAGE_FLOW = ['待注册', '已注册', '已预约', '已到课', '已报名']
const REFERRAL_STAGE_EVENTS = {
已注册: 'registered',
已预约: 'booked',
已到课: 'attended',
已报名: 'purchased',
}
const DAY_MS = 24 * 60 * 60 * 1000
function formatDateKey(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function parseDateKey(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (!match) return null
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
return formatDateKey(date) === match[0] ? date : null
}
function shiftDate(date, days) {
const next = new Date(date)
next.setDate(next.getDate() + days)
return next
}
function snapshotAccount(account) {
if (!account) return null
return {
...account,
child: account.child ? { ...account.child } : account.child,
}
}
function paginate(list = [], filters = {}) {
const pageNum = Math.max(1, Number(filters.pageNum || filters.page || 1))
const pageSize = Math.min(100, Math.max(1, Number(filters.pageSize || filters.limit || 10)))
const start = (pageNum - 1) * pageSize
return {
list: list.slice(start, start + pageSize),
total: list.length,
pageNum,
pageSize,
}
}
function parseTrialBookingLimit(value, fallback = 1) {
const limit = Number(value ?? fallback)
if (!Number.isInteger(limit) || limit < 0 || limit > 99) {
const error = new Error('可预约试听次数必须是 0 到 99 之间的整数')
error.status = 400
throw error
}
return limit
}
class AdminService {
constructor(repository, pointService) {
this.repository = repository
this.pointService = pointService
}
async getDashboard() {
const leads = await this.getDecoratedLeads()
const transactions = await this.repository.listPointTransactions()
const overview = this.getLeadOverview(leads, transactions)
return {
overview,
funnel: [
{ label: '触达', value: leads.length },
{ label: '注册', value: leads.filter((lead) => lead.stage !== '待跟进').length },
{ label: '预约', value: leads.filter((lead) => ['已预约', '已到课', '已报名'].includes(lead.stage)).length },
{ label: '到课', value: overview.attended },
{ label: '报名', value: overview.purchased },
],
leads,
}
}
getLeadOverview(leads, transactions) {
return {
leads: leads.length,
attended: leads.filter((lead) => ['已到课', '已报名'].includes(lead.stage)).length,
purchased: leads.filter((lead) => lead.stage === '已报名').length,
pointsCost: transactions
.filter((tx) => tx.status !== 'pending')
.reduce((sum, tx) => sum + Math.max(0, tx.amount), 0),
revenue: 0,
}
}
async listLeads(filters = {}) {
const keyword = String(filters.keyword || '').trim()
const stage = String(filters.stage || '').trim()
const advisor = String(filters.advisor || '').trim()
const leads = await this.getDecoratedLeads()
const list = leads.filter((lead) => {
const matchKeyword = !keyword || [lead.parent, lead.phone, lead.childGrade, lead.source, lead.channel].some((value) => String(value || '').includes(keyword))
const matchStage = !stage || stage === '全部' || lead.stage === stage
const matchAdvisor = !advisor || advisor === '全部' || lead.advisor === advisor
return matchKeyword && matchStage && matchAdvisor
})
return {
list,
total: list.length,
}
}
async getPcOverview(filters = {}) {
const accounts = await this.repository.listAccounts()
const leads = await this.getDecoratedLeads()
const transactions = await this.repository.listPointTransactions()
const purchased = leads.filter((lead) => lead.stage === '已报名').length
const totalLeads = leads.length
return {
overview: {
accounts: accounts.length,
activeAccounts: accounts.filter((account) => account.status === 'active').length,
leads: totalLeads,
conversion: totalLeads ? Math.round((purchased / totalLeads) * 100) : 0,
pointsCost: transactions
.filter((tx) => tx.status !== 'pending')
.reduce((sum, tx) => sum + Math.max(0, tx.amount), 0),
revenue: 0,
},
accounts,
roleMatrix: await this.repository.getRoleMatrix(),
channelStats: this.buildChannelStats(leads),
referralRules: await this.repository.getReferralRules(),
trend: this.buildDailyTrend(accounts, leads, filters),
}
}
buildDailyTrend(accounts = [], leads = [], filters = {}) {
const today = new Date()
today.setHours(0, 0, 0, 0)
let endDate = parseDateKey(filters.endDate) || today
if (endDate > today) endDate = today
let startDate = parseDateKey(filters.startDate) || shiftDate(endDate, -29)
if (startDate > endDate) startDate = shiftDate(endDate, -29)
if ((endDate - startDate) / DAY_MS > 365) startDate = shiftDate(endDate, -365)
const dates = []
const usersByDate = new Map()
const leadsByDate = new Map()
for (let cursor = new Date(startDate); cursor <= endDate; cursor = shiftDate(cursor, 1)) {
const key = formatDateKey(cursor)
dates.push(key)
usersByDate.set(key, 0)
leadsByDate.set(key, 0)
}
accounts
.filter((account) => account.role === 'parent')
.forEach((account) => this.incrementDailyCount(usersByDate, account.createdAt))
leads.forEach((lead) => this.incrementDailyCount(leadsByDate, lead.createdAt))
return {
startDate: formatDateKey(startDate),
endDate: formatDateKey(endDate),
dates,
users: dates.map((date) => usersByDate.get(date) || 0),
leads: dates.map((date) => leadsByDate.get(date) || 0),
}
}
incrementDailyCount(counter, createdAt) {
const date = String(createdAt || '').slice(0, 10)
if (counter.has(date)) counter.set(date, counter.get(date) + 1)
}
buildChannelStats(leads = []) {
const rows = new Map()
leads.forEach((lead) => {
const channel = String(lead.channel || '未标记渠道').trim() || '未标记渠道'
const current = rows.get(channel) || { channel, leads: 0, attended: 0, purchased: 0 }
current.leads += 1
if (['已到课', '已报名'].includes(lead.stage)) current.attended += 1
if (lead.stage === '已报名') current.purchased += 1
rows.set(channel, current)
})
return [...rows.values()].sort((left, right) => right.leads - left.leads || left.channel.localeCompare(right.channel, 'zh-CN'))
}
async getAccounts(filters = {}) {
const keyword = String(filters.keyword || '').trim()
const role = String(filters.role || '').trim()
const status = String(filters.status || '').trim()
const wechatBound = String(filters.wechatBound || '').trim()
const accounts = await this.repository.listAccounts()
const filtered = accounts.filter((account) => {
const matchKeyword = !keyword || [
account.name,
account.phone,
account.username,
account.id,
account.roleName,
account.role,
account.wechatNickname,
account.wechatOpenid,
account.wechatUnionid,
].some((value) => String(value || '').toLowerCase().includes(keyword.toLowerCase()))
const matchRole = !role || role === 'all' || account.role === role
const matchStatus = !status || status === 'all' || account.status === status
const isWechatBound = Boolean(account.wechatOpenid || account.wechatUnionid)
const matchWechat = !wechatBound
|| wechatBound === 'all'
|| (wechatBound === 'bound' && isWechatBound)
|| (wechatBound === 'unbound' && !isWechatBound)
return matchKeyword && matchRole && matchStatus && matchWechat
})
if (String(filters.paged || '') !== '1') return this.decorateInvitationRelations(filtered)
const page = paginate(filtered, filters)
return {
...page,
list: await this.decorateInvitationRelations(page.list),
overview: {
total: filtered.length,
active: filtered.filter((item) => item.status === 'active').length,
parents: filtered.filter((item) => item.role === 'parent').length,
admins: filtered.filter((item) => item.role !== 'parent').length,
wechatBound: filtered.filter((item) => item.wechatOpenid || item.wechatUnionid).length,
},
}
}
async decorateInvitationRelations(accounts = []) {
const appointments = await this.repository.listAppointments()
const appointmentUsage = appointments.reduce((result, item) => {
if (item.status !== 'cancelled') result.set(item.userId, (result.get(item.userId) || 0) + 1)
return result
}, new Map())
return Promise.all(accounts.map(async (account) => {
const trialBookingLimit = Number.isInteger(Number(account.trialBookingLimit)) ? Number(account.trialBookingLimit) : 1
const trialBookingUsed = appointmentUsage.get(account.id) || 0
const bookingQuota = {
trialBookingLimit,
trialBookingUsed,
trialBookingRemaining: Math.max(0, trialBookingLimit - trialBookingUsed),
}
if (account.role !== 'parent') {
return {
...account,
...bookingQuota,
invitedBy: null,
invitedUsers: [],
invitedCount: 0,
}
}
const referral = await this.repository.findReferralByInvitee?.(account.id)
const inviter = referral?.inviterId ? await this.repository.findUserById(referral.inviterId) : null
const invitedRecords = await this.repository.getReferralsByInviter(account.id)
const invitedUsers = await Promise.all(invitedRecords.map(async (item) => {
const invitee = item.inviteeId ? await this.repository.findUserById(item.inviteeId) : null
return {
id: invitee?.id || item.inviteeId || '',
name: invitee?.name || item.name,
phone: invitee?.phone || item.phone,
status: item.status,
childGrade: item.childGrade,
channel: item.channel,
}
}))
return {
...account,
...bookingQuota,
invitedBy: inviter
? {
id: inviter.id,
name: inviter.name,
phone: inviter.phone,
}
: null,
invitedUsers,
invitedCount: invitedUsers.length,
}
}))
}
async getRoles() {
return {
roles: await this.repository.getRoleMatrix(),
permissionTree: PERMISSION_TREE,
}
}
async updateRolePermissions(role, payload = {}) {
if (role === 'parent') {
const error = new Error('家长角色不参与后台权限配置')
error.status = 400
throw error
}
const roleInfo = await this.repository.findRole(role)
if (!roleInfo) {
const error = new Error('角色不存在')
error.status = 404
throw error
}
const permissions = sanitizePermissions(payload.permissions || [])
if (role === 'super_admin' && !permissions.includes('*:*:*')) {
permissions.unshift('*:*:*')
}
return this.repository.updateRolePermissions(role, permissions)
}
async createAccount(payload = {}) {
const role = String(payload.role || 'parent').trim()
const phone = String(payload.phone || '').trim()
const username = String(payload.username || phone || '').trim()
const name = String(payload.name || '').trim()
const password = String(payload.password || '').trim()
const status = String(payload.status || 'active').trim()
const trialBookingLimit = parseTrialBookingLimit(payload.trialBookingLimit)
if (!VALID_ROLES.includes(role)) {
const error = new Error('角色无效')
error.status = 400
throw error
}
if (!VALID_STATUSES.includes(status)) {
const error = new Error('账号状态无效')
error.status = 400
throw error
}
if (!name || !phone || !/^1\d{10}$/.test(phone)) {
const error = new Error('姓名和正确手机号必填')
error.status = 400
throw error
}
if (!username) {
const error = new Error('登录账号不能为空')
error.status = 400
throw error
}
const existed = await this.repository.findUserByLoginAccount(username)
const phoneExisted = await this.repository.findUserByPhone(phone)
if (existed || phoneExisted) {
const error = new Error('账号或手机号已存在')
error.status = 409
throw error
}
const roleInfo = await this.repository.findRole(role)
const user = await this.repository.addUser({
id: createId(role === 'parent' ? 'parent' : 'user'),
username,
passwordHash: password ? hashPassword(password) : null,
role,
roleName: roleInfo?.name || role,
name,
phone,
status,
trialBookingLimit,
inviterCode: role === 'parent' ? `LFH-${Math.random().toString(36).slice(2, 7).toUpperCase()}` : null,
phoneBound: true,
phoneBoundAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
child: payload.childName
? {
id: createId('child'),
name: String(payload.childName).trim(),
grade: String(payload.childGrade || '待确认').trim(),
focus: String(payload.childFocus || '待测评').trim(),
}
: undefined,
})
if (role === 'parent') await this.repository.getPointAccount(user.id)
return this.repository.withPermissions(user)
}
async updateAccount(id, payload = {}, operator = null) {
const account = await this.repository.findUserById(id)
if (!account) {
const error = new Error('账号不存在')
error.status = 404
throw error
}
const role = payload.role !== undefined ? String(payload.role || '').trim() : account.role
const status = payload.status !== undefined ? String(payload.status || '').trim() : account.status
const phone = payload.phone !== undefined ? String(payload.phone || '').trim() : account.phone
const username = payload.username !== undefined ? String(payload.username || '').trim() : account.username
const name = payload.name !== undefined ? String(payload.name || '').trim() : account.name
const password = String(payload.password || '').trim()
const trialBookingLimit = parseTrialBookingLimit(payload.trialBookingLimit, account.trialBookingLimit ?? 1)
if (operator && role !== account.role && !hasPermission(operator, ['system:user:role'])) {
const error = new Error('没有分配角色权限')
error.status = 403
throw error
}
if (operator && status !== account.status && !hasPermission(operator, ['system:user:status'])) {
const error = new Error('没有启停账号权限')
error.status = 403
throw error
}
if (!VALID_ROLES.includes(role)) {
const error = new Error('角色无效')
error.status = 400
throw error
}
if (!VALID_STATUSES.includes(status)) {
const error = new Error('账号状态无效')
error.status = 400
throw error
}
if (!name || !phone || !/^1\d{10}$/.test(phone)) {
const error = new Error('姓名和正确手机号必填')
error.status = 400
throw error
}
if (!username) {
const error = new Error('登录账号不能为空')
error.status = 400
throw error
}
const accounts = await this.repository.listAccounts()
const duplicated = accounts.find((item) => item.id !== id && (item.username === username || item.phone === phone))
if (duplicated) {
const error = new Error('账号或手机号已存在')
error.status = 409
throw error
}
const previous = snapshotAccount(account)
const roleInfo = await this.repository.findRole(role)
const childName = String(payload.childName !== undefined ? payload.childName : account.child?.name || '').trim()
const childGrade = String(payload.childGrade !== undefined ? payload.childGrade : account.child?.grade || '').trim()
const childFocus = String(payload.childFocus !== undefined ? payload.childFocus : account.child?.focus || '').trim()
const patch = {
username,
role,
roleName: roleInfo?.name || role,
name,
phone,
status,
trialBookingLimit,
phoneBound: true,
phoneBoundAt: account.phoneBoundAt || new Date().toISOString().slice(0, 16).replace('T', ' '),
inviterCode: role === 'parent' ? account.inviterCode || `LFH-${Math.random().toString(36).slice(2, 7).toUpperCase()}` : account.inviterCode,
child: role === 'parent'
? {
id: account.child?.id || createId('child'),
name: childName || '未填写',
grade: childGrade || '待确认',
focus: childFocus || '待测评',
}
: null,
}
if (password) patch.passwordHash = hashPassword(password)
const updated = await this.repository.updateUser(id, patch)
if (role === 'parent') await this.repository.getPointAccount(id)
await this.syncParentDisplayInfo(previous, updated)
return this.repository.withPermissions(updated)
}
async deleteAccount(id) {
const account = await this.repository.findUserById(id)
if (!account) {
const error = new Error('账号不存在')
error.status = 404
throw error
}
if (account.role === 'super_admin') {
const superAdmins = (await this.repository.listAccounts()).filter((item) => item.role === 'super_admin' && item.status !== 'disabled')
if (superAdmins.length <= 1) {
const error = new Error('至少保留一个启用的超级管理员')
error.status = 400
throw error
}
}
const [transactions, appointments, redemptions, invitedRecords, invitedBy] = await Promise.all([
this.repository.getPointTransactions(id),
this.repository.listAppointments({ userId: id }),
this.repository.listRedemptions({ userId: id }),
this.repository.getReferralsByInviter(id),
this.repository.findReferralByInvitee?.(id),
])
if (transactions.length || appointments.length || redemptions.length || invitedRecords.length || invitedBy) {
const error = new Error('该用户已有推荐、积分、预约或兑换记录,请停用账号以保留业务历史')
error.status = 409
throw error
}
await this.repository.deleteUser(id)
return this.repository.withPermissions(account)
}
async updateAccountRole(id, role) {
const account = await this.repository.updateAccountRole(id, role)
if (!account) {
const error = new Error('账号或角色不存在')
error.status = 404
throw error
}
return account
}
async updateAccountStatus(id, status) {
if (!VALID_STATUSES.includes(status)) {
const error = new Error('账号状态无效')
error.status = 400
throw error
}
const account = await this.repository.updateAccountStatus(id, status)
if (!account) {
const error = new Error('账号不存在')
error.status = 404
throw error
}
return account
}
async updateLeadStage(id, stage) {
if (!LEAD_STAGE_FLOW.includes(stage)) {
const error = new Error('线索阶段无效')
error.status = 400
throw error
}
const lead = await this.repository.updateLeadStage(id, stage)
if (!lead) {
const error = new Error('线索不存在')
error.status = 404
throw error
}
await this.syncReferralWithLeadStage(lead, stage)
return lead
}
async confirmLeadPurchase(id) {
return this.updateLeadStage(id, '已报名')
}
async getDecoratedLeads() {
const leads = await this.repository.getLeads()
return Promise.all(leads.map((lead) => this.decorateLead(lead)))
}
async decorateLead(lead) {
const referral = await this.findReferralByLead(lead)
if (!referral) return lead
const invitee = referral.inviteeId ? await this.repository.findUserById(referral.inviteeId) : null
const hasIdentityLink = Boolean(
(lead.referralId && lead.referralId === referral.id)
|| (lead.inviteeId && referral.inviteeId && lead.inviteeId === referral.inviteeId),
)
return {
...lead,
referralId: referral.id || lead.referralId,
inviteeId: referral.inviteeId || lead.inviteeId,
parent: invitee?.name || referral.name || lead.parent,
phone: invitee?.phone || referral.phone || '',
childGrade: invitee?.child?.grade || referral.childGrade || '',
stage: hasIdentityLink ? this.maxLeadStage(lead.stage, referral.status) : lead.stage,
}
}
maxLeadStage(leadStage, referralStage) {
const leadRank = LEAD_STAGE_FLOW.indexOf(leadStage)
const referralRank = LEAD_STAGE_FLOW.indexOf(referralStage)
if (referralRank < 0) return leadStage
if (leadRank < 0) return referralStage
return LEAD_STAGE_FLOW[Math.max(leadRank, referralRank)]
}
async syncParentDisplayInfo(previous, current) {
if (!current || current.role !== 'parent') return
const referrals = await this.repository.updateReferralInviteeInfo?.(current.id, {
name: current.name,
phone: current.phone,
childGrade: current.child?.grade || '待确认',
})
if (Array.isArray(referrals) && referrals.length && this.repository.updateLeadInfoByReferral) {
await Promise.all(referrals.map((referral) => this.repository.updateLeadInfoByReferral(referral, {
parent: current.name,
}, previous || {})))
return
}
if (previous?.name && previous.name !== current.name) {
await this.repository.updateLeadsByParent?.(previous.name, { parent: current.name })
}
}
async syncReferralWithLeadStage(lead, targetStage) {
if (!this.pointService || !REFERRAL_STAGE_EVENTS[targetStage]) return null
const referral = await this.findReferralByLead(lead)
if (!referral) return null
const currentRank = Math.max(0, REFERRAL_STAGE_FLOW.indexOf(referral.status))
const targetRank = REFERRAL_STAGE_FLOW.indexOf(targetStage)
if (targetRank <= currentRank) return referral
let nextReferral = referral
for (let rank = currentRank + 1; rank <= targetRank; rank += 1) {
const nextStatus = REFERRAL_STAGE_FLOW[rank]
const event = REFERRAL_STAGE_EVENTS[nextStatus]
if (!event) continue
nextReferral = await this.applyReferralStageReward(nextReferral, nextStatus, event)
}
return nextReferral
}
async findReferralByLead(lead) {
const matchedReferral = await this.repository.findReferralForLead?.(lead)
if (matchedReferral) return matchedReferral
if (lead?.referralId && this.repository.findReferralById) {
const referral = await this.repository.findReferralById(lead.referralId)
if (referral) return referral
}
if (lead?.inviteeId && this.repository.findReferralByInvitee) {
const referral = await this.repository.findReferralByInvitee(lead.inviteeId)
if (referral) return referral
}
if (!lead?.parent || !lead?.source) return null
const accounts = await this.repository.listAccounts()
const inviter = accounts.find((account) => account.role === 'parent' && [account.id, account.name, account.phone].filter(Boolean).includes(lead.source))
if (!inviter) return null
const records = await this.repository.getReferralsByInviter(inviter.id)
const matches = []
for (const record of records) {
const invitee = record.inviteeId ? await this.repository.findUserById(record.inviteeId) : null
const fallbackNames = [
invitee?.name,
record.name,
invitee?.phone ? `家长${String(invitee.phone).slice(-4)}` : '',
record.phone ? `家长${String(record.phone).slice(-4)}` : '',
].filter(Boolean)
if (fallbackNames.includes(lead.parent)) matches.push(record)
}
if (!matches.length) return null
return matches.find((record) => !lead.channel || record.channel === lead.channel) || matches[0]
}
async applyReferralStageReward(referral, nextStatus, event) {
const rule = await this.pointService.getReferralRule(event) || { inviter: 0, invitee: 0 }
const inviterPoints = Number(rule.inviter || 0)
const inviteePoints = Number(rule.invitee || 0)
const inviteeId = referral.inviteeId
if (event === 'attended') {
await this.settleReferralPending(referral.inviterId, `${referral.name}确认到课`)
if (inviteeId) await this.settleReferralPending(inviteeId, '推荐确认到课')
}
if (inviterPoints > 0) {
await this.pointService.grant(referral.inviterId, `${referral.name}${rule.title || nextStatus}`, inviterPoints, event === 'booked' ? 'pending' : 'available')
}
if (inviteeId && inviteePoints > 0) {
await this.pointService.grant(inviteeId, `推荐${rule.title || nextStatus}奖励`, inviteePoints, event === 'booked' ? 'pending' : 'available')
}
return this.repository.updateReferral(referral.id, {
status: nextStatus,
reward: Number(referral.reward || 0) + inviterPoints,
})
}
async settleReferralPending(userId, title) {
const account = await this.repository.getPointAccount(userId)
if (Number(account.pending || 0) > 0) {
await this.pointService.settlePending(userId, account.pending, title)
}
}
}
module.exports = { AdminService }

@ -0,0 +1,482 @@
const { hashPassword, verifyPassword } = require('../utils/password')
const { createId } = require('../utils/ids')
const { env } = require('../config/env')
const { createSessionToken } = require('../utils/sessionToken')
function snapshotAccount(account) {
if (!account) return null
return {
...account,
child: account.child ? { ...account.child } : account.child,
}
}
class AuthService {
constructor(repository, pointService, smsService) {
this.repository = repository
this.pointService = pointService
this.smsService = smsService
}
withSession(account) {
if (!account) return account
return {
...account,
sessionToken: createSessionToken(account.id),
}
}
async login(credentials = {}) {
const loginAccount = String(credentials.account || credentials.username || credentials.phone || credentials.accountId || '').trim()
const password = String(credentials.password || '')
if (!loginAccount || !password) {
const error = new Error('账号和密码不能为空')
error.status = 400
throw error
}
const rawAccount = await this.repository.findUserByLoginAccount(loginAccount)
if (!rawAccount) {
const error = new Error('账号或密码错误')
error.status = 401
throw error
}
if (!verifyPassword(password, rawAccount.passwordHash)) {
const error = new Error('账号或密码错误')
error.status = 401
throw error
}
const account = await this.repository.touchLogin(rawAccount.id)
if (!account) {
const error = new Error('账号不存在')
error.status = 404
throw error
}
if (account.status === 'disabled') {
const error = new Error('账号已停用')
error.status = 403
throw error
}
return this.withSession(account)
}
async loginByPhone(payload = {}) {
const phone = String(payload.phone || '').trim()
const code = String(payload.code || '').trim()
const inviterCode = String(payload.inviterCode || payload.inviteCode || '').trim().toUpperCase()
if (!/^1\d{10}$/.test(phone)) {
const error = new Error('手机号格式不正确')
error.status = 400
throw error
}
this.verifySmsCode(phone, code, 'login')
let account = await this.repository.findUserByPhone(phone)
const isNewAccount = !account
if (isNewAccount && inviterCode) await this.assertInviteCode(inviterCode)
if (!account) {
account = await this.repository.addUser({
id: createId('parent'),
username: phone,
role: 'parent',
roleName: '家长',
name: `家长${phone.slice(-4)}`,
phone,
status: 'active',
inviterCode: `LFH-${Math.random().toString(36).slice(2, 7).toUpperCase()}`,
phoneBound: true,
phoneBoundAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
})
await this.repository.getPointAccount(account.id)
if (inviterCode) await this.applyReferralCode(account, inviterCode, '小程序邀请码')
}
const safeAccount = await this.repository.touchLogin(account.id)
if (!safeAccount) {
const error = new Error('账号不存在')
error.status = 404
throw error
}
if (safeAccount.status === 'disabled') {
const error = new Error('账号已停用')
error.status = 403
throw error
}
if (!safeAccount.phoneBound) {
const error = new Error('请先绑定手机号')
error.status = 403
throw error
}
return this.withSession(safeAccount)
}
async loginByWechat(payload = {}) {
const code = String(payload.code || '').trim()
if (!code) {
const error = new Error('缺少微信登录 code')
error.status = 400
throw error
}
const wechat = await this.exchangeWechatCode(code)
const account = await this.findWechatAccount(wechat)
if (!account) {
return { needPhoneBinding: true }
}
if (account.status === 'disabled') {
const error = new Error('账号已停用')
error.status = 403
throw error
}
if (!account.phoneBound || !account.phone) {
return { needPhoneBinding: true, account: await this.repository.withPermissions(account) }
}
return { needPhoneBinding: false, account: this.withSession(await this.repository.touchLogin(account.id)) }
}
async bindWechatPhone(payload = {}) {
const phone = String(payload.phone || '').trim()
const code = String(payload.code || '').trim()
const name = String(payload.name || '').trim()
const inviterCode = String(payload.inviterCode || payload.inviteCode || '').trim().toUpperCase()
if (!/^1\d{10}$/.test(phone)) {
const error = new Error('手机号格式不正确')
error.status = 400
throw error
}
this.verifySmsCode(phone, code, 'bind_wechat')
const wechatCode = String(payload.wechatCode || payload.wxCode || '').trim()
if (!wechatCode) {
const error = new Error('缺少微信登录 code')
error.status = 400
throw error
}
const wechat = await this.exchangeWechatCode(wechatCode)
const wechatAccount = await this.findWechatAccount(wechat)
const phoneAccount = await this.repository.findUserByPhone(phone)
if (wechatAccount?.phone && wechatAccount.phone !== phone) {
const error = new Error('该微信已绑定其他手机号,请在当前账号的账号安全中换绑手机号')
error.status = 409
throw error
}
if (phoneAccount && this.isAccountBoundToOtherWechat(phoneAccount, wechat)) {
const error = new Error('该手机号已绑定其他微信,请使用原微信登录或联系管理员处理')
error.status = 409
throw error
}
if (wechatAccount && phoneAccount && wechatAccount.id !== phoneAccount.id) {
const error = new Error('该微信和手机号已分别绑定不同账号,请联系管理员处理')
error.status = 409
throw error
}
let account = phoneAccount || wechatAccount
const isNewAccount = !account
if (isNewAccount && inviterCode) await this.assertInviteCode(inviterCode)
if (account) {
const previous = snapshotAccount(account)
account = await this.repository.updateUser(account.id, {
name: name || account.name,
phone,
wechatOpenid: wechat.openid,
wechatUnionid: wechat.unionid || account.wechatUnionid,
loginProvider: 'wechat_phone',
phoneBound: true,
phoneBoundAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
wechatBoundAt: account.wechatBoundAt || new Date().toISOString().slice(0, 16).replace('T', ' '),
})
await this.syncParentDisplayInfo(previous, account)
} else {
account = await this.repository.addUser({
id: createId('parent'),
username: phone,
role: 'parent',
roleName: '家长',
name: name || `家长${phone.slice(-4)}`,
phone,
wechatOpenid: wechat.openid,
wechatUnionid: wechat.unionid || null,
loginProvider: 'wechat_phone',
status: 'active',
inviterCode: `LFH-${Math.random().toString(36).slice(2, 7).toUpperCase()}`,
phoneBound: true,
phoneBoundAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
wechatBoundAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
})
await this.repository.getPointAccount(account.id)
if (inviterCode) await this.applyReferralCode(account, inviterCode, '微信绑定邀请码')
}
return this.withSession(await this.repository.touchLogin(account.id))
}
async updateProfile(accountId, payload = {}) {
if (!accountId) {
const error = new Error('缺少账号 ID')
error.status = 400
throw error
}
const account = await this.repository.findUserById(accountId)
if (!account) {
const error = new Error('账号不存在')
error.status = 404
throw error
}
const patch = {}
if (payload.name !== undefined) patch.name = String(payload.name || '').trim()
if (payload.wechatNickname !== undefined) patch.wechatNickname = String(payload.wechatNickname || '').trim()
if (payload.wechatAvatar !== undefined) patch.wechatAvatar = String(payload.wechatAvatar || '').trim()
const childName = String(payload.childName || account.child?.name || '').trim()
const childGrade = String(payload.childGrade || account.child?.grade || '').trim()
const childFocus = String(payload.childFocus || account.child?.focus || '').trim()
if (childName || childGrade || childFocus) {
patch.child = {
id: account.child?.id || createId('child'),
name: childName || '未填写',
grade: childGrade || '待确认',
focus: childFocus || '待测评',
}
}
if (patch.name !== undefined && !patch.name) {
const error = new Error('姓名不能为空')
error.status = 400
throw error
}
const previous = snapshotAccount(account)
const updated = await this.repository.updateUser(accountId, patch)
await this.syncParentDisplayInfo(previous, updated)
return this.repository.withPermissions(updated)
}
async updatePassword(accountId, payload = {}) {
const account = await this.repository.findUserById(accountId)
if (!account) {
const error = new Error('账号不存在')
error.status = 404
throw error
}
const oldPassword = String(payload.oldPassword || '').trim()
const password = String(payload.password || payload.newPassword || '').trim()
const confirmPassword = String(payload.confirmPassword || '').trim()
if (password.length < 6) {
const error = new Error('密码至少需要 6 位')
error.status = 400
throw error
}
if (confirmPassword && confirmPassword !== password) {
const error = new Error('两次输入的密码不一致')
error.status = 400
throw error
}
if (account.passwordHash && !verifyPassword(oldPassword, account.passwordHash)) {
const error = new Error('原密码不正确')
error.status = 401
throw error
}
const updated = await this.repository.updateUser(accountId, {
passwordHash: hashPassword(password),
loginProvider: account.wechatOpenid ? 'wechat_phone_password' : 'password',
})
return this.repository.withPermissions(updated)
}
async updatePhone(accountId, payload = {}) {
const account = await this.repository.findUserById(accountId)
if (!account) {
const error = new Error('账号不存在')
error.status = 404
throw error
}
const phone = String(payload.phone || '').trim()
const code = String(payload.code || '').trim()
if (!/^1\d{10}$/.test(phone)) {
const error = new Error('手机号格式不正确')
error.status = 400
throw error
}
this.verifySmsCode(phone, code, 'update_phone')
const existed = await this.repository.findUserByPhone(phone)
if (existed && existed.id !== accountId) {
const error = new Error('该手机号已被其他账号绑定')
error.status = 409
throw error
}
const patch = {
phone,
phoneBound: true,
phoneBoundAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
}
if (!account.username || account.username === account.phone) {
patch.username = phone
}
const previous = snapshotAccount(account)
const updated = await this.repository.updateUser(accountId, patch)
await this.syncParentDisplayInfo(previous, updated)
return this.repository.withPermissions(updated)
}
async current(accountId) {
if (!accountId) {
const error = new Error('缺少账号 ID')
error.status = 400
throw error
}
const account = await this.repository.findUserById(accountId)
if (!account) {
const error = new Error('账号不存在')
error.status = 404
throw error
}
const safeAccount = await this.repository.withPermissions(account)
const appointments = await this.repository.listAppointments({ userId: account.id })
const trialBookingUsed = appointments.filter((item) => item.status !== 'cancelled').length
const trialBookingLimit = Number.isInteger(Number(safeAccount.trialBookingLimit)) ? Number(safeAccount.trialBookingLimit) : 1
return {
...safeAccount,
trialBookingLimit,
trialBookingUsed,
trialBookingRemaining: Math.max(0, trialBookingLimit - trialBookingUsed),
}
}
async findWechatAccount(wechat) {
const byOpenid = wechat.openid ? await this.repository.findUserByLoginAccount(wechat.openid) : null
if (byOpenid) return byOpenid
return wechat.unionid ? await this.repository.findUserByLoginAccount(wechat.unionid) : null
}
isAccountBoundToOtherWechat(account, wechat) {
if (!account) return false
const openid = String(wechat?.openid || '').trim()
const unionid = String(wechat?.unionid || '').trim()
const accountOpenid = String(account.wechatOpenid || '').trim()
const accountUnionid = String(account.wechatUnionid || '').trim()
if (accountOpenid && accountOpenid !== openid) return true
if (accountUnionid && unionid && accountUnionid !== unionid) return true
return false
}
async assertInviteCode(inviterCode) {
const inviter = await this.repository.findUserByInviteCode(inviterCode)
if (!inviter) {
const error = new Error('邀请码无效')
error.status = 400
throw error
}
return inviter
}
async sendSmsCode(payload = {}) {
if (!this.smsService) {
const error = new Error('短信服务未启用')
error.status = 501
throw error
}
return this.smsService.sendVerificationCode(payload)
}
verifySmsCode(phone, code, purpose) {
if (!this.smsService) {
const error = new Error('短信服务未启用')
error.status = 501
throw error
}
return this.smsService.verifyCode(phone, code, purpose)
}
async syncParentDisplayInfo(previous, current) {
if (!current || current.role !== 'parent') return
const referrals = await this.repository.updateReferralInviteeInfo?.(current.id, {
name: current.name,
phone: current.phone,
childGrade: current.child?.grade || '待确认',
})
if (Array.isArray(referrals) && referrals.length && this.repository.updateLeadInfoByReferral) {
await Promise.all(referrals.map((referral) => this.repository.updateLeadInfoByReferral(referral, {
parent: current.name,
}, previous || {})))
return
}
if (previous?.name && previous.name !== current.name) {
await this.repository.updateLeadsByParent?.(previous.name, { parent: current.name })
}
}
async applyReferralCode(invitee, inviterCode, channel) {
if (!inviterCode || !invitee || invitee.role !== 'parent') return null
const inviter = await this.assertInviteCode(inviterCode)
if (inviter.id === invitee.id) return null
const records = await this.repository.getReferralsByInviter(inviter.id)
const existed = records.some((item) => item.inviteeId === invitee.id || item.phone === invitee.phone)
if (existed) return null
const rule = await this.pointService.getReferralRule('registered') || { inviter: 20, invitee: 20 }
const referral = await this.repository.addReferral({
id: createId('rel'),
inviterId: inviter.id,
inviteeId: invitee.id,
name: invitee.name,
phone: invitee.phone,
childGrade: invitee.child?.grade || '待确认',
status: '已注册',
reward: rule.inviter,
channel,
})
await this.repository.addLead({
id: createId('lead'),
referralId: referral.id,
inviteeId: invitee.id,
parent: invitee.name,
source: inviter.name,
stage: '已注册',
advisor: '未分配',
risk: '正常',
channel,
})
if (rule.inviter > 0) await this.pointService.grant(inviter.id, `${invitee.name}完成邀请码注册`, rule.inviter)
if (rule.invitee > 0) await this.pointService.grant(invitee.id, '填写邀请码注册奖励', rule.invitee)
return referral
}
async exchangeWechatCode(code) {
if (!env.wechat.appId || !env.wechat.appSecret) {
const error = new Error('微信登录尚未配置 AppID 和 AppSecret')
error.status = 501
throw error
}
const params = new URLSearchParams({
appid: env.wechat.appId,
secret: env.wechat.appSecret,
js_code: code,
grant_type: 'authorization_code',
})
const response = await fetch(`https://api.weixin.qq.com/sns/jscode2session?${params.toString()}`)
const data = await response.json()
if (!response.ok || data.errcode) {
const error = new Error(data.errmsg || '微信登录校验失败')
error.status = 400
throw error
}
if (!data.openid) {
const error = new Error('微信登录未返回 openid')
error.status = 400
throw error
}
return {
openid: data.openid,
unionid: data.unionid || null,
}
}
}
module.exports = { AuthService }

@ -0,0 +1,369 @@
const { createId } = require('../utils/ids')
function readPagination(filters = {}) {
const pageNum = Math.max(1, Number(filters.pageNum || filters.page || 1))
const pageSize = Math.min(100, Math.max(1, Number(filters.pageSize || filters.limit || 10)))
return { pageNum, pageSize }
}
function paginate(list = [], filters = {}) {
const { pageNum, pageSize } = readPagination(filters)
const start = (pageNum - 1) * pageSize
return {
list: list.slice(start, start + pageSize),
total: list.length,
pageNum,
pageSize,
}
}
function isValidDateTime(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/)
if (!match) return false
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]))
return date.getFullYear() === Number(match[1])
&& date.getMonth() === Number(match[2]) - 1
&& date.getDate() === Number(match[3])
&& date.getHours() === Number(match[4])
&& date.getMinutes() === Number(match[5])
}
class CourseService {
constructor(repository, pointService) {
this.repository = repository
this.pointService = pointService
}
normalizeCoursePayload(payload = {}) {
const patch = {}
if (payload.name !== undefined) patch.name = String(payload.name).trim()
if (payload.nextTime !== undefined) {
patch.nextTime = String(payload.nextTime).trim()
if (patch.nextTime && !isValidDateTime(patch.nextTime)) {
const error = new Error('试听时间格式无效,请重新选择日期和时间')
error.status = 400
throw error
}
}
if (payload.campus !== undefined) patch.campus = String(payload.campus).trim()
if (payload.teacher !== undefined) patch.teacher = String(payload.teacher).trim()
if (payload.status !== undefined) patch.status = ['active', 'paused'].includes(payload.status) ? payload.status : 'active'
if (payload.seats !== undefined) patch.seats = Number(payload.seats)
if (payload.pointsBack !== undefined) patch.pointsBack = Number(payload.pointsBack)
if (payload.trialMinutes !== undefined) patch.trialMinutes = Number(payload.trialMinutes)
if (payload.tags !== undefined) patch.tags = Array.isArray(payload.tags) ? payload.tags : String(payload.tags).split(',').map((item) => item.trim()).filter(Boolean)
for (const key of ['seats', 'pointsBack', 'trialMinutes']) {
if (patch[key] !== undefined && (!Number.isFinite(patch[key]) || patch[key] < 0)) {
const error = new Error('课程数值字段无效')
error.status = 400
throw error
}
}
return patch
}
async listCourses(filters = {}) {
const keyword = String(filters.keyword || '').trim()
const campus = String(filters.campus || '').trim()
const courses = await this.repository.getCourses()
const active = courses.filter((course) => course.status !== 'paused')
const filtered = active.filter((course) => {
const matchKeyword = !keyword || [course.name, course.teacher, course.campus, ...(course.tags || [])].some((value) => String(value || '').includes(keyword))
const matchCampus = !campus || campus === 'all' || campus === '全部' || course.campus === campus
return matchKeyword && matchCampus
})
if (String(filters.paged || '') !== '1') return filtered
return {
...paginate(filtered, filters),
campusOptions: [...new Set(active.map((item) => item.campus).filter(Boolean))],
}
}
async listAdminCourses(filters = {}) {
const keyword = String(filters.keyword || '').trim()
const status = String(filters.status || '').trim()
const campus = String(filters.campus || '').trim()
const courses = await this.repository.getCourses()
const filtered = courses.filter((course) => {
const matchKeyword = !keyword || [course.name, course.teacher, course.campus, ...(course.tags || [])].some((value) => String(value || '').includes(keyword))
const matchStatus = !status || status === '全部' || course.status === status
const matchCampus = !campus || campus === '全部' || course.campus === campus
return matchKeyword && matchStatus && matchCampus
})
if (String(filters.paged || '') !== '1') return filtered
const active = filtered.filter((item) => item.status === 'active')
const overview = {
total: filtered.length,
active: active.length,
seats: active.reduce((sum, item) => sum + Number(item.seats || 0), 0),
campuses: new Set(filtered.map((item) => item.campus).filter(Boolean)).size,
avgPoints: active.length ? Math.round(active.reduce((sum, item) => sum + Number(item.pointsBack || 0), 0) / active.length) : 0,
}
return {
...paginate(filtered, filters),
overview,
campusOptions: [...new Set(courses.map((item) => item.campus).filter(Boolean))],
}
}
async getAdminCourse(id) {
const course = await this.repository.findCourse(id)
if (!course) {
const error = new Error('课程不存在')
error.status = 404
throw error
}
return course
}
async listAppointments(filters = {}) {
const appointments = await this.repository.listAppointments(filters)
const overview = appointments.reduce(
(result, item) => {
result.total += 1
result[item.status] = (result[item.status] || 0) + 1
return result
},
{ total: 0, booked: 0, attended: 0, cancelled: 0 },
)
return {
...paginate(appointments, filters),
overview,
}
}
async listUserAppointments(userId) {
const value = String(userId || '').trim()
if (!value) {
const error = new Error('缺少预约账号')
error.status = 400
throw error
}
return this.repository.listAppointments({ userId: value })
}
async attendAppointment(id) {
const appointment = await this.repository.findAppointmentById(id)
if (!appointment) {
const error = new Error('预约记录不存在')
error.status = 404
throw error
}
if (appointment.status === 'attended') return appointment
if (appointment.status !== 'booked') {
const error = new Error('当前预约状态不能确认到课')
error.status = 400
throw error
}
const referral = await this.repository.findReferralByInvitee?.(appointment.userId)
const bookingRule = referral
? await this.pointService.getReferralRule('booked') || { inviter: 50, invitee: 50 }
: { inviter: 0, invitee: 0 }
const inviteeSettlement = await this.pointService.settlePendingReference(
appointment.userId,
'appointment',
appointment.id,
`确认${appointment.course?.name || '试听课'}到课`,
)
if (!inviteeSettlement) {
await this.settleLegacyAppointmentPoints(
appointment.userId,
Number(appointment.course?.pointsBack || 0) + Number(bookingRule.invitee || 0),
`确认${appointment.course?.name || '试听课'}到课`,
)
}
if (referral && !['已到课', '已报名'].includes(referral.status)) {
const inviterSettlement = await this.pointService.settlePendingReference(
referral.inviterId,
'appointment',
appointment.id,
`${referral.name}确认到课`,
)
if (!inviterSettlement) {
await this.settleLegacyAppointmentPoints(
referral.inviterId,
Number(bookingRule.inviter || 0),
`${referral.name}确认到课`,
)
}
const rule = await this.pointService.getReferralRule('attended') || { inviter: 150, invitee: 100 }
const updatedReferral = await this.repository.updateReferral(referral.id, {
status: '已到课',
reward: Number(referral.reward || 0) + Number(rule.inviter || 0),
})
await this.repository.updateLeadStageByReferral?.(updatedReferral || referral, '已到课')
if (rule.inviter > 0) await this.pointService.grant(referral.inviterId, `${referral.name}确认到课`, rule.inviter)
if (rule.invitee > 0) await this.pointService.grant(appointment.userId, '推荐到课奖励', rule.invitee)
}
return this.repository.updateAppointment(id, { status: 'attended' })
}
async settleLegacyAppointmentPoints(userId, expectedAmount, title) {
if (expectedAmount <= 0) return null
const account = await this.repository.getPointAccount(userId)
const amount = Math.min(Number(account.pending || 0), expectedAmount)
if (amount <= 0) return null
return this.pointService.settlePending(userId, amount, title)
}
async createCourse(payload) {
const course = {
id: createId('course'),
name: '新试听课程',
tags: ['试听'],
nextTime: '',
pointsBack: 0,
seats: 0,
campus: '',
teacher: '',
trialMinutes: 45,
status: 'paused',
...this.normalizeCoursePayload(payload),
}
if (!course.name) {
const error = new Error('课程名称不能为空')
error.status = 400
throw error
}
return this.repository.addCourse(course)
}
async updateCourse(id, payload) {
const patch = this.normalizeCoursePayload(payload)
if (patch.name !== undefined && !patch.name) {
const error = new Error('课程名称不能为空')
error.status = 400
throw error
}
const course = await this.repository.updateCourse(id, patch)
if (!course) {
const error = new Error('课程不存在')
error.status = 404
throw error
}
return course
}
async deleteCourse(id) {
const appointments = await this.repository.listAppointments({ courseId: id })
if (appointments.length) {
const error = new Error('该课程已有预约记录,不能删除;请暂停课程以保留历史记录')
error.status = 409
throw error
}
if (patch.nextTime !== undefined && !patch.nextTime) {
const error = new Error('请选择试听日期和时间')
error.status = 400
throw error
}
if (!course.nextTime) {
const error = new Error('请选择试听日期和时间')
error.status = 400
throw error
}
const course = await this.repository.deleteCourse(id)
if (!course) {
const error = new Error('课程不存在')
error.status = 404
throw error
}
return course
}
async book(payload) {
const userId = String(payload.userId || '').trim()
if (!userId) {
const error = new Error('缺少预约账号')
error.status = 400
throw error
}
const user = await this.repository.findUserById(userId)
if (!user || user.role !== 'parent') {
const error = new Error('家长账号不存在')
error.status = 404
throw error
}
if (user.status !== 'active') {
const error = new Error('账号已停用,不能预约课程')
error.status = 403
throw error
}
if (!user.phoneBound || !user.phone) {
const error = new Error('请先绑定手机号再预约课程')
error.status = 400
throw error
}
if (payload.childId && user.child?.id && payload.childId !== user.child.id) {
const error = new Error('预约学生信息与当前账号不一致')
error.status = 400
throw error
}
const appointments = await this.repository.listAppointments({ userId })
const activeAppointments = appointments.filter((item) => item.status !== 'cancelled')
if (activeAppointments.some((item) => item.courseId === payload.courseId)) {
const error = new Error('该试听课程已经预约,请勿重复预约')
error.status = 400
throw error
}
const trialBookingLimit = Number.isInteger(Number(user.trialBookingLimit)) ? Number(user.trialBookingLimit) : 1
if (activeAppointments.length >= trialBookingLimit) {
const error = new Error(trialBookingLimit > 0
? `当前账号最多可预约 ${trialBookingLimit} 次试听课程`
: '当前账号暂不可预约试听课程')
error.status = 400
throw error
}
const course = await this.repository.findCourse(payload.courseId)
if (!course) {
const error = new Error('课程不存在')
error.status = 404
throw error
}
if (course.status !== 'active') {
const error = new Error('该课程已暂停预约')
error.status = 400
throw error
}
if (course.seats <= 0) {
const error = new Error('该课程已约满')
error.status = 400
throw error
}
const appointment = await this.repository.addAppointment({
id: createId('appt'),
userId,
childId: payload.childId,
courseId: payload.courseId,
status: 'booked',
createdAt: new Date().toISOString(),
})
await this.repository.updateCourse(course.id, { seats: course.seats - 1 })
const referral = await this.repository.findReferralByInvitee?.(userId)
if (referral && !['已预约', '已到课', '已报名'].includes(referral.status)) {
const rule = await this.pointService.getReferralRule('booked') || { inviter: 50, invitee: 50 }
const updatedReferral = await this.repository.updateReferral(referral.id, {
status: '已预约',
reward: Number(referral.reward || 0) + Number(rule.inviter || 0),
})
await this.repository.updateLeadStageByReferral?.(updatedReferral || referral, '已预约')
const reference = { referenceType: 'appointment', referenceId: appointment.id }
if (rule.inviter > 0) await this.pointService.grant(referral.inviterId, `${referral.name}预约试听`, rule.inviter, 'pending', reference)
if (rule.invitee > 0) await this.pointService.grant(userId, '推荐预约试听奖励', rule.invitee, 'pending', reference)
}
if (course.pointsBack > 0) {
await this.pointService.grant(userId, `预约${course.name}`, course.pointsBack, 'pending', {
referenceType: 'appointment',
referenceId: appointment.id,
})
}
return appointment
}
}
module.exports = { CourseService }

@ -0,0 +1,24 @@
const { repository } = require('../repositories')
const { PointService } = require('./pointService')
const { ReferralService } = require('./referralService')
const { CourseService } = require('./courseService')
const { AdminService } = require('./adminService')
const { AuthService } = require('./authService')
const { SmsService } = require('./smsService')
const pointService = new PointService(repository)
const smsService = new SmsService()
const referralService = new ReferralService(repository, pointService, smsService)
const courseService = new CourseService(repository, pointService)
const adminService = new AdminService(repository, pointService)
const authService = new AuthService(repository, pointService, smsService)
module.exports = {
repository,
smsService,
pointService,
referralService,
courseService,
adminService,
authService,
}

@ -0,0 +1,646 @@
const { createId } = require('../utils/ids')
const REDEMPTION_STATUS_LABELS = {
pending_review: '待审核',
approved: '已发券',
rejected: '已驳回',
used: '已核销',
expired: '已过期',
}
const VOUCHER_STATUS_LABELS = {
not_issued: '未发券',
issued: '待核销',
used: '已核销',
void: '已作废',
expired: '已过期',
}
function toDateTime(value = new Date()) {
return new Date(value).toISOString().slice(0, 16).replace('T', ' ')
}
function addDays(days) {
const date = new Date()
date.setDate(date.getDate() + days)
return toDateTime(date)
}
function readPagination(filters = {}) {
const pageNum = Math.max(1, Number(filters.pageNum || filters.page || 1))
const pageSize = Math.min(100, Math.max(1, Number(filters.pageSize || filters.limit || 10)))
return { pageNum, pageSize }
}
function paginate(list = [], filters = {}) {
const { pageNum, pageSize } = readPagination(filters)
const start = (pageNum - 1) * pageSize
return {
list: list.slice(start, start + pageSize),
total: list.length,
pageNum,
pageSize,
}
}
class PointService {
constructor(repository) {
this.repository = repository
}
async getCenter(userId) {
const redemptions = await this.repository.listRedemptions({ userId })
return {
account: await this.repository.getPointAccount(userId),
transactions: await this.repository.getPointTransactions(userId),
rewards: await this.repository.getRewards(),
redemptions: redemptions.map((item) => this.decorateRedemption(item)),
expiryRule: await this.repository.getPointExpiryRule(),
expirySchedules: await this.repository.listPointExpirySchedules(userId),
}
}
async listUserRewards(userId, filters = {}) {
const keyword = String(filters.keyword || '').trim()
const rewards = await this.repository.listRewards(filters)
const filtered = rewards.filter((reward) => {
const matchKeyword = !keyword || [reward.name, reward.type, reward.description].some((value) => String(value || '').includes(keyword))
return matchKeyword
})
return paginate(filtered, filters)
}
async listUserTransactions(userId, filters = {}) {
const keyword = String(filters.keyword || '').trim()
const transactions = await this.repository.getPointTransactions(userId)
const filtered = transactions.filter((item) => {
const matchKeyword = !keyword || [item.title, item.createdAt, item.status].some((value) => String(value || '').includes(keyword))
const matchStatus = !filters.status || filters.status === 'all' || item.status === filters.status
return matchKeyword && matchStatus
})
return paginate(filtered, filters)
}
async listUserRedemptions(userId, filters = {}) {
const keyword = String(filters.keyword || '').trim()
const redemptions = (await this.repository.listRedemptions({ userId })).map((item) => this.decorateRedemption(item))
const filtered = redemptions.filter((item) => {
const matchKeyword = !keyword || [item.id, item.reward?.name, item.voucherCode, item.statusLabel, item.rejectedReason].some((value) => String(value || '').includes(keyword))
const matchStatus = !filters.status || filters.status === 'all' || item.status === filters.status
return matchKeyword && matchStatus
})
return paginate(filtered, filters)
}
async getAdminManager() {
const parentUsers = await this.repository.listParentUsers()
const accounts = await Promise.all(parentUsers.map(async (user) => ({
user,
points: await this.repository.getPointAccount(user.id),
})))
const rawTransactions = await this.repository.listPointTransactions()
const transactions = await Promise.all(rawTransactions.map(async (transaction) => ({
...transaction,
userName: (await this.repository.findUserById(transaction.userId))?.name || transaction.userId,
})))
const rawExpirySchedules = await this.repository.listPointExpirySchedules()
const expirySchedules = await Promise.all(rawExpirySchedules.map(async (item) => ({
...item,
userName: (await this.repository.findUserById(item.userId))?.name || item.userId,
})))
const redemptions = await this.repository.listRedemptions()
const overview = accounts.reduce(
(result, item) => {
result.available += item.points.available
result.pending += item.points.pending
result.frozen += item.points.frozen
result.expiring += item.points.expiring
return result
},
{ available: 0, pending: 0, frozen: 0, expiring: 0 },
)
return {
overview: {
...overview,
parents: accounts.length,
rewards: (await this.repository.getRewards()).length,
transactions: transactions.length,
pendingRedemptions: redemptions.filter((item) => item.status === 'pending_review').length,
issuedVouchers: redemptions.filter((item) => item.voucherStatus === 'issued').length,
nextExpireAt: expirySchedules[0]?.expireAt || '',
},
accounts,
transactions,
expiryRule: await this.repository.getPointExpiryRule(),
expirySchedules,
rewards: await this.repository.getRewards(),
redemptions: redemptions.slice(0, 8).map((item) => this.decorateRedemption(item)),
rules: await this.repository.getReferralRules(),
}
}
async listPointAccounts(filters = {}) {
const keyword = String(filters.keyword || '').trim()
const parentUsers = await this.repository.listParentUsers()
const rows = await Promise.all(parentUsers.map(async (user) => ({
user,
points: await this.repository.getPointAccount(user.id),
})))
const filtered = rows.filter((item) => {
const matchKeyword = !keyword || [item.user.name, item.user.phone, item.user.id, item.user.child?.name].some((value) => String(value || '').includes(keyword))
const matchStatus = !filters.pointStatus
|| filters.pointStatus === 'all'
|| (filters.pointStatus === 'available' && item.points.available > 0)
|| (filters.pointStatus === 'pending' && item.points.pending > 0)
|| (filters.pointStatus === 'expiring' && item.points.expiring > 0)
|| (filters.pointStatus === 'frozen' && item.points.frozen > 0)
return matchKeyword && matchStatus
})
const overview = filtered.reduce(
(result, item) => {
result.available += item.points.available
result.pending += item.points.pending
result.frozen += item.points.frozen
result.expiring += item.points.expiring
return result
},
{ available: 0, pending: 0, frozen: 0, expiring: 0 },
)
return {
...paginate(filtered, filters),
overview: {
...overview,
parents: filtered.length,
},
}
}
async listPointTransactions(filters = {}) {
const keyword = String(filters.keyword || '').trim()
const startDate = String(filters.startDate || '').trim()
const endDate = String(filters.endDate || '').trim()
const rawTransactions = await this.repository.listPointTransactions()
const rows = await Promise.all(rawTransactions.map(async (transaction) => ({
...transaction,
userName: (await this.repository.findUserById(transaction.userId))?.name || transaction.userId,
})))
const filtered = rows.filter((item) => {
const matchUser = !filters.userId || item.userId === filters.userId
const matchStatus = !filters.status || filters.status === 'all' || item.status === filters.status
const matchDate = (!startDate || item.createdAt >= startDate) && (!endDate || item.createdAt <= endDate)
const matchKeyword = !keyword || [item.userName, item.title, item.userId].some((value) => String(value || '').includes(keyword))
return matchUser && matchStatus && matchDate && matchKeyword
})
const overview = filtered.reduce(
(result, item) => {
if (item.status === 'pending') result.pending += item.amount
if (item.status !== 'pending' && item.amount > 0) result.income += item.amount
if (item.status !== 'pending' && item.amount < 0) result.expense += Math.abs(item.amount)
return result
},
{ income: 0, expense: 0, pending: 0 },
)
return {
...paginate(filtered, filters),
overview,
expiryRule: await this.repository.getPointExpiryRule(),
expirySchedules: await this.listPointExpirySchedules(filters),
}
}
async listPointExpirySchedules(filters = {}) {
const keyword = String(filters.expiryKeyword || filters.keyword || '').trim()
const rawExpirySchedules = await this.repository.listPointExpirySchedules(filters.userId)
const rows = await Promise.all(rawExpirySchedules.map(async (item) => ({
...item,
userName: (await this.repository.findUserById(item.userId))?.name || item.userId,
})))
return rows.filter((item) => {
const matchKeyword = !keyword || [item.userName, item.source, item.userId].some((value) => String(value || '').includes(keyword))
const matchDate = (!filters.expireStart || item.expireAt >= filters.expireStart) && (!filters.expireEnd || item.expireAt <= filters.expireEnd)
return matchKeyword && matchDate
})
}
async getReferralRule(event) {
const rules = await this.repository.getReferralRules()
return rules.find((rule) => rule.event === event) || null
}
normalizeReferralRulePayload(payload = {}) {
const patch = {}
if (payload.title !== undefined) patch.title = String(payload.title).trim()
if (payload.inviter !== undefined) patch.inviter = Number(payload.inviter)
if (payload.invitee !== undefined) patch.invitee = Number(payload.invitee)
if (!patch.title) {
const error = new Error('规则名称不能为空')
error.status = 400
throw error
}
for (const key of ['inviter', 'invitee']) {
if (!Number.isFinite(patch[key]) || patch[key] < 0) {
const error = new Error('奖励积分必须是不小于 0 的数字')
error.status = 400
throw error
}
patch[key] = Math.round(patch[key])
}
return patch
}
async updateReferralRule(event, payload = {}) {
const rule = await this.repository.updateReferralRule(event, this.normalizeReferralRulePayload(payload))
if (!rule) {
const error = new Error('推荐奖励规则不存在')
error.status = 404
throw error
}
return rule
}
async listRewards(filters = {}) {
const rewards = await this.repository.listRewards(filters)
const overview = rewards.reduce(
(result, item) => {
result.total += 1
if (item.stock > 0) result.inStock += 1
if (item.stock <= 0) result.soldOut += 1
return result
},
{ total: 0, inStock: 0, soldOut: 0 },
)
return {
...paginate(rewards, filters),
overview,
}
}
async getRewardDetail(id) {
const reward = await this.repository.findReward(id)
if (!reward) {
const error = new Error('兑换权益不存在')
error.status = 404
throw error
}
const redemptions = (await this.repository.listRedemptions({ itemId: id })).slice(0, 10).map((item) => this.decorateRedemption(item))
return { reward, redemptions }
}
normalizeRewardPayload(payload = {}) {
const reward = {}
if (payload.name !== undefined) reward.name = String(payload.name).trim()
if (payload.type !== undefined) reward.type = String(payload.type).trim()
if (payload.cost !== undefined) reward.cost = Number(payload.cost)
if (payload.stock !== undefined) reward.stock = Number(payload.stock)
if (payload.description !== undefined) reward.description = String(payload.description || '').trim()
if (!reward.name) {
const error = new Error('兑换权益名称不能为空')
error.status = 400
throw error
}
if (!reward.type) reward.type = '课程权益'
if (!Number.isFinite(reward.cost) || reward.cost < 0) {
const error = new Error('兑换积分无效')
error.status = 400
throw error
}
if (!Number.isFinite(reward.stock) || reward.stock < 0) {
const error = new Error('库存数量无效')
error.status = 400
throw error
}
return reward
}
async createReward(payload = {}) {
return this.repository.addReward({
id: createId('reward'),
description: '',
...this.normalizeRewardPayload(payload),
})
}
async grant(userId, title, amount, status = 'available', reference = {}) {
const account = await this.repository.getPointAccount(userId)
const target = status === 'pending' ? 'pending' : 'available'
account[target] += Number(amount)
await this.repository.updatePointAccount(userId, account)
return this.repository.addPointTransaction({
id: createId('pt'),
userId,
title,
amount,
status,
referenceType: reference.referenceType || '',
referenceId: reference.referenceId || '',
createdAt: new Date().toISOString().slice(0, 10),
})
}
async adjust(userId, amount, title, status = 'available') {
const numericAmount = Number(amount)
if (!Number.isFinite(numericAmount) || numericAmount === 0) {
const error = new Error('积分调整数量无效')
error.status = 400
throw error
}
const user = await this.repository.findUserById(userId)
if (!user || user.role !== 'parent') {
const error = new Error('家长账号不存在')
error.status = 404
throw error
}
const target = status === 'pending' ? 'pending' : 'available'
const account = await this.repository.getPointAccount(userId)
if (account[target] + numericAmount < 0) {
const error = new Error('调整后积分不能小于 0')
error.status = 400
throw error
}
account[target] += numericAmount
await this.repository.updatePointAccount(userId, account)
return this.repository.addPointTransaction({
id: createId('pt'),
userId,
title: title || '后台手动调整',
amount: numericAmount,
status: target,
createdAt: new Date().toISOString().slice(0, 10),
})
}
async settlePending(userId, amount, title = '手动确认到账', reference = {}) {
const numericAmount = Number(amount)
if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
const error = new Error('到账积分数量无效')
error.status = 400
throw error
}
const user = await this.repository.findUserById(userId)
if (!user || user.role !== 'parent') {
const error = new Error('家长账号不存在')
error.status = 404
throw error
}
const account = await this.repository.getPointAccount(userId)
if (account.pending < numericAmount) {
const error = new Error('待到账积分不足')
error.status = 400
throw error
}
account.pending -= numericAmount
account.available += numericAmount
await this.repository.updatePointAccount(userId, account)
const pendingTransaction = await this.repository.addPointTransaction({
id: createId('pt'),
userId,
title: `${title || '手动确认到账'}(待到账转出)`,
amount: -numericAmount,
status: 'pending',
referenceType: reference.referenceType || '',
referenceId: reference.referenceId || '',
createdAt: new Date().toISOString().slice(0, 10),
})
const transaction = await this.repository.addPointTransaction({
id: createId('pt'),
userId,
title: `${title || '手动确认到账'}(已到账)`,
amount: numericAmount,
status: 'available',
referenceType: reference.referenceType || '',
referenceId: reference.referenceId || '',
createdAt: new Date().toISOString().slice(0, 10),
})
return {
account,
pendingTransaction,
transaction,
}
}
async settlePendingReference(userId, referenceType, referenceId, title) {
if (!referenceType || !referenceId) return null
const transactions = await this.repository.getPointTransactions(userId)
const amount = transactions
.filter((item) => item.status === 'pending' && item.referenceType === referenceType && item.referenceId === referenceId)
.reduce((sum, item) => sum + Number(item.amount || 0), 0)
if (amount <= 0) return null
return this.settlePending(userId, amount, title, { referenceType, referenceId })
}
async updateReward(id, payload) {
const patch = {}
if (payload.name !== undefined) patch.name = String(payload.name).trim()
if (payload.type !== undefined) patch.type = String(payload.type).trim()
if (payload.cost !== undefined) patch.cost = Number(payload.cost)
if (payload.stock !== undefined) patch.stock = Number(payload.stock)
if (payload.description !== undefined) patch.description = String(payload.description || '').trim()
if (patch.cost !== undefined && (!Number.isFinite(patch.cost) || patch.cost < 0)) {
const error = new Error('兑换积分无效')
error.status = 400
throw error
}
if (patch.stock !== undefined && (!Number.isFinite(patch.stock) || patch.stock < 0)) {
const error = new Error('库存数量无效')
error.status = 400
throw error
}
const reward = await this.repository.updateReward(id, patch)
if (!reward) {
const error = new Error('兑换权益不存在')
error.status = 404
throw error
}
return reward
}
async redeem(userId, itemId) {
const user = await this.repository.findUserById(userId)
if (!user || user.role !== 'parent') {
const error = new Error('家长账号不存在')
error.status = 404
throw error
}
const reward = await this.repository.findReward(itemId)
if (!reward) {
const error = new Error('兑换权益不存在')
error.status = 404
throw error
}
const account = await this.repository.getPointAccount(userId)
if (account.available < reward.cost) {
const error = new Error('可用积分不足')
error.status = 400
throw error
}
if (Number(reward.stock || 0) <= 0) {
const error = new Error('兑换权益库存不足')
error.status = 400
throw error
}
account.available -= reward.cost
await this.repository.updatePointAccount(userId, account)
await this.repository.updateReward(reward.id, { stock: reward.stock - 1 })
await this.repository.addPointTransaction({
id: createId('pt'),
userId,
title: `兑换${reward.name}`,
amount: -reward.cost,
status: 'available',
createdAt: new Date().toISOString().slice(0, 10),
})
return this.repository.addRedemption({
id: createId('redeem'),
userId,
itemId,
costPoints: reward.cost,
status: 'pending_review',
voucherStatus: 'not_issued',
createdAt: toDateTime(),
})
}
async listRedemptions(filters = {}) {
const list = await this.repository.listRedemptions(filters)
const overviewFilters = {
...filters,
status: 'all',
voucherStatus: 'all',
}
const overviewList = await this.repository.listRedemptions(overviewFilters)
const overview = overviewList.reduce(
(result, item) => {
result.total += 1
result[item.status] = (result[item.status] || 0) + 1
if (item.voucherStatus === 'issued') result.issuedVouchers += 1
return result
},
{ total: 0, pending_review: 0, approved: 0, rejected: 0, used: 0, expired: 0, issuedVouchers: 0 },
)
return {
...paginate(list.map((item) => this.decorateRedemption(item)), filters),
overview,
statusLabels: REDEMPTION_STATUS_LABELS,
voucherStatusLabels: VOUCHER_STATUS_LABELS,
}
}
decorateRedemption(order) {
return {
...order,
statusLabel: REDEMPTION_STATUS_LABELS[order.status] || order.status,
voucherStatusLabel: VOUCHER_STATUS_LABELS[order.voucherStatus] || order.voucherStatus,
}
}
createVoucherCode() {
const time = Date.now().toString(36).toUpperCase()
const random = Math.random().toString(36).slice(2, 6).toUpperCase()
return `LFH-${time}-${random}`
}
async approveRedemption(id, operatorId, payload = {}) {
const order = await this.repository.findRedemptionById(id)
if (!order) {
const error = new Error('兑换申请不存在')
error.status = 404
throw error
}
if (order.status !== 'pending_review') {
const error = new Error('只有待审核申请可以通过')
error.status = 400
throw error
}
let voucherCode = this.createVoucherCode()
for (let i = 0; i < 5; i += 1) {
const exists = await this.repository.findRedemptionByVoucherCode(voucherCode)
if (!exists) break
voucherCode = this.createVoucherCode()
}
const expireAt = payload.expireAt ? String(payload.expireAt) : addDays(Number(payload.validDays || 30))
return this.decorateRedemption(await this.repository.updateRedemption(id, {
status: 'approved',
voucherCode,
voucherStatus: 'issued',
reviewedBy: operatorId || '',
reviewedAt: toDateTime(),
expireAt,
}))
}
async rejectRedemption(id, operatorId, payload = {}) {
const order = await this.repository.findRedemptionById(id)
if (!order) {
const error = new Error('兑换申请不存在')
error.status = 404
throw error
}
if (!['pending_review', 'approved'].includes(order.status) || order.voucherStatus === 'used') {
const error = new Error('当前状态不能驳回')
error.status = 400
throw error
}
const account = await this.repository.getPointAccount(order.userId)
const refund = Number(order.costPoints || order.reward?.cost || 0)
if (refund > 0) {
account.available += refund
await this.repository.updatePointAccount(order.userId, account)
await this.repository.addPointTransaction({
id: createId('pt'),
userId: order.userId,
title: `兑换驳回退还${order.reward?.name || '学习权益'}`,
amount: refund,
status: 'available',
createdAt: new Date().toISOString().slice(0, 10),
})
}
return this.decorateRedemption(await this.repository.updateRedemption(id, {
status: 'rejected',
voucherStatus: 'void',
reviewedBy: operatorId || '',
reviewedAt: toDateTime(),
rejectedReason: String(payload.reason || '审核未通过').trim(),
}))
}
async verifyVoucher(payload = {}) {
const id = String(payload.id || '').trim()
const code = String(payload.code || '').trim().toUpperCase()
const order = id ? await this.repository.findRedemptionById(id) : await this.repository.findRedemptionByVoucherCode(code)
if (!order) {
const error = new Error('券码不存在')
error.status = 404
throw error
}
if (order.status !== 'approved' || order.voucherStatus !== 'issued') {
const error = new Error('当前券码不能核销')
error.status = 400
throw error
}
if (order.expireAt && new Date(order.expireAt.replace(' ', 'T')).getTime() < Date.now()) {
await this.repository.updateRedemption(order.id, { status: 'expired', voucherStatus: 'expired' })
const error = new Error('券码已过期')
error.status = 400
throw error
}
return this.decorateRedemption(await this.repository.updateRedemption(order.id, {
status: 'used',
voucherStatus: 'used',
usedAt: toDateTime(),
usedBy: String(payload.operatorId || '').trim(),
verifyNote: String(payload.note || '现场核销').trim(),
}))
}
}
module.exports = { PointService }

@ -0,0 +1,254 @@
const { createId } = require('../utils/ids')
const { env } = require('../config/env')
const { createSessionToken } = require('../utils/sessionToken')
let wechatTokenCache = {
token: '',
expiresAt: 0,
}
function readPagination(filters = {}) {
const pageNum = Math.max(1, Number(filters.pageNum || filters.page || 1))
const pageSize = Math.min(100, Math.max(1, Number(filters.pageSize || filters.limit || 10)))
return { pageNum, pageSize }
}
function paginate(list = [], filters = {}) {
const { pageNum, pageSize } = readPagination(filters)
const start = (pageNum - 1) * pageSize
return {
list: list.slice(start, start + pageSize),
total: list.length,
pageNum,
pageSize,
}
}
class ReferralService {
constructor(repository, pointService, smsService) {
this.repository = repository
this.pointService = pointService
this.smsService = smsService
}
async getCenter(inviterId, filters = {}) {
if (!inviterId) {
const error = new Error('缺少推荐人')
error.status = 400
throw error
}
const records = await this.repository.getReferralsByInviter(inviterId)
const keyword = String(filters.keyword || '').trim()
const status = String(filters.status || '').trim()
const filteredRecords = records.filter((item) => {
const matchKeyword = !keyword || [item.name, item.phone, item.childGrade, item.channel, item.status].some((value) => String(value || '').includes(keyword))
const matchStatus = !status || status === 'all' || item.status === status
return matchKeyword && matchStatus
})
return {
stats: this.getStats(records),
rules: (await this.repository.getReferralRules()).map(({ title, inviter, invitee }) => ({ title, inviter, invitee })),
records: String(filters.paged || '') === '1' ? paginate(filteredRecords, filters) : filteredRecords,
share: await this.createShare('默认', inviterId),
}
}
async createShare(channel, inviterId) {
if (!inviterId) {
const error = new Error('缺少推荐人')
error.status = 400
throw error
}
const inviter = await this.repository.findUserById(inviterId)
if (!inviter) {
const error = new Error('推荐人不存在')
error.status = 404
throw error
}
return {
title: `${inviter.name}邀请你领取立方和教育试听积分`,
channel,
path: `/pages/login/index?inviteCode=${inviter.inviterCode}`,
posterText: '扫码领取试听积分',
}
}
async createMiniProgramCode(inviterId) {
if (!inviterId) {
const error = new Error('缺少推荐人')
error.status = 400
throw error
}
const inviter = await this.repository.findUserById(inviterId)
if (!inviter) {
const error = new Error('推荐人不存在')
error.status = 404
throw error
}
if (!env.wechat.appId || !env.wechat.appSecret) {
const error = new Error('微信小程序码尚未配置 AppID 和 AppSecret')
error.status = 501
throw error
}
const accessToken = await this.getWechatAccessToken()
const response = await fetch(`https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=${accessToken}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
scene: `invite=${inviter.inviterCode}`,
page: 'pages/login/index',
check_path: false,
env_version: env.wechat.envVersion || 'release',
width: 430,
}),
})
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
const data = await response.json()
const error = new Error(data.errmsg || '微信小程序码生成失败')
error.status = 400
throw error
}
if (!response.ok) {
const error = new Error('微信小程序码生成失败')
error.status = 400
throw error
}
const buffer = Buffer.from(await response.arrayBuffer())
return {
inviterCode: inviter.inviterCode,
path: `/pages/login/index?inviteCode=${inviter.inviterCode}`,
image: `data:image/png;base64,${buffer.toString('base64')}`,
}
}
async getWechatAccessToken() {
if (wechatTokenCache.token && Date.now() < wechatTokenCache.expiresAt) return wechatTokenCache.token
const params = new URLSearchParams({
grant_type: 'client_credential',
appid: env.wechat.appId,
secret: env.wechat.appSecret,
})
const response = await fetch(`https://api.weixin.qq.com/cgi-bin/token?${params.toString()}`)
const data = await response.json()
if (!response.ok || data.errcode || !data.access_token) {
const error = new Error(data.errmsg || '微信 access_token 获取失败')
error.status = 400
throw error
}
wechatTokenCache = {
token: data.access_token,
expiresAt: Date.now() + Math.max(60, Number(data.expires_in || 7200) - 300) * 1000,
}
return wechatTokenCache.token
}
async bindReferral(payload) {
const inviterCode = String(payload.inviterCode || '').trim().toUpperCase()
const parentName = String(payload.parentName || '').trim()
const phone = String(payload.phone || '').trim()
const code = String(payload.code || '').trim()
const childName = String(payload.childName || '').trim()
const grade = String(payload.grade || '').trim()
if (!parentName || !/^1\d{10}$/.test(phone)) {
const error = new Error('家长姓名和正确手机号必填')
error.status = 400
throw error
}
if (!this.smsService) {
const error = new Error('短信服务未启用')
error.status = 501
throw error
}
this.smsService.verifyCode(phone, code, 'referral_register')
const inviter = await this.repository.findUserByInviteCode(inviterCode)
if (!inviter) {
const error = new Error('推荐码无效')
error.status = 400
throw error
}
const existed = await this.repository.findUserByLoginAccount(phone)
if (existed) {
const error = new Error('该手机号已注册,不能重复领取新客奖励')
error.status = 409
throw error
}
const invitee = await this.repository.addUser({
id: createId('parent'),
username: phone,
passwordHash: null,
role: 'parent',
roleName: '家长',
name: parentName,
phone,
status: 'active',
inviterCode: `LFH-${Math.random().toString(36).slice(2, 7).toUpperCase()}`,
child: { id: createId('child'), name: childName || '未填写', grade: grade || '待确认', focus: '待测评' },
})
const rule = await this.pointService.getReferralRule('registered') || { inviter: 20, invitee: 20 }
const referral = await this.repository.addReferral({
id: createId('rel'),
inviterId: inviter.id,
inviteeId: invitee.id,
name: parentName,
phone,
childGrade: grade || '待确认',
status: '已注册',
reward: rule.inviter,
channel: 'H5/小程序',
})
await this.repository.addLead({
id: createId('lead'),
referralId: referral.id,
inviteeId: invitee.id,
parent: parentName,
source: inviter.name,
stage: '已注册',
advisor: '未分配',
risk: '正常',
channel: referral.channel,
})
if (rule.inviter > 0) await this.pointService.grant(inviter.id, `${parentName}完成注册`, rule.inviter)
if (rule.invitee > 0) await this.pointService.grant(invitee.id, '新家长注册奖励', rule.invitee)
const safeInvitee = await this.repository.withPermissions(invitee)
return {
referral,
invitee: {
...safeInvitee,
sessionToken: createSessionToken(safeInvitee.id),
},
}
}
getStats(records) {
const stageRank = {
待注册: 0,
已注册: 1,
已预约: 2,
已到课: 3,
已报名: 4,
}
const reached = (status) => records.filter((item) => (stageRank[item.status] || 0) >= stageRank[status]).length
const purchased = reached('已报名')
return {
invited: records.length,
registered: reached('已注册'),
booked: reached('已预约'),
attended: reached('已到课'),
purchased,
conversion: records.length ? Math.round((purchased / records.length) * 100) : 0,
}
}
}
module.exports = { ReferralService }

@ -0,0 +1,179 @@
const crypto = require('crypto')
const { env } = require('../config/env')
const VALID_PURPOSES = ['login', 'bind_wechat', 'update_phone', 'referral_register']
function nowSeconds() {
return Math.floor(Date.now() / 1000)
}
function toAliyunTimestamp(date = new Date()) {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
}
function percentEncode(value) {
return encodeURIComponent(String(value))
.replace(/\+/g, '%20')
.replace(/\*/g, '%2A')
.replace(/%7E/g, '~')
}
function hasRealConfig(value) {
const text = String(value || '').trim()
return Boolean(text && !text.startsWith('your_') && text !== 'SMS_000000000')
}
class SmsService {
constructor(options = env.sms) {
this.options = options
this.codes = new Map()
}
normalizePhone(phone) {
return String(phone || '').trim()
}
assertPhone(phone) {
if (!/^1\d{10}$/.test(phone)) {
const error = new Error('手机号格式不正确')
error.status = 400
throw error
}
}
normalizePurpose(purpose) {
const value = String(purpose || 'login').trim()
if (!VALID_PURPOSES.includes(value)) {
const error = new Error('验证码用途无效')
error.status = 400
throw error
}
return value
}
cacheKey(phone, purpose) {
return `${purpose}:${phone}`
}
createCode() {
const length = Math.max(4, Math.min(8, Number(this.options.codeLength || 6)))
const min = 10 ** (length - 1)
const max = (10 ** length) - 1
return String(crypto.randomInt(min, max + 1))
}
async sendVerificationCode(payload = {}) {
const phone = this.normalizePhone(payload.phone)
const purpose = this.normalizePurpose(payload.purpose)
this.assertPhone(phone)
const key = this.cacheKey(phone, purpose)
const existed = this.codes.get(key)
const current = nowSeconds()
const cooldown = Math.max(0, Number(this.options.codeCooldownSeconds || 60))
if (existed && existed.sentAt + cooldown > current) {
const error = new Error(`验证码发送过于频繁,请 ${existed.sentAt + cooldown - current} 秒后再试`)
error.status = 429
throw error
}
const code = this.createCode()
if (this.shouldUseAliyun()) {
await this.sendAliyunSms(phone, code)
this.codes.set(key, {
code,
sentAt: current,
expiresAt: current + Math.max(60, Number(this.options.codeTtlSeconds || 300)),
})
return {
phone,
purpose,
expiresIn: Number(this.options.codeTtlSeconds || 300),
cooldown,
message: '验证码已发送',
}
}
if (this.options.allowTestCode) {
return {
phone,
purpose,
expiresIn: Number(this.options.codeTtlSeconds || 300),
cooldown,
message: '短信未配置,测试验证码可用',
}
}
const error = new Error('短信服务未配置')
error.status = 501
throw error
}
verifyCode(phoneValue, codeValue, purposeValue = 'login') {
const phone = this.normalizePhone(phoneValue)
const code = String(codeValue || '').trim()
const purpose = this.normalizePurpose(purposeValue)
this.assertPhone(phone)
if (this.options.allowTestCode && code === String(this.options.testCode || '111111')) return true
const key = this.cacheKey(phone, purpose)
const record = this.codes.get(key)
if (!record || record.expiresAt < nowSeconds() || record.code !== code) {
const error = new Error('验证码错误')
error.status = 401
throw error
}
this.codes.delete(key)
return true
}
shouldUseAliyun() {
const aliyun = this.options.aliyun || {}
return String(this.options.provider || 'aliyun') === 'aliyun'
&& hasRealConfig(aliyun.accessKeyId)
&& hasRealConfig(aliyun.accessKeySecret)
&& hasRealConfig(aliyun.signName)
&& hasRealConfig(aliyun.templateCode)
}
async sendAliyunSms(phone, code) {
const aliyun = this.options.aliyun || {}
const params = {
AccessKeyId: aliyun.accessKeyId,
Action: 'SendSms',
Format: 'JSON',
PhoneNumbers: phone,
RegionId: 'cn-hangzhou',
SignName: aliyun.signName,
SignatureMethod: 'HMAC-SHA1',
SignatureNonce: crypto.randomUUID(),
SignatureVersion: '1.0',
TemplateCode: aliyun.templateCode,
TemplateParam: JSON.stringify({ [aliyun.templateParamKey || 'code']: code }),
Timestamp: toAliyunTimestamp(),
Version: '2017-05-25',
}
const canonicalQuery = Object.keys(params)
.sort()
.map((key) => `${percentEncode(key)}=${percentEncode(params[key])}`)
.join('&')
const stringToSign = `GET&%2F&${percentEncode(canonicalQuery)}`
const signature = crypto
.createHmac('sha1', `${aliyun.accessKeySecret}&`)
.update(stringToSign)
.digest('base64')
const url = `https://${aliyun.endpoint || 'dysmsapi.aliyuncs.com'}/?Signature=${percentEncode(signature)}&${canonicalQuery}`
const response = await fetch(url, { method: 'GET' })
const data = await response.json().catch(() => ({}))
if (!response.ok || data.Code !== 'OK') {
const error = new Error(data.Message || '短信发送失败')
error.status = 502
error.providerCode = data.Code
throw error
}
return data
}
}
module.exports = { SmsService }

@ -0,0 +1,5 @@
function createId(prefix) {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
}
module.exports = { createId }

@ -0,0 +1,14 @@
const crypto = require('crypto')
function hashPassword(password) {
return crypto.createHash('sha256').update(String(password || '')).digest('hex')
}
function verifyPassword(password, passwordHash) {
return Boolean(passwordHash) && hashPassword(password) === passwordHash
}
module.exports = {
hashPassword,
verifyPassword,
}

@ -0,0 +1,38 @@
const crypto = require('crypto')
const { env } = require('../config/env')
function sign(value) {
return crypto
.createHmac('sha256', env.auth.sessionSecret)
.update(value)
.digest('base64url')
}
function createSessionToken(accountId) {
const issuedAt = Math.floor(Date.now() / 1000)
const payload = Buffer.from(JSON.stringify({
sub: accountId,
iat: issuedAt,
exp: issuedAt + Math.max(1, Number(env.auth.sessionTtlHours || 168)) * 60 * 60,
})).toString('base64url')
return `${payload}.${sign(payload)}`
}
function verifySessionToken(token) {
const [payload, signature, extra] = String(token || '').trim().split('.')
if (!payload || !signature || extra) return null
const expected = sign(payload)
const actualBuffer = Buffer.from(signature)
const expectedBuffer = Buffer.from(expected)
if (actualBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(actualBuffer, expectedBuffer)) return null
try {
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
if (!data.sub || !data.exp || Number(data.exp) <= Math.floor(Date.now() / 1000)) return null
return data
} catch (error) {
return null
}
}
module.exports = { createSessionToken, verifySessionToken }

@ -0,0 +1,85 @@
services:
mysql:
image: mysql:8.4
container_name: lifanghe-mysql
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: lifanghe_education
MYSQL_USER: lifanghe
MYSQL_PASSWORD: lifanghe123
TZ: Asia/Shanghai
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./backend/sql/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-ulifanghe", "-plifanghe123"]
interval: 10s
timeout: 5s
retries: 10
backend:
image: node:20-alpine
container_name: lifanghe-backend
working_dir: /app
depends_on:
mysql:
condition: service_healthy
environment:
NODE_ENV: development
PORT: 3100
DB_CLIENT: mysql
MYSQL_HOST: mysql
MYSQL_PORT: 3306
MYSQL_USER: lifanghe
MYSQL_PASSWORD: lifanghe123
MYSQL_DATABASE: lifanghe_education
WECHAT_APP_ID: ${WECHAT_APP_ID:-}
WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:-}
TZ: Asia/Shanghai
ports:
- "3100:3100"
volumes:
- ./backend:/app
- backend_node_modules:/app/node_modules
command: sh -c "npm install && npm start"
front-h5:
image: node:20-alpine
container_name: lifanghe-front-h5
working_dir: /app
depends_on:
- backend
environment:
VITE_API_BASE_URL: http://127.0.0.1:3100/api
TZ: Asia/Shanghai
ports:
- "5177:5177"
volumes:
- ./front:/app
- front_node_modules:/app/node_modules
command: sh -c "npm install && npm run dev:h5 -- --host 0.0.0.0 --port 5177"
pc-admin:
image: node:20-alpine
container_name: lifanghe-pc-admin
working_dir: /app
depends_on:
- backend
environment:
VITE_APP_BASE_API: http://127.0.0.1:3100/api
TZ: Asia/Shanghai
ports:
- "5174:5174"
volumes:
- ./pc-admin:/app
- pc_admin_node_modules:/app/node_modules
command: sh -c "npm install && npx vite --host 0.0.0.0 --port 5174"
volumes:
mysql_data:
backend_node_modules:
front_node_modules:
pc_admin_node_modules:

@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:3100/api
# VITE_API_BASE_URL=https://lfhedu.ruixininfo.com/api

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

13919
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,69 @@
{
"name": "uni-preset-vue",
"version": "0.0.0",
"scripts": {
"dev:custom": "uni -p",
"dev:h5": "uni",
"dev:h5:ssr": "uni --ssr",
"dev:mp-alipay": "uni -p mp-alipay",
"dev:mp-baidu": "uni -p mp-baidu",
"dev:mp-jd": "uni -p mp-jd",
"dev:mp-kuaishou": "uni -p mp-kuaishou",
"dev:mp-lark": "uni -p mp-lark",
"dev:mp-qq": "uni -p mp-qq",
"dev:mp-toutiao": "uni -p mp-toutiao",
"dev:mp-harmony": "uni -p mp-harmony",
"dev:mp-weixin": "uni -p mp-weixin",
"dev:mp-xhs": "uni -p mp-xhs",
"dev:quickapp-webview": "uni -p quickapp-webview",
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
"build:custom": "uni build -p",
"build:h5": "uni build",
"build:h5:ssr": "uni build --ssr",
"build:mp-alipay": "uni build -p mp-alipay",
"build:mp-baidu": "uni build -p mp-baidu",
"build:mp-jd": "uni build -p mp-jd",
"build:mp-kuaishou": "uni build -p mp-kuaishou",
"build:mp-lark": "uni build -p mp-lark",
"build:mp-qq": "uni build -p mp-qq",
"build:mp-toutiao": "uni build -p mp-toutiao",
"build:mp-harmony": "uni build -p mp-harmony",
"build:mp-weixin": "uni build -p mp-weixin",
"build:mp-xhs": "uni build -p mp-xhs",
"build:quickapp-webview": "uni build -p quickapp-webview",
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-5000720260410001",
"@dcloudio/uni-app-harmony": "3.0.0-5000720260410001",
"@dcloudio/uni-app-plus": "3.0.0-5000720260410001",
"@dcloudio/uni-components": "3.0.0-5000720260410001",
"@dcloudio/uni-h5": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-alipay": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-baidu": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-harmony": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-jd": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-kuaishou": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-lark": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-qq": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-toutiao": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-weixin": "3.0.0-5000720260410001",
"@dcloudio/uni-mp-xhs": "3.0.0-5000720260410001",
"@dcloudio/uni-quickapp-webview": "3.0.0-5000720260410001",
"uview-plus": "^3.4.41",
"vue": "^3.4.21",
"vue-i18n": "^9.1.9"
},
"devDependencies": {
"@dcloudio/types": "^3.4.8",
"@dcloudio/uni-automator": "3.0.0-5000720260410001",
"@dcloudio/uni-cli-shared": "3.0.0-5000720260410001",
"@dcloudio/uni-stacktracey": "3.0.0-5000720260410001",
"@dcloudio/vite-plugin-uni": "3.0.0-5000720260410001",
"@vue/runtime-core": "^3.4.21",
"sass": "^1.89.2",
"vite": "5.2.8"
}
}

@ -0,0 +1,10 @@
/// <reference types='@dcloudio/types' />
import 'vue'
declare module '@vue/runtime-core' {
type Hooks = App.AppInstance & Page.PageInstance;
interface ComponentCustomOptions extends Hooks {
}
}

@ -0,0 +1,38 @@
<script>
export default {
onLaunch() {
console.log('LiFangHe Education app launched')
},
}
</script>
<style lang="scss">
page {
min-height: 100%;
color: #17312d;
background: #f3f6ed;
font-family: "Inter", "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
}
view,
text,
button,
input,
textarea {
box-sizing: border-box;
}
button {
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
line-height: normal;
background: transparent;
}
button::after {
border: 0;
}
</style>

@ -0,0 +1,96 @@
<template>
<view class="admin-tabbar-shell">
<view class="admin-tabbar-spacer"></view>
<view class="admin-tabbar">
<view
v-for="item in tabs"
:key="item.key"
:class="['admin-tabbar__item', active === item.key && 'admin-tabbar__item--active']"
@click="go(item)"
>
<image class="admin-tabbar__icon" :src="iconSrc(item)" mode="aspectFit"></image>
<text>{{ item.label }}</text>
</view>
</view>
</view>
</template>
<script>
export default {
props: {
active: {
type: String,
default: 'home',
},
},
data() {
return {
tabs: [
{ key: 'home', label: '首页', icon: 'home', url: '/pages/admin/index' },
{ key: 'leads', label: '线索', icon: 'referral', url: '/pages/admin-leads/index' },
{ key: 'courses', label: '试听', icon: 'courses', url: '/pages/admin-courses/index' },
{ key: 'points', label: '积分', icon: 'points', url: '/pages/admin-points/index' },
{ key: 'profile', label: '我的', icon: 'account', url: '/pages/admin-profile/index' },
],
}
},
methods: {
iconSrc(item) {
const suffix = this.active === item.key ? '-active' : ''
return `/static/tabbar/${item.icon}${suffix}.png`
},
go(item) {
if (this.active === item.key) return
uni.redirectTo({ url: item.url })
},
},
}
</script>
<style lang="scss" scoped>
.admin-tabbar-spacer {
height: calc(146rpx + env(safe-area-inset-bottom));
}
.admin-tabbar {
position: fixed;
right: 24rpx;
bottom: calc(20rpx + env(safe-area-inset-bottom));
left: 24rpx;
z-index: 50;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8rpx;
padding: 14rpx;
border: 1rpx solid rgba(11, 79, 70, 0.1);
border-radius: 18rpx;
background: rgba(251, 252, 247, 0.96);
box-shadow: 0 18rpx 46rpx rgba(11, 47, 42, 0.16);
}
.admin-tabbar__item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 86rpx;
border-radius: 14rpx;
color: #6f7c78;
font-size: 20rpx;
font-weight: 900;
}
.admin-tabbar__icon {
width: 42rpx;
height: 42rpx;
}
.admin-tabbar__item--active {
color: #0b4f46;
background: #dff8ee;
}
.admin-tabbar__item text {
margin-top: 6rpx;
}
</style>

@ -0,0 +1,8 @@
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://lfhedu.ruixininfo.com/api'
export const POINT_STATUS = {
available: '可用',
pending: '待发放',
frozen: '冻结',
revoked: '已撤销'
}

@ -0,0 +1,46 @@
import {
createSSRApp
} from "vue";
import App from "./App.vue";
import uviewPlus from "uview-plus";
import "uview-plus/index.scss";
import "./styles/theme.scss";
import UButton from "uview-plus/components/u-button/u-button.vue";
import UDatetimePicker from "uview-plus/components/u-datetime-picker/u-datetime-picker.vue";
import UGap from "uview-plus/components/u-gap/u-gap.vue";
import UIcon from "uview-plus/components/u-icon/u-icon.vue";
import UInput from "uview-plus/components/u-input/u-input.vue";
import ULoadingIcon from "uview-plus/components/u-loading-icon/u-loading-icon.vue";
import UOverlay from "uview-plus/components/u-overlay/u-overlay.vue";
import UPicker from "uview-plus/components/u-picker/u-picker.vue";
import UPopup from "uview-plus/components/u-popup/u-popup.vue";
import USafeBottom from "uview-plus/components/u-safe-bottom/u-safe-bottom.vue";
import UStatusBar from "uview-plus/components/u-status-bar/u-status-bar.vue";
import UTag from "uview-plus/components/u-tag/u-tag.vue";
import UToast from "uview-plus/components/u-toast/u-toast.vue";
import UToolbar from "uview-plus/components/u-toolbar/u-toolbar.vue";
import UTransition from "uview-plus/components/u-transition/u-transition.vue";
export function createApp() {
const app = createSSRApp(App);
app.use(uviewPlus);
app.component("u-button", UButton);
app.component("up-datetime-picker", UDatetimePicker);
app.component("u-gap", UGap);
app.component("u-icon", UIcon);
app.component("up-icon", UIcon);
app.component("up-input", UInput);
app.component("u-loading-icon", ULoadingIcon);
app.component("u-overlay", UOverlay);
app.component("u-picker", UPicker);
app.component("u-popup", UPopup);
app.component("u-safe-bottom", USafeBottom);
app.component("u-status-bar", UStatusBar);
app.component("u-tag", UTag);
app.component("u-toast", UToast);
app.component("u-toolbar", UToolbar);
app.component("u-transition", UTransition);
return {
app,
};
}

@ -0,0 +1,56 @@
{
"name": "",
"appid": "",
"description": "",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
/* 5+App特有相关 */
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
/* 模块配置 */
"modules": {},
/* 应用发布信息 */
"distribute": {
/* android打包配置 */
"android": {
"permissions": ["<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>", "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>", "<uses-permission android:name=\"android.permission.VIBRATE\"/>", "<uses-permission android:name=\"android.permission.READ_LOGS\"/>", "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>", "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>", "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>", "<uses-permission android:name=\"android.permission.CAMERA\"/>", "<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>", "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>", "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>", "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>", "<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>", "<uses-feature android:name=\"android.hardware.camera\"/>", "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"]
},
/* ios打包配置 */
"ios": {},
/* SDK配置 */
"sdkConfigs": {}
}
},
/* 快应用特有相关 */
"quickapp": {},
/* 小程序特有相关 */
"mp-weixin": {
"appid": "wx48e101cff8e91db9",
"setting": {
"urlCheck": false
},
"usingComponents": true
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3"
}

@ -0,0 +1,208 @@
{
"easycom": {
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue"
},
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "立方和教育",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/referral/index",
"style": {
"navigationBarTitleText": "推荐有礼",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/points/index",
"style": {
"navigationBarTitleText": "积分中心",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/courses/index",
"style": {
"navigationBarTitleText": "课程预约",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/account/index",
"style": {
"navigationBarTitleText": "我的账号",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin/index",
"style": {
"navigationBarTitleText": "增长管理台",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin-leads/index",
"style": {
"navigationBarTitleText": "线索管理",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin-points/index",
"style": {
"navigationBarTitleText": "积分管理",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin-point-records/index",
"style": {
"navigationBarTitleText": "积分流水",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin-point-accounts/index",
"style": {
"navigationBarTitleText": "积分账户",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin-courses/index",
"style": {
"navigationBarTitleText": "试听管理",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin-course-edit/index",
"style": {
"navigationBarTitleText": "维护试听课",
"navigationStyle": "custom"
}
},
{
"path": "pages/admin-appointments/index",
"style": {
"navigationBarTitleText": "预约到课",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin-profile/index",
"style": {
"navigationBarTitleText": "管理账号",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/login/index",
"style": {
"navigationBarTitleText": "账号登录",
"navigationStyle": "custom"
}
},
{
"path": "pages/campaign/index",
"style": {
"navigationBarTitleText": "成长邀请",
"navigationStyle": "custom"
}
},
{
"path": "pages/courses-list/index",
"style": {
"navigationBarTitleText": "可约课程",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/referral-records/index",
"style": {
"navigationBarTitleText": "推荐记录",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/point-rewards/index",
"style": {
"navigationBarTitleText": "兑换权益",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/point-records/index",
"style": {
"navigationBarTitleText": "积分记录",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
}
],
"tabBar": {
"color": "#6f7c78",
"selectedColor": "#0b4f46",
"backgroundColor": "#fbfcf7",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "static/tabbar/home.png",
"selectedIconPath": "static/tabbar/home-active.png"
},
{
"pagePath": "pages/referral/index",
"text": "推荐",
"iconPath": "static/tabbar/referral.png",
"selectedIconPath": "static/tabbar/referral-active.png"
},
{
"pagePath": "pages/points/index",
"text": "积分",
"iconPath": "static/tabbar/points.png",
"selectedIconPath": "static/tabbar/points-active.png"
},
{
"pagePath": "pages/courses/index",
"text": "课程",
"iconPath": "static/tabbar/courses.png",
"selectedIconPath": "static/tabbar/courses-active.png"
},
{
"pagePath": "pages/account/index",
"text": "我的",
"iconPath": "static/tabbar/account.png",
"selectedIconPath": "static/tabbar/account-active.png"
}
]
},
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "立方和教育",
"navigationBarBackgroundColor": "#0b2f2a",
"backgroundColor": "#f3f6ed"
}
}

@ -0,0 +1,594 @@
<template>
<view class="lf-page account-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">我的账号</view>
<view class="lf-nav__sub">维护个人资料和账号安全</view>
</view>
<view class="lf-pill">{{ account.roleName || '-' }}</view>
</view>
<view class="profile-card lf-card lf-card--ink">
<view class="profile-card__top">
<image v-if="account.wechatAvatar" class="profile-card__avatar-img" :src="account.wechatAvatar" mode="aspectFill" />
<view v-else class="profile-card__avatar">{{ (account.name || '-').slice(0, 1) }}</view>
<view>
<view class="profile-card__name">{{ account.name }}</view>
<view class="profile-card__phone">{{ account.phone || '未绑定手机号' }}</view>
</view>
</view>
<view v-if="isAdmin && (account.permissions || []).length" class="permission-strip">
<text v-for="permission in account.permissions || []" :key="permission">{{ permission }}</text>
</view>
</view>
<view class="lf-card action-card">
<view class="lf-row">
<view class="lf-section-title">个人资料</view>
<button class="mini-action" @click="toggleEdit">{{ editing ? '收起' : '编辑' }}</button>
</view>
<view v-if="!editing" class="profile-list">
<view class="profile-line">
<text>姓名</text>
<text>{{ account.name || '-' }}</text>
</view>
<view class="profile-line">
<text>孩子</text>
<text>{{ account.child?.name || '-' }}</text>
</view>
<view class="profile-line">
<text>年级</text>
<text>{{ account.child?.grade || '-' }}</text>
</view>
<view class="profile-line">
<text>关注点</text>
<text>{{ account.child?.focus || '-' }}</text>
</view>
<view v-if="account.role === 'parent'" class="profile-line">
<text>试听次数</text>
<text>剩余 {{ account.trialBookingRemaining ?? account.trialBookingLimit ?? 1 }} / {{ account.trialBookingLimit ?? 1 }} 次</text>
</view>
</view>
<view v-else class="edit-form">
<!-- #ifdef MP-WEIXIN -->
<button class="avatar-button" open-type="chooseAvatar" @chooseavatar="chooseAvatar">
<image v-if="form.wechatAvatar" :src="form.wechatAvatar" mode="aspectFill" />
<text v-else>选头像</text>
</button>
<!-- #endif -->
<view class="form-field">
<text>姓名</text>
<input v-model="form.name" maxlength="20" placeholder="请输入姓名" />
</view>
<view class="form-field">
<text>孩子姓名</text>
<input v-model="form.childName" maxlength="20" placeholder="请输入孩子姓名" />
</view>
<view class="form-field">
<text>孩子年级</text>
<input v-model="form.childGrade" maxlength="20" placeholder="例如:三年级" />
</view>
<view class="form-field">
<text>学习关注点</text>
<input v-model="form.childFocus" maxlength="30" placeholder="例如:数学思维 / 阅读表达" />
</view>
<button class="save-button" :disabled="saving" @click="saveProfile">{{ saving ? '保存中' : '保存资料' }}</button>
</view>
</view>
<view class="lf-card action-card">
<view class="lf-section-title">账号安全</view>
<view class="entry-row" @click="togglePasswordPanel">
<view>
<text>{{ account.hasPassword ? '修改密码' : '设置密码' }}</text>
<text>{{ account.hasPassword ? '用于账号密码登录' : '设置后可使用账号密码登录' }}</text>
</view>
<u-icon name="arrow-right" color="#0b4f46" size="22"></u-icon>
</view>
<view v-if="passwordPanel" class="security-panel">
<view v-if="account.hasPassword" class="form-field">
<text>原密码</text>
<input v-model="passwordForm.oldPassword" password maxlength="32" placeholder="请输入原密码" />
</view>
<view class="form-field">
<text>新密码</text>
<input v-model="passwordForm.password" password maxlength="32" placeholder="至少 6 位" />
</view>
<view class="form-field">
<text>确认密码</text>
<input v-model="passwordForm.confirmPassword" password maxlength="32" placeholder="请再次输入新密码" />
</view>
<button class="save-button" :disabled="securitySaving" @click="savePassword">
{{ securitySaving ? '保存中' : account.hasPassword ? '修改密码' : '设置密码' }}
</button>
</view>
<view class="entry-row" @click="togglePhonePanel">
<view>
<text>换绑手机号</text>
<text>当前:{{ account.phone || '未绑定' }}</text>
</view>
<u-icon name="arrow-right" color="#0b4f46" size="22"></u-icon>
</view>
<view v-if="phonePanel" class="security-panel">
<view class="form-field">
<text>新手机号</text>
<input v-model="phoneForm.phone" type="number" maxlength="11" placeholder="请输入新手机号" />
</view>
<view class="form-field form-field--code">
<view class="code-input">
<text>验证码</text>
<input v-model="phoneForm.code" type="number" maxlength="6" placeholder="请输入验证码" />
</view>
<button class="code-button" :disabled="phoneCountdown > 0" @click="requestPhoneCode">
{{ phoneCountdown > 0 ? `${phoneCountdown}s` : '获取验证码' }}
</button>
</view>
<button class="save-button" :disabled="securitySaving" @click="savePhone">
{{ securitySaving ? '保存中' : '确认换绑' }}
</button>
</view>
<view class="entry-row entry-row--danger" @click="confirmLogout">
<view>
<text>退出登录</text>
<text>退出后需要重新登录当前账号</text>
</view>
<u-icon name="arrow-right" color="#b54732" size="22"></u-icon>
</view>
</view>
<view v-if="isAdmin" class="lf-card action-card">
<view class="lf-section-title">管理入口</view>
<view class="entry-row" @click="go('/pages/admin/index')">
<view>
<text>移动管理台</text>
<text>线索推进、积分管理、试听课程管理</text>
</view>
<u-icon name="arrow-right" color="#0b4f46" size="22"></u-icon>
</view>
</view>
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import { sendSmsCode, updateBoundPhone, updatePassword, updateProfile, uploadAvatar } from '../../services/educationApi'
import { clearStoredAccount, getStoredAccount, isAdminAccount, refreshStoredAccount, setStoredAccount } from '../../services/accountSession'
function createProfileForm(account = {}) {
return {
name: account.name || '',
childName: account.child?.name || '',
childGrade: account.child?.grade || '',
childFocus: account.child?.focus || '',
wechatAvatar: account.wechatAvatar || '',
}
}
function createPasswordForm() {
return {
oldPassword: '',
password: '',
confirmPassword: '',
}
}
export default {
data() {
const account = getStoredAccount() || {}
return {
account,
editing: false,
saving: false,
securitySaving: false,
passwordPanel: false,
phonePanel: false,
phoneCountdown: 0,
phoneTimer: null,
form: createProfileForm(account),
passwordForm: createPasswordForm(),
phoneForm: {
phone: '',
code: '',
},
}
},
computed: {
isAdmin() {
return isAdminAccount(this.account)
},
},
async onShow() {
const account = await refreshStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.account = account
this.form = createProfileForm(account)
},
async onPullDownRefresh() {
await this.onShow()
uni.stopPullDownRefresh()
},
onUnload() {
if (this.phoneTimer) clearInterval(this.phoneTimer)
},
methods: {
toggleEdit() {
this.editing = !this.editing
if (this.editing) this.form = createProfileForm(this.account)
},
async chooseAvatar(event) {
const localPath = event.detail.avatarUrl
if (!localPath) return
const res = await uploadAvatar(localPath)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '头像上传失败', type: 'error' })
return
}
this.form.wechatAvatar = res.data?.url || res.url || localPath
this.$refs.toast.show({ message: '头像已上传', type: 'success' })
},
async saveProfile() {
if (!this.form.name.trim()) {
this.$refs.toast.show({ message: '请填写姓名', type: 'error' })
return
}
this.saving = true
try {
const res = await updateProfile(this.account.id, this.form)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '保存失败', type: 'error' })
return
}
this.applyAccount(res.data || res)
this.editing = false
this.$refs.toast.show({ message: '资料已保存', type: 'success' })
} finally {
this.saving = false
}
},
togglePasswordPanel() {
this.passwordPanel = !this.passwordPanel
this.passwordForm = createPasswordForm()
if (this.passwordPanel) this.phonePanel = false
},
togglePhonePanel() {
this.phonePanel = !this.phonePanel
this.phoneForm = { phone: '', code: '' }
if (this.phonePanel) this.passwordPanel = false
},
async requestPhoneCode() {
if (!/^1\d{10}$/.test(this.phoneForm.phone)) {
this.$refs.toast.show({ message: '请先输入正确手机号', type: 'error' })
return
}
const res = await sendSmsCode(this.phoneForm.phone, 'update_phone')
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '验证码发送失败', type: 'error' })
return
}
const data = res.data || res
this.startPhoneCountdown(data.cooldown || 60)
this.$refs.toast.show({ message: res.message || data.message || '验证码已发送', type: 'success' })
},
startPhoneCountdown(seconds = 60) {
if (this.phoneTimer) clearInterval(this.phoneTimer)
this.phoneCountdown = Number(seconds || 60)
this.phoneTimer = setInterval(() => {
this.phoneCountdown -= 1
if (this.phoneCountdown <= 0) {
clearInterval(this.phoneTimer)
this.phoneTimer = null
}
}, 1000)
},
async savePassword() {
if (this.account.hasPassword && !this.passwordForm.oldPassword) {
this.$refs.toast.show({ message: '请输入原密码', type: 'error' })
return
}
if (this.passwordForm.password.length < 6) {
this.$refs.toast.show({ message: '密码至少需要 6 位', type: 'error' })
return
}
if (this.passwordForm.password !== this.passwordForm.confirmPassword) {
this.$refs.toast.show({ message: '两次输入的密码不一致', type: 'error' })
return
}
this.securitySaving = true
try {
const res = await updatePassword(this.account.id, this.passwordForm)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '密码保存失败', type: 'error' })
return
}
this.applyAccount(res.data || res)
this.passwordPanel = false
this.passwordForm = createPasswordForm()
this.$refs.toast.show({ message: '密码已更新', type: 'success' })
} finally {
this.securitySaving = false
}
},
async savePhone() {
if (!/^1\d{10}$/.test(this.phoneForm.phone)) {
this.$refs.toast.show({ message: '请输入正确手机号', type: 'error' })
return
}
if (!this.phoneForm.code) {
this.$refs.toast.show({ message: '请输入验证码', type: 'error' })
return
}
this.securitySaving = true
try {
const res = await updateBoundPhone(this.account.id, this.phoneForm)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '换绑失败', type: 'error' })
return
}
this.applyAccount(res.data || res)
this.phonePanel = false
this.phoneForm = { phone: '', code: '' }
this.$refs.toast.show({ message: '手机号已换绑', type: 'success' })
} finally {
this.securitySaving = false
}
},
applyAccount(account) {
this.account = account
this.form = createProfileForm(account)
setStoredAccount(account)
},
confirmLogout() {
uni.showModal({
title: '确认退出登录',
content: '退出后需要重新登录当前账号,是否继续?',
confirmText: '退出',
confirmColor: '#b54732',
success: (res) => {
if (!res.confirm) return
clearStoredAccount()
uni.reLaunch({ url: '/pages/login/index' })
},
})
},
go(url) {
uni.navigateTo({ url })
},
},
}
</script>
<style lang="scss" scoped>
.profile-card {
margin-top: 18rpx;
padding: 32rpx;
}
.profile-card__top {
display: flex;
align-items: center;
gap: 22rpx;
}
.profile-card__avatar,
.profile-card__avatar-img {
width: 92rpx;
height: 92rpx;
border-radius: 24rpx;
}
.profile-card__avatar {
display: grid;
place-items: center;
color: #0b2f2a;
background: #f5c451;
font-size: 42rpx;
font-weight: 900;
}
.profile-card__name {
color: #ffffff;
font-size: 36rpx;
font-weight: 900;
}
.profile-card__phone {
margin-top: 8rpx;
color: rgba(255, 255, 255, 0.68);
font-size: 24rpx;
}
.permission-strip {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 28rpx;
}
.permission-strip text {
padding: 8rpx 14rpx;
border-radius: 999rpx;
color: #dff8ee;
background: rgba(255, 255, 255, 0.1);
font-size: 20rpx;
font-weight: 800;
}
.action-card {
margin-top: 22rpx;
padding: 28rpx;
}
.mini-action {
display: flex;
align-items: center;
justify-content: center;
min-width: 96rpx;
height: 52rpx;
padding: 0 18rpx;
border-radius: 999rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 22rpx;
font-weight: 900;
line-height: normal;
}
.profile-list {
margin-top: 18rpx;
}
.profile-line {
display: flex;
justify-content: space-between;
gap: 24rpx;
padding: 20rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.profile-line:last-child {
border-bottom: 0;
}
.profile-line text:first-child {
color: #6f7c78;
font-size: 24rpx;
}
.profile-line text:last-child {
color: #17312d;
font-size: 26rpx;
font-weight: 900;
text-align: right;
}
.edit-form,
.security-panel {
margin-top: 20rpx;
padding: 22rpx;
border: 1rpx solid #dfe9dc;
border-radius: 16rpx;
background: #fbfdf8;
}
.avatar-button {
display: grid;
place-items: center;
width: 112rpx;
height: 112rpx;
margin-bottom: 22rpx;
border-radius: 28rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 22rpx;
font-weight: 900;
overflow: hidden;
}
.avatar-button image {
width: 112rpx;
height: 112rpx;
}
.form-field {
margin-top: 18rpx;
}
.form-field:first-child {
margin-top: 0;
}
.form-field text {
display: block;
margin-bottom: 10rpx;
color: #6f7c78;
font-size: 22rpx;
font-weight: 900;
}
.form-field input {
height: 78rpx;
padding: 0 22rpx;
border: 1rpx solid #dfe9dc;
border-radius: 14rpx;
color: #17312d;
background: #f8faf5;
font-size: 26rpx;
line-height: 78rpx;
}
.form-field--code {
display: grid;
grid-template-columns: minmax(0, 1fr) 190rpx;
align-items: end;
gap: 16rpx;
}
.code-button,
.save-button {
display: flex;
align-items: center;
justify-content: center;
height: 78rpx;
border-radius: 16rpx;
font-size: 26rpx;
font-weight: 900;
line-height: normal;
}
.code-button {
color: #0b4f46;
border: 1rpx solid #b9d8d2;
background: #dff8ee;
}
.code-button[disabled] {
color: #7d8b86;
background: #eef3ec;
}
.save-button {
margin-top: 24rpx;
color: #ffffff;
background: #0b4f46;
}
.entry-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 26rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.entry-row:last-child {
border-bottom: 0;
}
.entry-row--danger text:first-child {
color: #b54732;
}
.entry-row text {
display: block;
}
.entry-row text:first-child {
color: #17312d;
font-size: 28rpx;
font-weight: 900;
}
.entry-row text:last-child {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.45;
}
</style>

@ -0,0 +1,327 @@
<template>
<view class="lf-page admin-appointments-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">预约到课</view>
<view class="lf-nav__sub">搜索预约记录,确认到课后触发积分到账</view>
</view>
<view class="lf-pill">{{ total }} 条</view>
</view>
<view v-if="!isAdmin" class="lf-card denied-card">
<view class="denied-card__title">当前账号没有管理权限</view>
<view class="lf-caption">请切换到管理账号后查看预约记录。</view>
</view>
<template v-else>
<view class="lf-card filter-card">
<view class="search-box">
<u-icon name="search" color="#6f7c78" size="22"></u-icon>
<input v-model="filters.keyword" placeholder="搜索家长、手机号、课程、校区" confirm-type="search" @confirm="refresh" />
</view>
<view class="status-row">
<button
v-for="item in statusOptions"
:key="item.value"
:class="['status-chip', filters.status === item.value && 'status-chip--active']"
@click="selectStatus(item.value)"
>
{{ item.label }}
</button>
</view>
<button class="query-button" @click="refresh">查询</button>
</view>
<view v-for="item in list" :key="item.id" class="lf-card appointment-card">
<view class="appointment-card__top">
<view class="appointment-card__main">
<view class="appointment-card__name">{{ item.user?.name || '-' }}</view>
<view class="appointment-card__meta">{{ item.user?.phone || '-' }} · {{ formatTime(item.createdAt) }}</view>
</view>
<u-tag :text="statusLabel(item.status)" plain shape="circle" color="#0b4f46" borderColor="#b9d8d2"></u-tag>
</view>
<view class="course-panel">
<view>
<text>{{ item.course?.name || '-' }}</text>
<text>{{ item.course?.campus || '-' }} · {{ item.course?.teacher || '-' }}</text>
</view>
<view>
<text>{{ item.course?.nextTime || '-' }}</text>
<text>试听时间</text>
</view>
</view>
<button v-if="item.status === 'booked'" class="attend-button" @click="confirmAttend(item)">确认到课</button>
</view>
<view class="load-state">{{ loadText }}</view>
</template>
<AdminTabbar v-if="isAdmin" active="courses" />
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import AdminTabbar from '../../components/AdminTabbar.vue'
import { attendAdminAppointment, getAdminAppointments } from '../../services/educationApi'
import { getStoredAccount, isAdminAccount } from '../../services/accountSession'
export default {
components: { AdminTabbar },
data() {
return {
account: getStoredAccount() || {},
list: [],
total: 0,
loading: false,
finished: false,
filters: {
keyword: '',
status: 'all',
pageNum: 1,
pageSize: 10,
},
statusOptions: [
{ label: '全部', value: 'all' },
{ label: '已预约', value: 'booked' },
{ label: '已到课', value: 'attended' },
{ label: '已取消', value: 'cancelled' },
],
}
},
computed: {
isAdmin() {
return isAdminAccount(this.account)
},
loadText() {
if (this.loading) return '加载中...'
if (this.finished) return this.list.length ? '没有更多了' : '暂无预约记录'
return '上拉加载更多'
},
},
onShow() {
const account = getStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.account = account
if (this.isAdmin) this.refresh()
},
async onPullDownRefresh() {
if (this.isAdmin) await this.refresh()
uni.stopPullDownRefresh()
},
onReachBottom() {
this.loadMore()
},
methods: {
selectStatus(status) {
this.filters.status = status
this.refresh()
},
async refresh() {
this.filters.pageNum = 1
this.finished = false
await this.load(true)
},
async loadMore() {
if (this.loading || this.finished || !this.isAdmin) return
this.filters.pageNum += 1
await this.load(false)
},
async load(reset) {
this.loading = true
try {
const res = await getAdminAppointments(this.filters)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '预约记录加载失败', type: 'error' })
return
}
const data = res.data || res || {}
const rows = data.list || []
this.total = data.total || 0
this.list = reset ? rows : this.list.concat(rows)
this.finished = this.list.length >= this.total
} finally {
this.loading = false
}
},
statusLabel(status) {
const map = { booked: '已预约', attended: '已到课', cancelled: '已取消' }
return map[status] || status || '-'
},
formatTime(value) {
if (!value) return '-'
return String(value).replace('T', ' ').slice(0, 16)
},
async confirmAttend(item) {
const confirmed = await this.confirmAction(
'确认到课',
`确认「${item.user?.name || item.userId}」已完成「${item.course?.name || '试听课'}」到课?确认后会处理对应待到账积分和到课奖励。`,
)
if (!confirmed) return
const res = await attendAdminAppointment(item.id)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '确认到课失败', type: 'error' })
return
}
this.$refs.toast.show({ message: '已确认到课', type: 'success' })
await this.refresh()
},
confirmAction(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '确认',
cancelText: '取消',
confirmColor: '#0b4f46',
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
},
},
}
</script>
<style lang="scss" scoped>
.admin-appointments-page {
padding-bottom: 150rpx;
}
.denied-card,
.filter-card,
.appointment-card {
margin-top: 22rpx;
padding: 28rpx;
}
.denied-card__title {
margin-bottom: 12rpx;
color: #17312d;
font-size: 34rpx;
font-weight: 900;
}
.search-box {
display: flex;
align-items: center;
gap: 12rpx;
height: 74rpx;
padding: 0 22rpx;
border-radius: 16rpx;
background: #f3f8ef;
}
.search-box input {
flex: 1;
color: #17312d;
font-size: 26rpx;
}
.status-row {
display: flex;
gap: 12rpx;
margin-top: 18rpx;
overflow-x: auto;
white-space: nowrap;
}
.status-chip {
height: 54rpx;
padding: 0 20rpx;
border: 1rpx solid #dce8dc;
border-radius: 999rpx;
color: #6f7c78;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
line-height: 54rpx;
}
.status-chip--active {
color: #0b4f46;
background: #dff8ee;
border-color: #9ae9dc;
}
.query-button,
.attend-button {
width: 100%;
height: 68rpx;
margin-top: 18rpx;
border-radius: 16rpx;
color: #ffffff;
background: #0b4f46;
font-size: 25rpx;
font-weight: 900;
line-height: 68rpx;
}
.appointment-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
}
.appointment-card__main {
min-width: 0;
}
.appointment-card__name {
color: #17312d;
font-size: 30rpx;
font-weight: 900;
}
.appointment-card__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.45;
}
.course-panel {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 14rpx;
margin-top: 22rpx;
}
.course-panel view {
min-width: 0;
padding: 18rpx;
border-radius: 14rpx;
background: #f3f8ef;
}
.course-panel text {
display: block;
}
.course-panel text:first-child {
color: #0b4f46;
font-size: 25rpx;
font-weight: 900;
line-height: 1.35;
}
.course-panel text:last-child {
margin-top: 8rpx;
color: #6f7c78;
font-size: 20rpx;
line-height: 1.4;
}
.load-state {
padding: 28rpx 0 90rpx;
color: #6f7c78;
font-size: 23rpx;
text-align: center;
}
</style>

@ -0,0 +1,514 @@
<template>
<view class="lf-page course-edit-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">{{ isEdit ? '编辑试听课' : '新增试听课' }}</view>
<view class="lf-nav__sub">维护家长端可预约的课程、席位和到课积分</view>
</view>
<button class="nav-back" @click="goBack">返回</button>
</view>
<view v-if="!isAdmin" class="lf-card denied-card">
<view class="denied-card__title">当前账号没有管理权限</view>
<view class="lf-caption">请切换到管理账号后再维护试听课。</view>
</view>
<template v-else>
<view class="lf-card edit-hero">
<view class="edit-hero__mark">{{ isEdit ? '改' : '新' }}</view>
<view>
<view class="edit-hero__title">{{ form.name || '未命名试听课' }}</view>
<view class="edit-hero__sub">
{{ form.campus || '未设置校区' }} · {{ form.teacher || '未设置老师' }} · {{ form.trialMinutes || 0 }} 分钟
</view>
</view>
</view>
<view class="lf-card form-card">
<view class="form-section-title">课程信息</view>
<view class="form-field form-field--wide">
<text>课程名称</text>
<input v-model="form.name" placeholder="例如:数学思维体验课" />
</view>
<view class="form-grid">
<view class="form-field">
<text>校区</text>
<input v-model="form.campus" placeholder="例如:南山校区" />
</view>
<view class="form-field">
<text>老师</text>
<input v-model="form.teacher" placeholder="例如:林老师" />
</view>
</view>
<view class="form-field form-field--wide">
<text>课程标签</text>
<input v-model="form.tagsText" placeholder="用逗号分隔,例如:试听,小班,可转班" />
</view>
</view>
<view class="lf-card form-card">
<view class="form-section-title">排期与权益</view>
<view class="form-field form-field--wide">
<text>试听时间</text>
<view class="datetime-trigger" @click="openNextTimePicker">
<text :class="form.nextTime ? '' : 'is-placeholder'">{{ form.nextTime || '选择试听日期和时间' }}</text>
<up-icon name="calendar" color="#0b4f46" size="20"></up-icon>
</view>
</view>
<view class="form-grid form-grid--three">
<view class="form-field">
<text>席位</text>
<view class="stepper">
<button @click="changeNumber('seats', -1)">-</button>
<input :value="form.seats" type="number" @input="onNumberInput('seats', $event)" />
<button @click="changeNumber('seats', 1)">+</button>
</view>
</view>
<view class="form-field">
<text>到课积分</text>
<input :value="form.pointsBack" type="number" @input="onNumberInput('pointsBack', $event)" />
</view>
<view class="form-field">
<text>时长</text>
<input :value="form.trialMinutes" type="number" @input="onNumberInput('trialMinutes', $event)" />
</view>
</view>
</view>
<view class="lf-card form-card">
<view class="form-section-title">上架状态</view>
<view class="status-switch">
<button :class="form.status === 'active' && 'is-active'" @click="form.status = 'active'">上架</button>
<button :class="form.status === 'paused' && 'is-active'" @click="form.status = 'paused'">暂停</button>
</view>
</view>
<view class="save-panel">
<button class="save-panel__ghost" @click="goBack">取消</button>
<button class="save-panel__primary" :disabled="saving" @click="submit">
{{ saving ? '保存中...' : (isEdit ? '保存修改' : '新增课程') }}
</button>
</view>
</template>
<AdminTabbar v-if="isAdmin" active="courses" />
<up-datetime-picker
v-model="nextTimeValue"
:show="nextTimePickerVisible"
mode="datetime"
title="选择试听时间"
:min-date="minCourseTime"
:max-date="maxCourseTime"
confirm-color="#0b4f46"
@confirm="confirmNextTime"
@cancel="closeNextTimePicker"
@close="closeNextTimePicker"
></up-datetime-picker>
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import AdminTabbar from '../../components/AdminTabbar.vue'
import { createAdminCourse, getAdminCourse, updateAdminCourse } from '../../services/educationApi'
import { getStoredAccount, isAdminAccount } from '../../services/accountSession'
function blankForm() {
return {
id: '',
name: '',
campus: '',
teacher: '',
nextTime: '',
seats: 4,
pointsBack: 100,
trialMinutes: 45,
status: 'paused',
tagsText: '试听,小班',
}
}
function padNumber(value) {
return String(value).padStart(2, '0')
}
function formatDateTime(value) {
const date = new Date(Number(value))
if (Number.isNaN(date.getTime())) return ''
return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())} ${padNumber(date.getHours())}:${padNumber(date.getMinutes())}`
}
function defaultCourseTime() {
const date = new Date(Date.now() + 24 * 60 * 60 * 1000)
date.setMinutes(0, 0, 0)
return date.getTime()
}
function parseCourseTime(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/)
if (!match) return defaultCourseTime()
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]))
return Number.isNaN(date.getTime()) ? defaultCourseTime() : date.getTime()
}
export default {
components: { AdminTabbar },
data() {
return {
account: getStoredAccount() || {},
courseId: '',
form: blankForm(),
saving: false,
nextTimePickerVisible: false,
nextTimeValue: defaultCourseTime(),
minCourseTime: new Date().setHours(0, 0, 0, 0),
maxCourseTime: new Date(new Date().getFullYear() + 2, 11, 31, 23, 59).getTime(),
}
},
computed: {
isAdmin() {
return isAdminAccount(this.account)
},
isEdit() {
return Boolean(this.courseId)
},
},
onLoad(query = {}) {
this.courseId = query.id || ''
},
onShow() {
const account = getStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.account = account
if (this.isAdmin && this.courseId) this.loadCourse()
},
methods: {
async loadCourse() {
const res = await getAdminCourse(this.courseId)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '课程加载失败', type: 'error' })
return
}
const course = res.data || res
this.form = {
id: course.id,
name: course.name || '',
campus: course.campus || '',
teacher: course.teacher || '',
nextTime: course.nextTime || '',
seats: course.seats ?? 0,
pointsBack: course.pointsBack ?? 0,
trialMinutes: course.trialMinutes ?? 45,
status: course.status || 'paused',
tagsText: (course.tags || []).join(','),
}
this.nextTimeValue = parseCourseTime(course.nextTime)
},
buildPayload() {
return {
name: String(this.form.name || '').trim(),
campus: String(this.form.campus || '').trim(),
teacher: String(this.form.teacher || '').trim(),
nextTime: String(this.form.nextTime || '').trim(),
seats: this.toNumber(this.form.seats),
pointsBack: this.toNumber(this.form.pointsBack),
trialMinutes: this.toNumber(this.form.trialMinutes),
status: this.form.status || 'paused',
tags: String(this.form.tagsText || '')
.split(/[,,]/)
.map((item) => item.trim())
.filter(Boolean),
}
},
async submit() {
const payload = this.buildPayload()
if (!payload.name) {
this.$refs.toast.show({ message: '请填写课程名称', type: 'error' })
return
}
if (!payload.nextTime) {
this.$refs.toast.show({ message: '请选择试听日期和时间', type: 'error' })
return
}
const confirmed = await this.confirmAction(
this.isEdit ? '确认保存课程' : '确认新增课程',
this.isEdit ? `保存「${payload.name}」后,家长端课程信息会同步变化。` : `新增「${payload.name}」后,可在家长端按上架状态展示。`,
)
if (!confirmed) return
this.saving = true
try {
const res = this.isEdit ? await updateAdminCourse(this.courseId, payload) : await createAdminCourse(payload)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '保存失败', type: 'error' })
return
}
this.$refs.toast.show({ message: this.isEdit ? '课程已保存' : '课程已新增', type: 'success' })
setTimeout(() => this.goBack(), 500)
} finally {
this.saving = false
}
},
changeNumber(key, delta) {
this.form[key] = Math.max(0, this.toNumber(this.form[key]) + delta)
},
onNumberInput(key, event) {
const value = event.detail?.value ?? ''
this.form[key] = value === '' ? '' : Math.max(0, Number(value))
},
openNextTimePicker() {
this.nextTimeValue = parseCourseTime(this.form.nextTime)
this.nextTimePickerVisible = true
},
confirmNextTime(event) {
this.nextTimeValue = Number(event.value)
this.form.nextTime = formatDateTime(event.value)
this.nextTimePickerVisible = false
},
closeNextTimePicker() {
this.nextTimePickerVisible = false
},
toNumber(value) {
const number = Number(value)
return Number.isFinite(number) && number >= 0 ? number : 0
},
goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
uni.redirectTo({ url: '/pages/admin-courses/index' })
},
confirmAction(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '确认',
cancelText: '取消',
confirmColor: '#0b4f46',
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
},
},
}
</script>
<style lang="scss" scoped>
.course-edit-page {
padding-bottom: 170rpx;
}
.nav-back {
min-width: 92rpx;
height: 52rpx;
padding: 0 18rpx;
border-radius: 999rpx;
color: #0b2f2a;
background: #f5c451;
font-size: 22rpx;
font-weight: 900;
line-height: 52rpx;
}
.denied-card,
.edit-hero,
.form-card {
margin-top: 22rpx;
padding: 28rpx;
}
.denied-card__title {
margin-bottom: 12rpx;
color: #17312d;
font-size: 34rpx;
font-weight: 900;
}
.edit-hero {
display: flex;
align-items: center;
gap: 22rpx;
color: #ffffff;
background:
linear-gradient(135deg, rgba(245, 196, 81, 0.24), transparent 44%),
#0b2f2a;
}
.edit-hero__mark {
display: grid;
place-items: center;
width: 82rpx;
height: 82rpx;
border-radius: 18rpx;
color: #0b2f2a;
background: #f5c451;
font-size: 30rpx;
font-weight: 900;
}
.edit-hero__title {
font-size: 34rpx;
font-weight: 900;
line-height: 1.3;
}
.edit-hero__sub {
margin-top: 8rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 22rpx;
}
.form-section-title {
margin-bottom: 22rpx;
color: #17312d;
font-size: 30rpx;
font-weight: 900;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.form-grid--three {
grid-template-columns: repeat(3, 1fr);
}
.form-field {
min-width: 0;
margin-top: 18rpx;
}
.form-field--wide:first-of-type,
.form-section-title + .form-field {
margin-top: 0;
}
.form-field text {
display: block;
margin-bottom: 10rpx;
color: #6f7c78;
font-size: 21rpx;
font-weight: 900;
}
.form-field input {
width: 100%;
height: 76rpx;
box-sizing: border-box;
padding: 0 20rpx;
border: 1rpx solid #dfe9dc;
border-radius: 14rpx;
color: #17312d;
background: #f8faf5;
font-size: 25rpx;
line-height: 76rpx;
}
.datetime-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 76rpx;
box-sizing: border-box;
padding: 0 20rpx;
border: 1rpx solid #dfe9dc;
border-radius: 14rpx;
color: #17312d;
background: #f8faf5;
font-size: 25rpx;
}
.datetime-trigger text {
min-width: 0;
margin: 0;
color: #17312d;
font-size: 25rpx;
font-weight: 500;
}
.datetime-trigger text.is-placeholder {
color: #9aa6a1;
}
.stepper {
display: grid;
grid-template-columns: 48rpx minmax(54rpx, 1fr) 48rpx;
gap: 8rpx;
}
.stepper button {
height: 76rpx;
border-radius: 14rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 28rpx;
font-weight: 900;
line-height: 76rpx;
}
.stepper input {
padding: 0 8rpx;
text-align: center;
}
.status-switch {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14rpx;
}
.status-switch button {
height: 78rpx;
border: 1rpx solid #dfe9dc;
border-radius: 16rpx;
color: #6f7c78;
background: #f8faf5;
font-size: 25rpx;
font-weight: 900;
line-height: 78rpx;
}
.status-switch button.is-active {
color: #0b4f46;
border-color: #9ae9dc;
background: #dff8ee;
}
.save-panel {
display: grid;
grid-template-columns: 0.85fr 1.15fr;
gap: 14rpx;
margin-top: 24rpx;
}
.save-panel button {
height: 78rpx;
border-radius: 18rpx;
font-size: 26rpx;
font-weight: 900;
line-height: 78rpx;
}
.save-panel__ghost {
color: #0b4f46;
background: #eef8f4;
}
.save-panel__primary {
color: #ffffff;
background: #0b4f46;
}
.save-panel__primary[disabled] {
opacity: 0.68;
}
</style>

@ -0,0 +1,566 @@
<template>
<view class="lf-page admin-courses-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">试听管理</view>
<view class="lf-nav__sub">可试听课程、名额与上架状态</view>
</view>
<view v-if="isAdmin" class="course-nav-actions">
<view class="lf-pill">{{ filteredCourses.length }} 门</view>
<button class="nav-add" @click="openCreate">新增</button>
</view>
<view v-else class="lf-pill">{{ filteredCourses.length }} 门</view>
</view>
<view v-if="!isAdmin" class="lf-card denied-card">
<view class="denied-card__title">当前账号没有管理权限</view>
<view class="lf-caption">请切换到管理账号后维护试听课程。</view>
<u-button color="#0b4f46" shape="circle" text="切换账号" @click="confirmLogout"></u-button>
</view>
<template v-else>
<view class="lf-card course-filter">
<view class="course-search">
<u-icon name="search" color="#6f7c78" size="22"></u-icon>
<input v-model="filters.keyword" placeholder="搜索课程、老师、校区" confirm-type="search" />
</view>
<view class="filter-row">
<button
v-for="status in statusOptions"
:key="status.value"
:class="['status-chip', filters.status === status.value && 'status-chip--active']"
@click="filters.status = status.value"
>
{{ status.label }}
</button>
</view>
<view class="campus-strip">
<button
v-for="campus in campusOptions"
:key="campus"
:class="['campus-chip', filters.campus === campus && 'campus-chip--active']"
@click="filters.campus = campus"
>
{{ campus }}
</button>
</view>
</view>
<view class="lf-card appointments-card">
<view class="lf-row">
<view>
<view class="lf-section-title">预约到课</view>
<view class="lf-caption">当前页仅展示最新 3 条预约</view>
</view>
<button class="ghost-button" @click="go('/pages/admin-appointments/index')">查看更多</button>
</view>
<view v-if="!previewAppointments.length" class="empty-mini">暂无预约记录</view>
<view v-for="item in previewAppointments" :key="item.id" class="appointment-row">
<view class="appointment-row__main">
<view class="appointment-row__name">{{ item.user?.name || '-' }}</view>
<view class="appointment-row__meta">{{ item.course?.name || '-' }} · {{ item.course?.nextTime || '-' }}</view>
<view class="appointment-row__sub">{{ item.user?.phone || '-' }} · {{ item.course?.campus || '-' }}</view>
</view>
<view class="appointment-row__side">
<u-tag :text="appointmentStatusLabel(item.status)" plain shape="circle" color="#0b4f46" borderColor="#b9d8d2"></u-tag>
<button v-if="item.status === 'booked'" @click="confirmAppointment(item)">到课</button>
</view>
</view>
</view>
<view v-if="!filteredCourses.length" class="lf-card empty-card">
<view class="empty-card__title">暂无匹配课程</view>
<view class="lf-caption">可以调整筛选条件,或新增一门试听课程。</view>
<button class="empty-card__button" @click="openCreate">新增试听课</button>
</view>
<view v-for="course in filteredCourses" :key="course.id" class="lf-card course-card">
<view class="course-card__top">
<view class="course-card__main">
<view class="course-card__name">{{ course.name }}</view>
<view class="course-card__meta">{{ course.campus || '未设置校区' }} · {{ course.teacher || '未设置老师' }} · {{ course.trialMinutes || 0 }} 分钟</view>
</view>
<view :class="['course-status', course.status === 'paused' && 'course-status--paused']">
{{ course.status === 'paused' ? '已暂停' : '上架中' }}
</view>
</view>
<view class="course-card__tags">
<text v-for="tag in course.tags" :key="tag">{{ tag }}</text>
<text v-if="!course.tags || !course.tags.length">未设置标签</text>
</view>
<view class="course-board">
<view class="course-board__cell">
<text>{{ course.nextTime || '待排期' }}</text>
<text>下次试听</text>
</view>
<view class="course-board__cell seat-cell">
<text>剩余席位</text>
<view class="seat-count">{{ course.seats || 0 }}</view>
</view>
<view class="course-board__cell">
<text>+{{ course.pointsBack || 0 }}</text>
<text>到课积分</text>
</view>
</view>
<view class="course-actions">
<button @click="openEdit(course)">编辑</button>
<button @click="toggleStatus(course)">{{ course.status === 'paused' ? '上架' : '暂停' }}</button>
<button class="is-danger" @click="removeCourse(course)">删除</button>
</view>
</view>
</template>
<AdminTabbar v-if="isAdmin" active="courses" />
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import AdminTabbar from '../../components/AdminTabbar.vue'
import { attendAdminAppointment, deleteAdminCourse, getAdminAppointments, getAdminCourses, updateAdminCourse } from '../../services/educationApi'
import { clearStoredAccount, getStoredAccount, isAdminAccount } from '../../services/accountSession'
export default {
components: { AdminTabbar },
data() {
return {
account: getStoredAccount() || {},
courses: [],
appointments: [],
filters: {
keyword: '',
status: '全部',
campus: '全部',
},
statusOptions: [
{ label: '全部', value: '全部' },
{ label: '上架中', value: 'active' },
{ label: '已暂停', value: 'paused' },
],
}
},
computed: {
isAdmin() {
return isAdminAccount(this.account)
},
campusOptions() {
return ['全部', ...new Set(this.courses.map((item) => item.campus).filter(Boolean))]
},
filteredCourses() {
const keyword = this.filters.keyword.trim()
return this.courses.filter((course) => {
const matchKeyword = !keyword || [course.name, course.teacher, course.campus, ...(course.tags || [])].some((value) => String(value || '').includes(keyword))
const matchStatus = this.filters.status === '全部' || course.status === this.filters.status
const matchCampus = this.filters.campus === '全部' || course.campus === this.filters.campus
return matchKeyword && matchStatus && matchCampus
})
},
previewAppointments() {
return this.appointments.slice(0, 3)
},
},
onShow() {
const account = getStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.account = account
if (this.isAdmin) this.load()
},
async onPullDownRefresh() {
if (this.isAdmin) await this.load()
uni.stopPullDownRefresh()
},
methods: {
async load() {
await Promise.all([this.loadCourses(), this.loadAppointments()])
},
async loadCourses() {
const res = await getAdminCourses()
const list = res.data || res || this.courses
this.courses = list.map((item) => ({ ...item, tags: [...(item.tags || [])] }))
},
async loadAppointments() {
const res = await getAdminAppointments({ pageNum: 1, pageSize: 3, status: 'all' })
const data = res.data || res || {}
this.appointments = data.list || []
},
appointmentStatusLabel(status) {
const map = { booked: '已预约', attended: '已到课', cancelled: '已取消' }
return map[status] || status || '-'
},
async confirmAppointment(item) {
const confirmed = await this.confirmAction(
'确认到课',
`确认「${item.user?.name || item.userId}」已完成「${item.course?.name || '试听课'}」到课?确认后会处理对应待到账积分和到课奖励。`,
)
if (!confirmed) return
const res = await attendAdminAppointment(item.id)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '确认到课失败', type: 'error' })
return
}
this.$refs.toast.show({ message: '已确认到课', type: 'success' })
await this.loadAppointments()
},
openCreate() {
uni.navigateTo({ url: '/pages/admin-course-edit/index' })
},
openEdit(course) {
uni.navigateTo({ url: `/pages/admin-course-edit/index?id=${encodeURIComponent(course.id)}` })
},
async toggleStatus(course) {
const nextStatus = course.status === 'paused' ? 'active' : 'paused'
const label = nextStatus === 'active' ? '上架' : '暂停'
const confirmed = await this.confirmAction(`确认${label}课程`, `${label}「${course.name}」后,家长端可预约课程会同步变化。`)
if (!confirmed) return
await updateAdminCourse(course.id, { status: nextStatus })
course.status = nextStatus
this.$refs.toast.show({ message: `课程已${label}`, type: 'success' })
},
async removeCourse(course) {
const confirmed = await this.confirmAction('确认删除课程', `仅可删除没有预约记录的「${course.name}」。已有预约时请暂停课程。`)
if (!confirmed) return
await deleteAdminCourse(course.id)
this.courses = this.courses.filter((item) => item.id !== course.id)
this.$refs.toast.show({ message: '课程已删除', type: 'success' })
},
confirmLogout() {
uni.showModal({
title: '确认退出登录',
content: '退出后需要重新登录当前账号,是否继续?',
confirmText: '退出',
confirmColor: '#b54732',
success: (res) => {
if (!res.confirm) return
clearStoredAccount()
uni.reLaunch({ url: '/pages/login/index' })
},
})
},
go(url) {
uni.navigateTo({ url })
},
confirmAction(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '确认',
cancelText: '取消',
confirmColor: '#0b4f46',
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
},
},
}
</script>
<style lang="scss" scoped>
.admin-courses-page {
padding-bottom: 150rpx;
}
.course-nav-actions {
display: flex;
align-items: center;
gap: 12rpx;
}
.nav-add {
min-width: 92rpx;
height: 52rpx;
padding: 0 18rpx;
border-radius: 999rpx;
color: #0b2f2a;
background: #f5c451;
font-size: 22rpx;
font-weight: 900;
line-height: 52rpx;
}
.denied-card,
.course-filter,
.appointments-card,
.course-card,
.empty-card {
margin-top: 22rpx;
padding: 28rpx;
}
.denied-card__title,
.empty-card__title {
margin-bottom: 12rpx;
color: #17312d;
font-size: 34rpx;
font-weight: 900;
}
.empty-card__button {
width: 220rpx;
height: 64rpx;
margin-top: 22rpx;
border-radius: 999rpx;
color: #ffffff;
background: #0b4f46;
font-size: 24rpx;
font-weight: 900;
line-height: 64rpx;
}
.ghost-button {
min-width: 132rpx;
height: 52rpx;
border: 1rpx solid #b9d8d2;
border-radius: 999rpx;
color: #0b4f46;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
line-height: 52rpx;
}
.empty-mini {
padding: 28rpx 0 6rpx;
color: #6f7c78;
font-size: 23rpx;
text-align: center;
}
.appointment-row {
display: flex;
justify-content: space-between;
gap: 18rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.appointment-row:last-child {
border-bottom: 0;
}
.appointment-row__main {
min-width: 0;
}
.appointment-row__name {
color: #17312d;
font-size: 28rpx;
font-weight: 900;
}
.appointment-row__meta,
.appointment-row__sub {
margin-top: 8rpx;
color: #6f7c78;
font-size: 21rpx;
line-height: 1.45;
}
.appointment-row__side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12rpx;
flex: 0 0 auto;
}
.appointment-row__side button {
min-width: 86rpx;
height: 50rpx;
border-radius: 999rpx;
color: #ffffff;
background: #0b4f46;
font-size: 22rpx;
font-weight: 900;
line-height: 50rpx;
}
.course-search {
display: flex;
align-items: center;
gap: 12rpx;
height: 74rpx;
padding: 0 22rpx;
border-radius: 18rpx;
background: #f3f8ef;
}
.course-search input {
flex: 1;
color: #17312d;
font-size: 26rpx;
}
.filter-row,
.campus-strip {
display: flex;
gap: 12rpx;
margin-top: 18rpx;
overflow-x: auto;
white-space: nowrap;
}
.status-chip,
.campus-chip {
height: 54rpx;
padding: 0 20rpx;
border: 1rpx solid #dce8dc;
border-radius: 999rpx;
color: #6f7c78;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
line-height: 54rpx;
white-space: nowrap;
}
.status-chip--active,
.campus-chip--active {
color: #0b4f46;
background: #dff8ee;
border-color: #9ae9dc;
}
.course-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
}
.course-card__main {
min-width: 0;
}
.course-card__name {
color: #17312d;
font-size: 31rpx;
font-weight: 900;
line-height: 1.35;
}
.course-card__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.45;
}
.course-status {
flex: 0 0 auto;
height: 44rpx;
padding: 0 16rpx;
border-radius: 999rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 20rpx;
font-weight: 900;
line-height: 44rpx;
white-space: nowrap;
}
.course-status--paused {
color: #9a3d20;
background: #ffe0d6;
}
.course-card__tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.course-card__tags text {
padding: 8rpx 14rpx;
border-radius: 999rpx;
color: #0b4f46;
background: #eef8f4;
font-size: 20rpx;
font-weight: 800;
}
.course-board {
display: grid;
grid-template-columns: 1.18fr 1.18fr 0.86fr;
gap: 12rpx;
margin-top: 22rpx;
}
.course-board__cell {
min-width: 0;
padding: 18rpx;
border-radius: 14rpx;
background: #f3f8ef;
}
.course-board__cell text {
display: block;
}
.course-board__cell text:first-child {
color: #0b4f46;
font-size: 25rpx;
font-weight: 900;
line-height: 1.35;
}
.course-board__cell text:last-child {
margin-top: 8rpx;
color: #6f7c78;
font-size: 20rpx;
font-weight: 800;
}
.seat-cell {
padding: 14rpx;
}
.seat-cell > text {
color: #6f7c78 !important;
font-size: 20rpx !important;
}
.seat-count {
margin-top: 10rpx;
color: #0b4f46;
font-size: 32rpx;
font-weight: 900;
}
.course-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
margin-top: 22rpx;
}
.course-actions button {
height: 66rpx;
border-radius: 16rpx;
color: #0b4f46;
background: #eef8f4;
font-size: 24rpx;
font-weight: 900;
line-height: 66rpx;
white-space: nowrap;
}
.course-actions .is-primary {
color: #ffffff;
background: #0b4f46;
}
.course-actions .is-danger {
color: #9a3d20;
background: #ffe0d6;
}
</style>

@ -0,0 +1,509 @@
<template>
<view class="lf-page admin-leads-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">线索管理</view>
<view class="lf-nav__sub">筛选来源和阶段,推进推荐转化</view>
</view>
<view class="lf-pill">{{ filteredLeads.length }} 条</view>
</view>
<view v-if="!isAdmin" class="lf-card denied-card">
<view class="denied-card__title">当前账号没有管理权限</view>
<view class="lf-caption">请切换到管理账号后查看线索。</view>
<u-button color="#0b4f46" shape="circle" text="切换账号" @click="confirmLogout"></u-button>
</view>
<template v-else>
<view class="lf-card filter-console">
<view class="filter-console__top">
<view class="search-box">
<u-icon name="search" color="#6f7c78" size="22"></u-icon>
<input v-model="filters.keyword" placeholder="搜索家长、来源、渠道" confirm-type="search" />
</view>
</view>
<scroll-view scroll-x class="stage-rail">
<button
v-for="stage in stageOptions"
:key="stage"
:class="['stage-pill', filters.stage === stage && 'stage-pill--active']"
@click="filters.stage = stage"
>
<text>{{ stage }}</text>
<text>{{ stageCount(stage) }}</text>
</button>
</scroll-view>
<view class="filter-console__foot">
<text>{{ filterSummary }}</text>
<button v-if="activeFilterCount || filters.keyword" @click="resetFilters">重置</button>
</view>
</view>
<view class="lead-list">
<view v-for="lead in filteredLeads" :key="lead.id" class="lf-card lead-card">
<view class="lead-card__head">
<view>
<view class="lead-card__name">{{ lead.parent }}</view>
<view class="lead-card__meta">{{ lead.channel || lead.source }} · 来源 {{ lead.source }}</view>
</view>
<u-tag :text="lead.stage" plain shape="circle" color="#0b4f46" borderColor="#b9d8d2"></u-tag>
</view>
<view class="lead-card__body">
<view>
<text>来源</text>
<text>{{ lead.channel || lead.source }}</text>
</view>
</view>
<button :class="['advance-button', lead.stage === '已报名' && 'advance-button--disabled', nextStage(lead) === '已报名' && 'advance-button--purchase']" @click="advance(lead)">
{{ actionLabel(lead) }}
</button>
</view>
</view>
</template>
<AdminTabbar v-if="isAdmin" active="leads" />
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import AdminTabbar from '../../components/AdminTabbar.vue'
import { confirmLeadPurchase, getAdminLeads, updateLeadStage } from '../../services/educationApi'
import { clearStoredAccount, getStoredAccount, isAdminAccount } from '../../services/accountSession'
const stageFlow = ['待跟进', '已注册', '已预约', '已到课', '已报名']
export default {
components: { AdminTabbar },
data() {
return {
account: getStoredAccount() || {},
leads: [],
filters: {
keyword: '',
stage: '全部',
},
stageOptions: ['全部', ...stageFlow],
}
},
computed: {
isAdmin() {
return isAdminAccount(this.account)
},
activeFilterCount() {
return [this.filters.stage].filter((item) => item !== '全部').length
},
filterSummary() {
const parts = []
if (this.filters.stage !== '全部') parts.push(this.filters.stage)
return parts.length ? `已筛选:${parts.join(' / ')}` : '展示全部线索,可按阶段快速切换'
},
filteredLeads() {
const keyword = this.filters.keyword.trim()
return this.leads.filter((lead) => {
const matchKeyword = !keyword || [lead.parent, lead.phone, lead.childGrade, lead.source, lead.channel].some((value) => String(value || '').includes(keyword))
const matchStage = this.filters.stage === '全部' || lead.stage === this.filters.stage
return matchKeyword && matchStage
})
},
},
onShow() {
const account = getStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.account = account
if (this.isAdmin) this.load()
},
async onPullDownRefresh() {
if (this.isAdmin) await this.load()
uni.stopPullDownRefresh()
},
methods: {
async load() {
const res = await getAdminLeads()
const data = res.data || res
this.leads = data.list || data || this.leads
},
stageCount(stage) {
if (stage === '全部') return this.leads.length
return this.leads.filter((lead) => lead.stage === stage).length
},
resetFilters() {
this.filters.keyword = ''
this.filters.stage = '全部'
},
nextStage(lead) {
const index = stageFlow.indexOf(lead.stage)
return stageFlow[Math.min(index + 1, stageFlow.length - 1)] || '已注册'
},
actionLabel(lead) {
if (lead.stage === '已报名') return '已完成报名'
return this.nextStage(lead) === '已报名' ? '确认报名正价课' : '推进到下一阶段'
},
async advance(lead) {
const next = this.nextStage(lead)
if (lead.stage === next) {
this.$refs.toast.show({ message: '线索已到最终阶段', type: 'default' })
return
}
const isPurchase = next === '已报名'
const confirmed = await this.confirmAction(
isPurchase ? '确认报名正价课' : '确认推进线索',
isPurchase
? `确认「${lead.parent}」已报名正价课?确认后会发放报名阶段推荐奖励,并更新推荐漏斗。`
: `将「${lead.parent}」从「${lead.stage}」推进到「${next}」,会影响转化统计。`,
)
if (!confirmed) return
const res = isPurchase ? await confirmLeadPurchase(lead.id) : await updateLeadStage(lead.id, next)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '线索更新失败', type: 'error' })
return
}
lead.stage = next
this.$refs.toast.show({ message: isPurchase ? '已确认报名并发放奖励' : `已推进到${next}`, type: 'success' })
},
confirmLogout() {
uni.showModal({
title: '确认退出登录',
content: '退出后需要重新登录当前账号,是否继续?',
confirmText: '退出',
confirmColor: '#b54732',
success: (res) => {
if (!res.confirm) return
clearStoredAccount()
uni.reLaunch({ url: '/pages/login/index' })
},
})
},
go(url) {
uni.navigateTo({ url })
},
confirmAction(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '确认',
cancelText: '取消',
confirmColor: '#0b4f46',
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
},
},
}
</script>
<style lang="scss" scoped>
.admin-leads-page {
padding-bottom: 150rpx;
}
.denied-card,
.filter-console {
margin-top: 22rpx;
padding: 28rpx;
}
.denied-card__title {
margin-bottom: 12rpx;
color: #17312d;
font-size: 34rpx;
font-weight: 900;
}
.filter-console {
overflow: hidden;
}
.filter-console__top {
display: flex;
align-items: center;
gap: 14rpx;
}
.search-box {
display: flex;
flex: 1;
align-items: center;
gap: 12rpx;
height: 74rpx;
padding: 0 22rpx;
border-radius: 18rpx;
background: #f3f8ef;
}
.search-box input {
flex: 1;
color: #17312d;
font-size: 26rpx;
}
.filter-more {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
width: 144rpx;
height: 74rpx;
padding: 0 16rpx;
border-radius: 18rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 23rpx;
font-weight: 900;
line-height: 1;
white-space: nowrap;
}
.filter-more text {
line-height: 1;
}
.filter-more__dot {
position: absolute;
top: 8rpx;
right: 12rpx;
display: grid;
place-items: center;
min-width: 28rpx;
height: 28rpx;
border-radius: 999rpx;
color: #ffffff;
background: #ff7a59;
font-size: 18rpx;
font-weight: 900;
}
.stage-rail {
margin-top: 22rpx;
white-space: nowrap;
}
.stage-pill {
display: inline-flex;
align-items: center;
justify-content: space-between;
min-width: 158rpx;
height: 72rpx;
margin-right: 12rpx;
padding: 0 18rpx;
border: 1rpx solid #dce8dc;
border-radius: 16rpx;
color: #17312d;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
line-height: 1;
vertical-align: top;
white-space: nowrap;
}
.stage-pill text:last-child {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34rpx;
height: 34rpx;
margin-left: 14rpx;
border-radius: 999rpx;
color: #6f7c78;
background: #eef3ea;
font-size: 18rpx;
line-height: 34rpx;
}
.stage-pill--active {
color: #0b4f46;
border-color: #9ae9dc;
background: #dff8ee;
}
.stage-pill--active text:last-child {
color: #ffffff;
background: #0b4f46;
}
.filter-console__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 18rpx;
}
.filter-console__foot text {
color: #6f7c78;
font-size: 22rpx;
font-weight: 800;
}
.filter-console__foot button {
min-width: 82rpx;
height: 42rpx;
border-radius: 999rpx;
color: #ff7a59;
background: #fff1ec;
font-size: 20rpx;
font-weight: 900;
}
.filter-sheet {
padding: 18rpx 28rpx calc(34rpx + env(safe-area-inset-bottom));
background: #fbfcf7;
}
.filter-sheet__bar {
width: 78rpx;
height: 8rpx;
margin: 0 auto 22rpx;
border-radius: 999rpx;
background: #dce8dc;
}
.filter-sheet__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.filter-sheet__title {
color: #17312d;
font-size: 34rpx;
font-weight: 900;
}
.filter-sheet__sub {
margin-top: 6rpx;
color: #6f7c78;
font-size: 22rpx;
}
.filter-sheet__head button {
min-width: 108rpx;
height: 56rpx;
border-radius: 999rpx;
color: #ffffff;
background: #0b4f46;
font-size: 22rpx;
font-weight: 900;
}
.filter-sheet__group {
margin-top: 28rpx;
}
.filter-sheet__label {
color: #17312d;
font-size: 25rpx;
font-weight: 900;
}
.sheet-chip-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 16rpx;
}
.sheet-chip {
height: 56rpx;
padding: 0 20rpx;
border: 1rpx solid #dce8dc;
border-radius: 999rpx;
color: #6f7c78;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
}
.sheet-chip--active {
color: #0b4f46;
border-color: #9ae9dc;
background: #dff8ee;
}
.lead-list {
margin-top: 20rpx;
}
.lead-card {
margin-top: 18rpx;
padding: 28rpx;
}
.lead-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
}
.lead-card__name {
color: #17312d;
font-size: 30rpx;
font-weight: 900;
}
.lead-card__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
}
.lead-card__body {
display: grid;
grid-template-columns: 1fr;
gap: 14rpx;
margin-top: 22rpx;
}
.lead-card__body view {
padding: 18rpx;
border-radius: 14rpx;
background: #f3f8ef;
}
.lead-card__body text {
display: block;
}
.lead-card__body text:first-child {
color: #6f7c78;
font-size: 20rpx;
font-weight: 800;
}
.lead-card__body text:last-child {
margin-top: 8rpx;
color: #17312d;
font-size: 24rpx;
font-weight: 900;
}
.advance-button {
width: 100%;
height: 74rpx;
margin-top: 24rpx;
border-radius: 16rpx;
color: #ffffff;
background: #0b4f46;
font-size: 25rpx;
font-weight: 900;
}
.advance-button--disabled {
color: #82918b;
background: #dfe8dc;
}
.advance-button--purchase {
background: #ff7a59;
}
</style>

@ -0,0 +1,435 @@
<template>
<view class="lf-page account-list-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">积分账户</view>
<view class="lf-nav__sub">搜索家长账户,查看可用、待到账和将过期积分</view>
</view>
<view class="lf-pill">{{ total }} 个</view>
</view>
<view v-if="!isAdmin" class="lf-card denied-card">
<view class="denied-card__title">当前账号没有管理权限</view>
<view class="lf-caption">请切换到管理账号后再查看积分账户。</view>
</view>
<template v-else>
<view class="summary-grid">
<view v-for="item in metrics" :key="item.label" class="lf-card summary-card">
<text>{{ item.label }}</text>
<text>{{ item.value }}</text>
</view>
</view>
<view class="lf-card filter-card">
<view class="search-box">
<u-icon name="search" color="#6f7c78" size="22"></u-icon>
<input v-model="filters.keyword" placeholder="搜索家长、手机号、孩子名" confirm-type="search" @confirm="refresh" />
</view>
<view class="status-row">
<button
v-for="item in statusOptions"
:key="item.value"
:class="['status-chip', filters.pointStatus === item.value && 'status-chip--active']"
@click="selectStatus(item.value)"
>
{{ item.label }}
</button>
</view>
<view class="query-actions">
<button class="query-actions__ghost" @click="reset">重置</button>
<button class="query-actions__primary" @click="refresh">查询</button>
</view>
</view>
<view v-for="item in list" :key="item.user.id" class="lf-card account-card">
<view class="account-card__head">
<view class="account-card__main">
<view class="account-card__name">{{ item.user.name }}</view>
<view class="account-card__meta">{{ item.user.phone || '-' }} · {{ item.user.child?.name || '未填写孩子' }}</view>
</view>
<view class="account-card__score">
<text>{{ item.points.available }}</text>
<text>可用</text>
</view>
</view>
<view class="points-grid">
<view>
<text>{{ item.points.pending }}</text>
<text>待到账</text>
</view>
<view>
<text>{{ item.points.expiring }}</text>
<text>将过期</text>
</view>
<view>
<text>{{ item.points.frozen }}</text>
<text>冻结</text>
</view>
</view>
<view class="account-actions">
<button @click="adjust(item, 100, '后台补发积分')">补发 100</button>
<button class="is-warn" @click="adjust(item, -50, '后台冲正积分')">冲正 50</button>
</view>
</view>
<view class="load-state">{{ loadText }}</view>
</template>
<AdminTabbar v-if="isAdmin" active="points" />
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import AdminTabbar from '../../components/AdminTabbar.vue'
import { adjustPoints, getAdminPointAccounts } from '../../services/educationApi'
import { getStoredAccount, isAdminAccount } from '../../services/accountSession'
export default {
components: { AdminTabbar },
data() {
return {
account: getStoredAccount() || {},
list: [],
total: 0,
overview: { parents: 0, available: 0, pending: 0, expiring: 0, frozen: 0 },
loading: false,
finished: false,
filters: {
keyword: '',
pointStatus: 'all',
pageNum: 1,
pageSize: 10,
},
statusOptions: [
{ label: '全部', value: 'all' },
{ label: '有可用', value: 'available' },
{ label: '待到账', value: 'pending' },
{ label: '将过期', value: 'expiring' },
{ label: '冻结', value: 'frozen' },
],
}
},
computed: {
isAdmin() {
return isAdminAccount(this.account)
},
metrics() {
return [
{ label: '账户数', value: this.overview.parents || 0 },
{ label: '可用', value: this.overview.available || 0 },
{ label: '待到账', value: this.overview.pending || 0 },
{ label: '将过期', value: this.overview.expiring || 0 },
]
},
loadText() {
if (this.loading) return '加载中...'
if (this.finished) return this.list.length ? '没有更多了' : '暂无积分账户'
return '上拉加载更多'
},
},
onShow() {
const account = getStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.account = account
if (this.isAdmin && !this.list.length) this.refresh()
},
async onPullDownRefresh() {
if (this.isAdmin) await this.refresh()
uni.stopPullDownRefresh()
},
onReachBottom() {
this.loadMore()
},
methods: {
selectStatus(status) {
this.filters.pointStatus = status
this.refresh()
},
reset() {
this.filters.keyword = ''
this.filters.pointStatus = 'all'
this.refresh()
},
async refresh() {
this.filters.pageNum = 1
this.finished = false
await this.load(true)
},
async loadMore() {
if (this.loading || this.finished || !this.isAdmin) return
this.filters.pageNum += 1
await this.load(false)
},
async load(reset) {
this.loading = true
try {
const res = await getAdminPointAccounts(this.filters)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '积分账户加载失败', type: 'error' })
return
}
const data = res.data || res || {}
const rows = data.list || []
this.total = data.total || 0
this.overview = data.overview || this.overview
this.list = reset ? rows : this.list.concat(rows)
this.finished = this.list.length >= this.total
} finally {
this.loading = false
}
},
async adjust(item, amount, title) {
const action = amount > 0 ? '补发' : '冲正'
const confirmed = await this.confirmAction('确认调整积分', `将为「${item.user.name}」${action} ${Math.abs(amount)} 积分,操作会写入积分流水。`)
if (!confirmed) return
const res = await adjustPoints({
userId: item.user.id,
amount,
title,
status: 'available',
})
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '积分调整失败', type: 'error' })
return
}
this.$refs.toast.show({ message: '积分已调整', type: 'success' })
this.refresh()
},
confirmAction(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '确认',
cancelText: '取消',
confirmColor: '#0b4f46',
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
},
},
}
</script>
<style lang="scss" scoped>
.account-list-page {
padding-bottom: 170rpx;
}
.denied-card,
.filter-card,
.account-card {
margin-top: 22rpx;
padding: 28rpx;
}
.denied-card__title {
margin-bottom: 12rpx;
color: #17312d;
font-size: 34rpx;
font-weight: 900;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
margin-top: 18rpx;
}
.summary-card {
min-height: 128rpx;
padding: 24rpx;
}
.summary-card text:first-child {
color: #6f7c78;
font-size: 22rpx;
font-weight: 800;
}
.summary-card text:last-child {
display: block;
margin-top: 12rpx;
color: #0b4f46;
font-size: 38rpx;
font-weight: 900;
}
.search-box {
display: flex;
align-items: center;
gap: 12rpx;
height: 74rpx;
padding: 0 22rpx;
border-radius: 16rpx;
background: #f3f8ef;
}
.search-box input {
flex: 1;
color: #17312d;
font-size: 26rpx;
}
.status-row {
display: flex;
gap: 12rpx;
margin-top: 18rpx;
overflow-x: auto;
white-space: nowrap;
}
.status-chip {
height: 54rpx;
padding: 0 20rpx;
border: 1rpx solid #dce8dc;
border-radius: 999rpx;
color: #6f7c78;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
line-height: 54rpx;
}
.status-chip--active {
color: #0b4f46;
background: #dff8ee;
border-color: #9ae9dc;
}
.query-actions {
display: grid;
grid-template-columns: 0.86fr 1.14fr;
gap: 14rpx;
margin-top: 18rpx;
}
.query-actions button {
height: 68rpx;
border-radius: 16rpx;
font-size: 25rpx;
font-weight: 900;
line-height: 68rpx;
}
.query-actions__ghost {
color: #0b4f46;
background: #eef8f4;
}
.query-actions__primary {
color: #ffffff;
background: #0b4f46;
}
.account-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
}
.account-card__main {
min-width: 0;
}
.account-card__name {
color: #17312d;
font-size: 30rpx;
font-weight: 900;
}
.account-card__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.45;
}
.account-card__score {
display: flex;
flex-direction: column;
align-items: flex-end;
flex: 0 0 auto;
min-width: 112rpx;
}
.account-card__score text:first-child {
color: #0b4f46;
font-size: 38rpx;
font-weight: 900;
}
.account-card__score text:last-child {
margin-top: 4rpx;
color: #6f7c78;
font-size: 20rpx;
font-weight: 800;
}
.points-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
margin-top: 22rpx;
}
.points-grid view {
padding: 18rpx 12rpx;
border-radius: 14rpx;
background: #f3f8ef;
text-align: center;
}
.points-grid text {
display: block;
}
.points-grid text:first-child {
color: #17312d;
font-size: 28rpx;
font-weight: 900;
}
.points-grid text:last-child {
margin-top: 6rpx;
color: #6f7c78;
font-size: 20rpx;
font-weight: 800;
}
.account-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14rpx;
margin-top: 22rpx;
}
.account-actions button {
height: 66rpx;
border-radius: 16rpx;
color: #ffffff;
background: #0b4f46;
font-size: 24rpx;
font-weight: 900;
line-height: 66rpx;
}
.account-actions .is-warn {
background: #ff7a59;
}
.load-state {
padding: 28rpx 0 90rpx;
color: #6f7c78;
font-size: 23rpx;
text-align: center;
}
</style>

@ -0,0 +1,346 @@
<template>
<view class="lf-page point-records-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">积分流水</view>
<view class="lf-nav__sub">按家长、事项和状态查询积分变动</view>
</view>
<view class="lf-pill">{{ total }} 条</view>
</view>
<view v-if="!isAdmin" class="lf-card denied-card">
<view class="denied-card__title">当前账号没有管理权限</view>
<view class="lf-caption">请切换到管理账号后再查看积分流水。</view>
</view>
<template v-else>
<view class="lf-card filter-card">
<view class="search-box">
<u-icon name="search" color="#6f7c78" size="22"></u-icon>
<input v-model="filters.keyword" placeholder="搜索家长、账号或事项" confirm-type="search" @confirm="refresh" />
</view>
<view class="status-row">
<button
v-for="item in statusOptions"
:key="item.value"
:class="['status-chip', filters.status === item.value && 'status-chip--active']"
@click="selectStatus(item.value)"
>
{{ item.label }}
</button>
</view>
<view class="date-grid">
<view class="date-field">
<text>开始日期</text>
<input v-model="filters.startDate" placeholder="YYYY-MM-DD" confirm-type="done" />
</view>
<view class="date-field">
<text>结束日期</text>
<input v-model="filters.endDate" placeholder="YYYY-MM-DD" confirm-type="done" />
</view>
</view>
<view class="query-actions">
<button class="query-actions__ghost" @click="reset">重置</button>
<button class="query-actions__primary" @click="refresh">查询</button>
</view>
</view>
<view v-for="tx in list" :key="tx.id" class="lf-card tx-card">
<view class="tx-card__top">
<view class="tx-card__main">
<view class="tx-card__title">{{ tx.title }}</view>
<view class="tx-card__meta">{{ tx.userName || tx.userId }} · {{ tx.createdAt }}</view>
</view>
<view class="tx-card__amount">
<text :class="tx.amount < 0 && 'is-minus'">{{ tx.amount > 0 ? '+' : '' }}{{ tx.amount }}</text>
<text>{{ statusLabel(tx.status) }}</text>
</view>
</view>
<view class="tx-card__id">{{ tx.userId }}</view>
</view>
<view class="load-state">{{ loadText }}</view>
</template>
<AdminTabbar v-if="isAdmin" active="points" />
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import AdminTabbar from '../../components/AdminTabbar.vue'
import { getAdminPointTransactions } from '../../services/educationApi'
import { getStoredAccount, isAdminAccount } from '../../services/accountSession'
export default {
components: { AdminTabbar },
data() {
return {
account: getStoredAccount() || {},
list: [],
total: 0,
loading: false,
finished: false,
filters: {
keyword: '',
status: 'all',
startDate: '',
endDate: '',
pageNum: 1,
pageSize: 10,
},
statusOptions: [
{ label: '全部', value: 'all' },
{ label: '可用', value: 'available' },
{ label: '待到账', value: 'pending' },
],
}
},
computed: {
isAdmin() {
return isAdminAccount(this.account)
},
loadText() {
if (this.loading) return '加载中...'
if (this.finished) return this.list.length ? '没有更多了' : '暂无积分流水'
return '上拉加载更多'
},
},
onShow() {
const account = getStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.account = account
if (this.isAdmin && !this.list.length) this.refresh()
},
async onPullDownRefresh() {
if (this.isAdmin) await this.refresh()
uni.stopPullDownRefresh()
},
onReachBottom() {
this.loadMore()
},
methods: {
selectStatus(status) {
this.filters.status = status
this.refresh()
},
reset() {
this.filters.keyword = ''
this.filters.status = 'all'
this.filters.startDate = ''
this.filters.endDate = ''
this.refresh()
},
async refresh() {
this.filters.pageNum = 1
this.finished = false
await this.load(true)
},
async loadMore() {
if (this.loading || this.finished || !this.isAdmin) return
this.filters.pageNum += 1
await this.load(false)
},
async load(reset) {
this.loading = true
try {
const res = await getAdminPointTransactions(this.filters)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '积分流水加载失败', type: 'error' })
return
}
const data = res.data || res || {}
const rows = data.list || []
this.total = data.total || 0
this.list = reset ? rows : this.list.concat(rows)
this.finished = this.list.length >= this.total
} finally {
this.loading = false
}
},
statusLabel(status) {
return status === 'pending' ? '待到账' : '可用'
},
},
}
</script>
<style lang="scss" scoped>
.point-records-page {
padding-bottom: 170rpx;
}
.denied-card,
.filter-card,
.tx-card {
margin-top: 22rpx;
padding: 28rpx;
}
.denied-card__title {
margin-bottom: 12rpx;
color: #17312d;
font-size: 34rpx;
font-weight: 900;
}
.search-box {
display: flex;
align-items: center;
gap: 12rpx;
height: 74rpx;
padding: 0 22rpx;
border-radius: 16rpx;
background: #f3f8ef;
}
.search-box input {
flex: 1;
color: #17312d;
font-size: 26rpx;
}
.status-row {
display: flex;
gap: 12rpx;
margin-top: 18rpx;
overflow-x: auto;
white-space: nowrap;
}
.status-chip {
height: 54rpx;
padding: 0 20rpx;
border: 1rpx solid #dce8dc;
border-radius: 999rpx;
color: #6f7c78;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
line-height: 54rpx;
}
.status-chip--active {
color: #0b4f46;
background: #dff8ee;
border-color: #9ae9dc;
}
.date-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14rpx;
margin-top: 18rpx;
}
.date-field text {
display: block;
margin-bottom: 8rpx;
color: #6f7c78;
font-size: 20rpx;
font-weight: 900;
}
.date-field input {
height: 68rpx;
padding: 0 18rpx;
border: 1rpx solid #dce8dc;
border-radius: 14rpx;
color: #17312d;
background: #fbfcf7;
font-size: 23rpx;
line-height: 68rpx;
}
.query-actions {
display: grid;
grid-template-columns: 0.86fr 1.14fr;
gap: 14rpx;
margin-top: 18rpx;
}
.query-actions button {
height: 68rpx;
border-radius: 16rpx;
font-size: 25rpx;
font-weight: 900;
line-height: 68rpx;
}
.query-actions__ghost {
color: #0b4f46;
background: #eef8f4;
}
.query-actions__primary {
color: #ffffff;
background: #0b4f46;
}
.tx-card__top {
display: flex;
justify-content: space-between;
gap: 20rpx;
}
.tx-card__main {
min-width: 0;
}
.tx-card__title {
color: #17312d;
font-size: 29rpx;
font-weight: 900;
line-height: 1.4;
}
.tx-card__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
}
.tx-card__amount {
display: flex;
flex-direction: column;
align-items: flex-end;
flex: 0 0 auto;
min-width: 128rpx;
}
.tx-card__amount text:first-child {
color: #0b4f46;
font-size: 32rpx;
font-weight: 900;
}
.tx-card__amount text:first-child.is-minus {
color: #ff7a59;
}
.tx-card__amount text:last-child {
margin-top: 8rpx;
color: #6f7c78;
font-size: 20rpx;
}
.tx-card__id {
margin-top: 18rpx;
padding: 12rpx 16rpx;
border-radius: 12rpx;
color: #0b4f46;
background: #eef8f4;
font-size: 20rpx;
font-weight: 800;
}
.load-state {
padding: 28rpx 0 90rpx;
color: #6f7c78;
font-size: 23rpx;
text-align: center;
}
</style>

@ -0,0 +1,474 @@
<template>
<view class="lf-page admin-points-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">积分管理</view>
<view class="lf-nav__sub">账户池、补发冲正、流水筛选</view>
</view>
<view class="lf-pill">{{ manager.overview.transactions || 0 }} 笔</view>
</view>
<view v-if="!isAdmin" class="lf-card denied-card">
<view class="denied-card__title">当前账号没有管理权限</view>
<view class="lf-caption">请切换到管理账号后查看积分管理。</view>
<u-button color="#0b4f46" shape="circle" text="切换账号" @click="confirmLogout"></u-button>
</view>
<template v-else>
<view class="points-metrics">
<view v-for="metric in metrics" :key="metric.label" class="lf-card metric-card">
<text>{{ metric.label }}</text>
<text>{{ metric.value }}</text>
</view>
</view>
<view class="lf-card expiry-card">
<view class="lf-row">
<view>
<view class="lf-section-title">积分过期提醒</view>
<view class="lf-caption">{{ manager.expiryRule?.description }}</view>
</view>
<view class="expiry-badge">{{ manager.expiryRule?.validDays || 180 }} 天</view>
</view>
<view class="expiry-note">{{ manager.expiryRule?.reminder }}</view>
<view v-for="item in manager.expirySchedules" :key="item.id" class="expiry-row">
<view>
<view class="expiry-row__name">{{ item.userName || item.userId }}</view>
<view class="expiry-row__meta">{{ item.source }}</view>
</view>
<view class="expiry-row__right">
<text>{{ item.points }} 积分</text>
<text>{{ item.expireAt }}</text>
</view>
</view>
</view>
<view class="lf-card account-card">
<view class="lf-row">
<view>
<view class="lf-section-title">家长积分账户</view>
<view class="lf-caption">仅展示前 5 个账户,筛选查询进入完整账户页</view>
</view>
<button class="ghost-button" @click="go('/pages/admin-point-accounts/index')">查看更多</button>
</view>
<view v-if="!previewAccounts.length" class="empty-mini">暂无积分账户</view>
<view v-for="item in previewAccounts" :key="item.user.id" class="account-row">
<view>
<view class="account-row__name">{{ item.user.name }}</view>
<view class="account-row__meta">{{ item.user.child?.name || '-' }} · 可用 {{ item.points.available }} · 待到账 {{ item.points.pending }}</view>
</view>
<view class="account-row__actions">
<button @click="adjust(item, 100, '后台补发积分')">补发</button>
<button class="is-warn" @click="adjust(item, -50, '后台冲正积分')">冲正</button>
</view>
</view>
</view>
<view class="lf-card filter-card">
<view class="lf-row">
<view>
<view class="lf-section-title">最近积分流水</view>
<view class="lf-caption">仅展示最新 5 条,筛选查询进入完整流水页</view>
</view>
<button class="ghost-button" @click="go('/pages/admin-point-records/index')">查看更多</button>
</view>
<view v-if="!previewTransactions.length" class="empty-mini">暂无积分流水</view>
<view v-for="tx in previewTransactions" :key="tx.id" class="tx-row">
<view>
<view class="tx-row__title">{{ tx.title }}</view>
<view class="tx-row__meta">{{ tx.userName || tx.userId }} · {{ tx.createdAt }}</view>
</view>
<view class="tx-row__amount">
<text :class="tx.amount < 0 && 'is-minus'">{{ tx.amount > 0 ? '+' : '' }}{{ tx.amount }}</text>
<text>{{ statusLabel(tx.status) }}</text>
</view>
</view>
</view>
</template>
<AdminTabbar v-if="isAdmin" active="points" />
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import AdminTabbar from '../../components/AdminTabbar.vue'
import { adjustPoints, getAdminPointManager } from '../../services/educationApi'
import { clearStoredAccount, getStoredAccount, isAdminAccount } from '../../services/accountSession'
const emptyManager = {
overview: {},
accounts: [],
transactions: [],
expiryRule: {},
expirySchedules: [],
rewards: [],
rules: [],
}
export default {
components: { AdminTabbar },
data() {
return {
account: getStoredAccount() || {},
manager: emptyManager,
}
},
computed: {
isAdmin() {
return isAdminAccount(this.account)
},
metrics() {
const overview = this.manager.overview || {}
return [
{ label: '可用积分池', value: overview.available || 0 },
{ label: '待到账', value: overview.pending || 0 },
{ label: '将过期', value: overview.expiring || 0 },
{ label: '家长账户', value: overview.parents || 0 },
]
},
previewTransactions() {
return (this.manager.transactions || []).slice(0, 5)
},
previewAccounts() {
return (this.manager.accounts || []).slice(0, 5)
},
},
onShow() {
const account = getStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.account = account
if (this.isAdmin) this.load()
},
async onPullDownRefresh() {
if (this.isAdmin) await this.load()
uni.stopPullDownRefresh()
},
methods: {
async load() {
const res = await getAdminPointManager()
this.manager = res.data || res || this.manager
},
statusLabel(status) {
return status === 'pending' ? '待到账' : '可用'
},
async adjust(item, amount, title) {
const action = amount > 0 ? '补发' : '冲正'
const confirmed = await this.confirmAction('确认调整积分', `将为「${item.user.name}」${action} ${Math.abs(amount)} 积分,操作会写入积分流水。`)
if (!confirmed) return
const res = await adjustPoints({
userId: item.user.id,
amount,
title,
status: 'available',
})
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '积分调整失败', type: 'error' })
return
}
this.$refs.toast.show({ message: '积分已调整', type: 'success' })
this.load()
},
confirmLogout() {
uni.showModal({
title: '确认退出登录',
content: '退出后需要重新登录当前账号,是否继续?',
confirmText: '退出',
confirmColor: '#b54732',
success: (res) => {
if (!res.confirm) return
clearStoredAccount()
uni.reLaunch({ url: '/pages/login/index' })
},
})
},
go(url) {
uni.navigateTo({ url })
},
confirmAction(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '确认',
cancelText: '取消',
confirmColor: '#0b4f46',
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
},
},
}
</script>
<style lang="scss" scoped>
.admin-points-page {
padding-bottom: 150rpx;
}
.denied-card,
.account-card,
.filter-card,
.expiry-card {
margin-top: 22rpx;
padding: 28rpx;
}
.denied-card__title {
margin-bottom: 12rpx;
color: #17312d;
font-size: 34rpx;
font-weight: 900;
}
.points-metrics {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
margin-top: 18rpx;
}
.metric-card {
min-height: 138rpx;
padding: 24rpx;
}
.metric-card text:first-child {
color: #6f7c78;
font-size: 22rpx;
font-weight: 800;
}
.metric-card text:last-child {
display: block;
margin-top: 14rpx;
color: #0b4f46;
font-size: 40rpx;
font-weight: 900;
}
.expiry-badge {
min-width: 86rpx;
height: 54rpx;
border-radius: 999rpx;
color: #0b2f2a;
background: #f5c451;
font-size: 22rpx;
font-weight: 900;
line-height: 54rpx;
text-align: center;
}
.expiry-note {
margin-top: 18rpx;
padding: 18rpx;
border-radius: 14rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 22rpx;
font-weight: 800;
line-height: 1.5;
}
.expiry-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.expiry-row:last-child {
border-bottom: 0;
}
.expiry-row__name {
color: #17312d;
font-size: 27rpx;
font-weight: 900;
}
.expiry-row__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 21rpx;
}
.expiry-row__right {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 210rpx;
}
.expiry-row__right text:first-child {
color: #ff7a59;
font-size: 26rpx;
font-weight: 900;
}
.expiry-row__right text:last-child {
margin-top: 8rpx;
color: #6f7c78;
font-size: 20rpx;
}
.account-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.account-row:last-child {
border-bottom: 0;
}
.account-row__name {
color: #17312d;
font-size: 28rpx;
font-weight: 900;
}
.account-row__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.45;
}
.account-row__actions {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.account-row__actions button {
min-width: 94rpx;
height: 46rpx;
border-radius: 999rpx;
color: #ffffff;
background: #0b4f46;
font-size: 21rpx;
font-weight: 900;
}
.account-row__actions .is-warn {
background: #ff7a59;
}
.ghost-button {
min-width: 132rpx;
height: 52rpx;
border: 1rpx solid #b9d8d2;
border-radius: 999rpx;
color: #0b4f46;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
line-height: 52rpx;
}
.empty-mini {
padding: 28rpx 0 6rpx;
color: #6f7c78;
font-size: 23rpx;
text-align: center;
}
.search-box {
display: flex;
align-items: center;
gap: 12rpx;
height: 74rpx;
margin-top: 22rpx;
padding: 0 22rpx;
border: 1rpx solid #dce8dc;
border-radius: 16rpx;
background: #fbfcf7;
}
.search-box input {
flex: 1;
color: #17312d;
font-size: 26rpx;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 16rpx;
}
.filter-chip {
height: 52rpx;
padding: 0 18rpx;
border: 1rpx solid #dce8dc;
border-radius: 999rpx;
color: #6f7c78;
background: #ffffff;
font-size: 22rpx;
font-weight: 800;
}
.filter-chip--active {
color: #0b4f46;
border-color: #9ae9dc;
background: #dff8ee;
}
.tx-row {
display: flex;
justify-content: space-between;
gap: 18rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.tx-row:last-child {
border-bottom: 0;
}
.tx-row__title {
color: #17312d;
font-size: 27rpx;
font-weight: 900;
}
.tx-row__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
}
.tx-row__amount {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 110rpx;
}
.tx-row__amount text:first-child {
color: #0b4f46;
font-size: 28rpx;
font-weight: 900;
}
.tx-row__amount text:first-child.is-minus {
color: #ff7a59;
}
.tx-row__amount text:last-child {
margin-top: 8rpx;
color: #6f7c78;
font-size: 20rpx;
}
</style>

@ -0,0 +1,431 @@
<template>
<view class="lf-page admin-profile-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">管理账号</view>
<view class="lf-nav__sub">角色权限与账号安全</view>
</view>
<view class="lf-pill">{{ account.roleName || '-' }}</view>
</view>
<view v-if="!isAdmin" class="lf-card denied-card">
<view class="denied-card__title">当前账号没有管理权限</view>
<view class="lf-caption">请切换到管理账号后查看。</view>
<u-button color="#0b4f46" shape="circle" text="切换账号" @click="confirmLogout"></u-button>
</view>
<template v-else>
<view class="profile-card lf-card lf-card--ink">
<view class="profile-card__top">
<view class="profile-card__avatar">{{ (account.name || '-').slice(0, 1) }}</view>
<view>
<view class="profile-card__name">{{ account.name }}</view>
<view class="profile-card__phone">{{ account.phone || '未绑定手机号' }}</view>
</view>
</view>
<view class="permission-strip">
<text v-for="permission in account.permissions || []" :key="permission">{{ permission }}</text>
</view>
</view>
<view class="lf-card action-card">
<view class="lf-section-title">账号安全</view>
<view class="entry-row" @click="togglePasswordPanel">
<view>
<text>{{ account.hasPassword ? '修改密码' : '设置密码' }}</text>
<text>{{ account.hasPassword ? '用于账号密码登录' : '设置后可使用账号密码登录' }}</text>
</view>
<u-icon name="arrow-right" color="#0b4f46" size="22"></u-icon>
</view>
<view v-if="passwordPanel" class="security-panel">
<view v-if="account.hasPassword" class="form-field">
<text>原密码</text>
<input v-model="passwordForm.oldPassword" password maxlength="32" placeholder="请输入原密码" />
</view>
<view class="form-field">
<text>新密码</text>
<input v-model="passwordForm.password" password maxlength="32" placeholder="至少 6 位" />
</view>
<view class="form-field">
<text>确认密码</text>
<input v-model="passwordForm.confirmPassword" password maxlength="32" placeholder="请再次输入新密码" />
</view>
<button class="save-button" :disabled="saving" @click="savePassword">
{{ saving ? '保存中' : account.hasPassword ? '修改密码' : '设置密码' }}
</button>
</view>
<view class="entry-row" @click="togglePhonePanel">
<view>
<text>换绑手机号</text>
<text>当前:{{ account.phone || '未绑定' }}</text>
</view>
<u-icon name="arrow-right" color="#0b4f46" size="22"></u-icon>
</view>
<view v-if="phonePanel" class="security-panel">
<view class="form-field">
<text>新手机号</text>
<input v-model="phoneForm.phone" type="number" maxlength="11" placeholder="请输入新手机号" />
</view>
<view class="form-field form-field--code">
<view class="code-input">
<text>验证码</text>
<input v-model="phoneForm.code" type="number" maxlength="6" placeholder="请输入验证码" />
</view>
<button class="code-button" :disabled="phoneCountdown > 0" @click="requestPhoneCode">
{{ phoneCountdown > 0 ? `${phoneCountdown}s` : '获取验证码' }}
</button>
</view>
<button class="save-button" :disabled="saving" @click="savePhone">
{{ saving ? '保存中' : '确认换绑' }}
</button>
</view>
<view class="entry-row entry-row--danger" @click="confirmLogout">
<view>
<text>退出登录</text>
<text>退出后需要重新登录当前账号</text>
</view>
<u-icon name="arrow-right" color="#b54732" size="22"></u-icon>
</view>
</view>
</template>
<AdminTabbar v-if="isAdmin" active="profile" />
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import AdminTabbar from '../../components/AdminTabbar.vue'
import { sendSmsCode, updateBoundPhone, updatePassword } from '../../services/educationApi'
import { clearStoredAccount, getStoredAccount, isAdminAccount, refreshStoredAccount, setStoredAccount } from '../../services/accountSession'
function createPasswordForm() {
return {
oldPassword: '',
password: '',
confirmPassword: '',
}
}
export default {
components: { AdminTabbar },
data() {
return {
account: getStoredAccount() || {},
saving: false,
passwordPanel: false,
phonePanel: false,
phoneCountdown: 0,
phoneTimer: null,
passwordForm: createPasswordForm(),
phoneForm: {
phone: '',
code: '',
},
}
},
computed: {
isAdmin() {
return isAdminAccount(this.account)
},
},
async onShow() {
const account = await refreshStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.account = account
},
async onPullDownRefresh() {
await this.onShow()
uni.stopPullDownRefresh()
},
onUnload() {
if (this.phoneTimer) clearInterval(this.phoneTimer)
},
methods: {
togglePasswordPanel() {
this.passwordPanel = !this.passwordPanel
this.passwordForm = createPasswordForm()
if (this.passwordPanel) this.phonePanel = false
},
togglePhonePanel() {
this.phonePanel = !this.phonePanel
this.phoneForm = { phone: '', code: '' }
if (this.phonePanel) this.passwordPanel = false
},
async requestPhoneCode() {
if (!/^1\d{10}$/.test(this.phoneForm.phone)) {
this.$refs.toast.show({ message: '请先输入正确手机号', type: 'error' })
return
}
const res = await sendSmsCode(this.phoneForm.phone, 'update_phone')
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '验证码发送失败', type: 'error' })
return
}
const data = res.data || res
this.startPhoneCountdown(data.cooldown || 60)
this.$refs.toast.show({ message: res.message || data.message || '验证码已发送', type: 'success' })
},
startPhoneCountdown(seconds = 60) {
if (this.phoneTimer) clearInterval(this.phoneTimer)
this.phoneCountdown = Number(seconds || 60)
this.phoneTimer = setInterval(() => {
this.phoneCountdown -= 1
if (this.phoneCountdown <= 0) {
clearInterval(this.phoneTimer)
this.phoneTimer = null
}
}, 1000)
},
async savePassword() {
if (this.account.hasPassword && !this.passwordForm.oldPassword) {
this.$refs.toast.show({ message: '请输入原密码', type: 'error' })
return
}
if (this.passwordForm.password.length < 6) {
this.$refs.toast.show({ message: '密码至少需要 6 位', type: 'error' })
return
}
if (this.passwordForm.password !== this.passwordForm.confirmPassword) {
this.$refs.toast.show({ message: '两次输入的密码不一致', type: 'error' })
return
}
this.saving = true
try {
const res = await updatePassword(this.account.id, this.passwordForm)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '密码保存失败', type: 'error' })
return
}
this.applyAccount(res.data || res)
this.passwordPanel = false
this.passwordForm = createPasswordForm()
this.$refs.toast.show({ message: '密码已更新', type: 'success' })
} finally {
this.saving = false
}
},
async savePhone() {
if (!/^1\d{10}$/.test(this.phoneForm.phone)) {
this.$refs.toast.show({ message: '请输入正确手机号', type: 'error' })
return
}
if (!this.phoneForm.code) {
this.$refs.toast.show({ message: '请输入验证码', type: 'error' })
return
}
this.saving = true
try {
const res = await updateBoundPhone(this.account.id, this.phoneForm)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '换绑失败', type: 'error' })
return
}
this.applyAccount(res.data || res)
this.phonePanel = false
this.phoneForm = { phone: '', code: '' }
this.$refs.toast.show({ message: '手机号已换绑', type: 'success' })
} finally {
this.saving = false
}
},
applyAccount(account) {
this.account = account
setStoredAccount(account)
},
confirmLogout() {
uni.showModal({
title: '确认退出登录',
content: '退出后需要重新登录当前账号,是否继续?',
confirmText: '退出',
confirmColor: '#b54732',
success: (res) => {
if (!res.confirm) return
clearStoredAccount()
uni.reLaunch({ url: '/pages/login/index' })
},
})
},
},
}
</script>
<style lang="scss" scoped>
.admin-profile-page {
padding-bottom: 150rpx;
}
.denied-card,
.action-card {
margin-top: 22rpx;
padding: 28rpx;
}
.denied-card__title {
margin-bottom: 12rpx;
color: #17312d;
font-size: 34rpx;
font-weight: 900;
}
.profile-card {
margin-top: 18rpx;
padding: 32rpx;
}
.profile-card__top {
display: flex;
align-items: center;
gap: 22rpx;
}
.profile-card__avatar {
display: grid;
place-items: center;
width: 92rpx;
height: 92rpx;
border-radius: 24rpx;
color: #0b2f2a;
background: #f5c451;
font-size: 42rpx;
font-weight: 900;
}
.profile-card__name {
color: #ffffff;
font-size: 36rpx;
font-weight: 900;
}
.profile-card__phone {
margin-top: 8rpx;
color: rgba(255, 255, 255, 0.68);
font-size: 24rpx;
}
.permission-strip {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 28rpx;
}
.permission-strip text {
padding: 8rpx 14rpx;
border-radius: 999rpx;
color: #dff8ee;
background: rgba(255, 255, 255, 0.1);
font-size: 20rpx;
font-weight: 800;
}
.entry-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 26rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.entry-row:last-child {
border-bottom: 0;
}
.entry-row--danger text:first-child {
color: #b54732;
}
.entry-row text {
display: block;
}
.entry-row text:first-child {
color: #17312d;
font-size: 28rpx;
font-weight: 900;
}
.entry-row text:last-child {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.45;
}
.security-panel {
margin-top: 20rpx;
padding: 22rpx;
border: 1rpx solid #dfe9dc;
border-radius: 16rpx;
background: #fbfdf8;
}
.form-field {
margin-top: 18rpx;
}
.form-field:first-child {
margin-top: 0;
}
.form-field text {
display: block;
margin-bottom: 10rpx;
color: #6f7c78;
font-size: 22rpx;
font-weight: 900;
}
.form-field input {
height: 78rpx;
padding: 0 22rpx;
border: 1rpx solid #dfe9dc;
border-radius: 14rpx;
color: #17312d;
background: #f8faf5;
font-size: 26rpx;
line-height: 78rpx;
}
.form-field--code {
display: grid;
grid-template-columns: minmax(0, 1fr) 190rpx;
align-items: end;
gap: 16rpx;
}
.code-button,
.save-button {
display: flex;
align-items: center;
justify-content: center;
height: 78rpx;
border-radius: 16rpx;
font-size: 26rpx;
font-weight: 900;
line-height: normal;
}
.code-button {
color: #0b4f46;
border: 1rpx solid #b9d8d2;
background: #dff8ee;
}
.code-button[disabled] {
color: #7d8b86;
background: #eef3ec;
}
.save-button {
margin-top: 24rpx;
color: #ffffff;
background: #0b4f46;
}
</style>

@ -0,0 +1,421 @@
<template>
<view class="lf-page admin-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">增长管理台</view>
<view class="lf-nav__sub">线索、积分、风控统一看</view>
</view>
<view class="lf-pill">{{ account.roleName }}</view>
</view>
<view v-if="!isAdmin" class="lf-card denied-card">
<view class="denied-card__title">当前账号没有管理权限</view>
<view class="lf-caption">请使用校区管理员、顾问、老师、财务或超级管理员账号登录后访问。</view>
<u-button color="#0b4f46" shape="circle" text="切换账号" @click="confirmLogout"></u-button>
</view>
<template v-else>
<view class="metrics-grid">
<view v-for="metric in metrics" :key="metric.label" class="metric-card lf-card">
<text>{{ metric.label }}</text>
<text>{{ metric.value }}</text>
</view>
</view>
<view class="lf-card ops-card">
<view class="lf-row">
<view class="lf-section-title">转化漏斗</view>
<text class="lf-caption">按当前账号权限展示</text>
</view>
<view v-for="item in admin.funnel" :key="item.label" class="ops-bar">
<view class="ops-bar__head">
<text>{{ item.label }}</text>
<text>{{ item.value }}</text>
</view>
<view class="ops-bar__track">
<view class="ops-bar__fill" :style="{ width: `${Math.max(12, item.value / admin.funnel[0].value * 100)}%` }"></view>
</view>
</view>
</view>
<view class="lf-card leads-card">
<view class="lf-row">
<view>
<view class="lf-section-title">待处理线索</view>
<view class="lf-caption">首页仅展示最新 3 条</view>
</view>
<button class="ghost-button" @click="goAdminPage('/pages/admin-leads/index')">全部线索</button>
</view>
<view v-for="lead in previewLeads" :key="lead.id" class="lead-row">
<view>
<view class="lead-row__name">{{ lead.parent }}</view>
<view class="lead-row__meta">来源 {{ lead.source }}</view>
<view class="lead-row__risk">{{ lead.channel || lead.source }}</view>
</view>
<view class="lead-row__stage">
<u-tag :text="lead.stage" plain shape="circle" color="#0b4f46" borderColor="#b9d8d2"></u-tag>
<button :class="[lead.stage === '已报名' && 'is-disabled', nextStage(lead) === '已报名' && 'is-purchase']" @click="advance(lead)">
{{ actionLabel(lead) }}
</button>
</view>
</view>
</view>
<view class="lf-card point-hub">
<view class="point-hub__main">
<view class="lf-cube-mark">分</view>
<view>
<view class="lf-section-title">积分管理</view>
<view class="lf-caption">补发、冲正、流水筛选在独立页面处理</view>
</view>
</view>
<view class="point-hub__stats">
<view>
<text>{{ admin.overview.pointsCost || 0 }}</text>
<text>累计发放</text>
</view>
<view>
<text>{{ admin.overview.attended || 0 }}</text>
<text>到课奖励</text>
</view>
</view>
<button class="primary-row-button" @click="goAdminPage('/pages/admin-points/index')">进入积分管理</button>
</view>
<view class="lf-card course-hub">
<view class="point-hub__main">
<view class="lf-cube-mark">课</view>
<view>
<view class="lf-section-title">试听课程管理</view>
<view class="lf-caption">维护可预约试听课、席位和到课积分</view>
</view>
</view>
<button class="primary-row-button" @click="goAdminPage('/pages/admin-courses/index')">进入试听管理</button>
</view>
</template>
<AdminTabbar v-if="isAdmin" active="home" />
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import AdminTabbar from '../../components/AdminTabbar.vue'
import { confirmLeadPurchase, getAdminDashboard, updateLeadStage } from '../../services/educationApi'
import { clearStoredAccount, getStoredAccount, isAdminAccount } from '../../services/accountSession'
const stageFlow = ['待跟进', '已注册', '已预约', '已到课', '已报名']
export default {
components: { AdminTabbar },
data() {
return {
account: getStoredAccount() || {},
admin: { overview: { leads: 0, attended: 0, purchased: 0, pointsCost: 0 }, funnel: [], leads: [] },
}
},
computed: {
isAdmin() {
return isAdminAccount(this.account)
},
metrics() {
const overview = this.admin.overview
return [
{ label: '推荐线索', value: overview.leads },
{ label: '确认到课', value: overview.attended },
{ label: '报名成交', value: overview.purchased },
{ label: '已发放积分', value: overview.pointsCost },
]
},
previewLeads() {
return (this.admin.leads || []).slice(0, 3)
},
},
onShow() {
const account = getStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.account = account
if (this.isAdmin) this.load()
},
async onPullDownRefresh() {
if (this.isAdmin) await this.load()
uni.stopPullDownRefresh()
},
methods: {
async load() {
const res = await getAdminDashboard()
this.admin = res.data || res || this.admin
},
nextStage(lead) {
const index = stageFlow.indexOf(lead.stage)
return stageFlow[Math.min(index + 1, stageFlow.length - 1)] || '已注册'
},
actionLabel(lead) {
if (lead.stage === '已报名') return '已报名'
return this.nextStage(lead) === '已报名' ? '确认报名' : '推进'
},
async advance(lead) {
const next = this.nextStage(lead)
if (lead.stage === next) {
this.$refs.toast.show({ message: '线索已到最终阶段', type: 'default' })
return
}
const isPurchase = next === '已报名'
const confirmed = await this.confirmAction(
isPurchase ? '确认报名正价课' : '确认推进线索',
isPurchase
? `确认「${lead.parent}」已报名正价课?确认后会发放报名阶段推荐奖励,并更新推荐漏斗。`
: `将「${lead.parent}」从「${lead.stage}」推进到「${next}」,会影响转化统计。`,
)
if (!confirmed) return
const res = isPurchase ? await confirmLeadPurchase(lead.id) : await updateLeadStage(lead.id, next)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '线索更新失败', type: 'error' })
return
}
lead.stage = next
this.$refs.toast.show({ message: isPurchase ? '已确认报名并发放奖励' : `已推进到${next}`, type: 'success' })
},
confirmLogout() {
uni.showModal({
title: '确认退出登录',
content: '退出后需要重新登录当前账号,是否继续?',
confirmText: '退出',
confirmColor: '#b54732',
success: (res) => {
if (!res.confirm) return
clearStoredAccount()
uni.reLaunch({ url: '/pages/login/index' })
},
})
},
go(url) {
uni.navigateTo({ url })
},
goAdminPage(url) {
uni.redirectTo({ url })
},
confirmAction(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '确认',
cancelText: '取消',
confirmColor: '#0b4f46',
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
},
},
}
</script>
<style lang="scss" scoped>
.admin-page {
padding-bottom: 150rpx;
}
.denied-card,
.ops-card,
.leads-card {
margin-top: 22rpx;
padding: 28rpx;
}
.denied-card__title {
margin-bottom: 12rpx;
color: #17312d;
font-size: 34rpx;
font-weight: 900;
}
.denied-card .u-button {
margin-top: 28rpx;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
margin-top: 18rpx;
}
.metric-card {
min-height: 150rpx;
padding: 24rpx;
}
.metric-card text:first-child {
color: #6f7c78;
font-size: 22rpx;
font-weight: 800;
}
.metric-card text:last-child {
display: block;
margin-top: 16rpx;
color: #0b4f46;
font-size: 44rpx;
font-weight: 900;
}
.ops-bar {
margin-top: 22rpx;
}
.ops-bar__head {
display: flex;
justify-content: space-between;
margin-bottom: 10rpx;
color: #17312d;
font-size: 24rpx;
font-weight: 800;
}
.ops-bar__track {
height: 16rpx;
overflow: hidden;
border-radius: 999rpx;
background: #e2ebde;
}
.ops-bar__fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #17bebb, #ff7a59);
}
.lead-row {
display: flex;
justify-content: space-between;
gap: 18rpx;
padding: 26rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.lead-row:last-child {
border-bottom: 0;
}
.lead-row__name {
color: #17312d;
font-size: 28rpx;
font-weight: 900;
}
.lead-row__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
}
.lead-row__risk {
display: inline-flex;
margin-top: 12rpx;
padding: 6rpx 12rpx;
border-radius: 999rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 20rpx;
font-weight: 800;
}
.lead-row__stage {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12rpx;
}
.lead-row__stage button {
min-width: 92rpx;
height: 48rpx;
border-radius: 999rpx;
color: #ffffff;
background: #0b4f46;
font-size: 22rpx;
font-weight: 800;
}
.lead-row__stage button.is-disabled {
color: #82918b;
background: #dfe8dc;
}
.lead-row__stage button.is-purchase {
min-width: 132rpx;
background: #ff7a59;
}
.ghost-button {
min-width: 136rpx;
height: 52rpx;
border: 1rpx solid #b9d8d2;
border-radius: 999rpx;
color: #0b4f46;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
}
.point-hub,
.course-hub {
margin-top: 22rpx;
padding: 28rpx;
}
.point-hub__main {
display: flex;
align-items: center;
gap: 20rpx;
}
.point-hub__stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
margin-top: 24rpx;
}
.point-hub__stats view {
padding: 20rpx;
border-radius: 14rpx;
background: #f3f8ef;
}
.point-hub__stats text {
display: block;
}
.point-hub__stats text:first-child {
color: #0b4f46;
font-size: 34rpx;
font-weight: 900;
}
.point-hub__stats text:last-child {
margin-top: 6rpx;
color: #6f7c78;
font-size: 21rpx;
font-weight: 800;
}
.primary-row-button {
width: 100%;
height: 76rpx;
margin-top: 24rpx;
border-radius: 16rpx;
color: #ffffff;
background: #0b4f46;
font-size: 26rpx;
font-weight: 900;
}
</style>

@ -0,0 +1,236 @@
<template>
<view class="campaign-page lf-page--light">
<view class="campaign-hero">
<view class="lf-cube-mark">和</view>
<view class="campaign-hero__label">来自 {{ inviterCode }} 的成长邀请</view>
<view class="campaign-hero__title">领取试听积分,给孩子选一节真正适配的课</view>
<view class="campaign-hero__sub">注册、预约、到课后双方都获得积分。积分可兑换体验课、测评、课程抵扣。</view>
</view>
<view class="lf-card form-card">
<view class="lf-section-title">领取新家长积分</view>
<input v-model="form.parentName" class="form-input" placeholder="家长姓名" />
<input v-model="form.phone" class="form-input" placeholder="手机号" type="number" />
<view class="code-row">
<input v-model="form.code" class="form-input code-row__input" placeholder="短信验证码" type="number" maxlength="6" />
<button class="code-button" :disabled="countdown > 0" @click="requestCode">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</button>
</view>
<input v-model="form.childName" class="form-input" placeholder="孩子姓名" />
<input v-model="form.grade" class="form-input" placeholder="孩子年级" />
<u-button color="#0b4f46" shape="circle" text="立即领取并预约" @click="submit"></u-button>
</view>
<view class="benefit-strip">
<view>
<text>20</text>
<text>注册积分</text>
</view>
<view>
<text>50</text>
<text>预约积分</text>
</view>
<view>
<text>100</text>
<text>到课积分</text>
</view>
</view>
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import { bindReferral, sendSmsCode } from '../../services/educationApi'
import { setStoredAccount } from '../../services/accountSession'
export default {
data() {
return {
inviterCode: '',
countdown: 0,
timer: null,
form: {
parentName: '',
phone: '',
code: '',
childName: '',
grade: '',
},
}
},
onLoad(query) {
this.inviterCode = query?.code || query?.inviteCode || ''
},
onUnload() {
if (this.timer) clearInterval(this.timer)
},
methods: {
async requestCode() {
if (!/^1\d{10}$/.test(this.form.phone)) {
this.$refs.toast.show({ message: '请先输入正确手机号', type: 'error' })
return
}
const res = await sendSmsCode(this.form.phone, 'referral_register')
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '验证码发送失败', type: 'error' })
return
}
const data = res.data || res
this.startCountdown(data.cooldown || 60)
this.$refs.toast.show({ message: res.message || data.message || '验证码已发送', type: 'success' })
},
startCountdown(seconds) {
if (this.timer) clearInterval(this.timer)
this.countdown = Number(seconds || 60)
this.timer = setInterval(() => {
this.countdown -= 1
if (this.countdown <= 0) {
clearInterval(this.timer)
this.timer = null
}
}, 1000)
},
async submit() {
if (!this.inviterCode) {
this.$refs.toast.show({ message: '推荐链接无效', type: 'error' })
return
}
if (!this.form.parentName.trim()) {
this.$refs.toast.show({ message: '请填写家长姓名', type: 'error' })
return
}
if (!/^1\d{10}$/.test(this.form.phone)) {
this.$refs.toast.show({ message: '请输入正确手机号', type: 'error' })
return
}
if (!this.form.code) {
this.$refs.toast.show({ message: '请输入短信验证码', type: 'error' })
return
}
const res = await bindReferral({ ...this.form, inviterCode: this.inviterCode })
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '领取失败', type: 'error' })
return
}
const data = res.data || res
if (data.invitee) setStoredAccount(data.invitee)
this.$refs.toast.show({ message: '注册成功,积分已到账', type: 'success' })
setTimeout(() => uni.navigateTo({ url: '/pages/courses/index' }), 800)
},
},
}
</script>
<style lang="scss" scoped>
.campaign-page {
min-height: 100vh;
padding: calc(48rpx + var(--status-bar-height)) 28rpx 48rpx;
}
.campaign-hero {
padding-top: 32rpx;
}
.campaign-hero__label {
margin-top: 34rpx;
color: #ff7a59;
font-size: 24rpx;
font-weight: 900;
}
.campaign-hero__title {
margin-top: 20rpx;
color: #0b2f2a;
font-size: 54rpx;
font-weight: 900;
line-height: 1.16;
}
.campaign-hero__sub {
margin-top: 22rpx;
color: #56645f;
font-size: 26rpx;
line-height: 1.7;
}
.form-card {
margin-top: 36rpx;
padding: 30rpx;
}
.form-input {
height: 88rpx;
margin: 22rpx 0;
padding: 0 24rpx;
border: 1rpx solid #dfe9dc;
border-radius: 14rpx;
color: #17312d;
background: #f8faf5;
font-size: 28rpx;
line-height: 88rpx;
}
.code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 190rpx;
align-items: center;
gap: 16rpx;
margin: 22rpx 0;
}
.code-row__input {
width: auto;
min-width: 0;
margin: 0;
}
.code-button {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
margin: 0;
padding: 0 12rpx;
border: 1rpx solid #b9d8d2;
border-radius: 14rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 24rpx;
font-weight: 900;
line-height: normal;
}
.code-button[disabled] {
color: #7d8b86;
background: #eef3ec;
}
.benefit-strip {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
margin-top: 22rpx;
}
.benefit-strip view {
min-height: 136rpx;
padding: 22rpx 12rpx;
border-radius: 16rpx;
text-align: center;
background: #0b2f2a;
}
.benefit-strip text:first-child {
display: block;
color: #f5c451;
font-size: 38rpx;
font-weight: 900;
}
.benefit-strip text:last-child {
color: rgba(255, 255, 255, 0.7);
font-size: 21rpx;
}
</style>

@ -0,0 +1,277 @@
<template>
<view class="lf-page list-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">可约课程</view>
<view class="lf-nav__sub">搜索课程、老师和校区,滚动加载更多</view>
</view>
<view class="lf-pill">{{ total }} 门</view>
</view>
<view class="lf-card filter-card">
<view class="search-box">
<u-icon name="search" color="#6f7c78" size="22"></u-icon>
<input v-model="filters.keyword" placeholder="搜索课程、老师、校区" confirm-type="search" @confirm="refresh" />
</view>
<button class="query-button" @click="refresh">查询</button>
<view class="quota-summary">试听额度 {{ trialBookingLimit }} 次 · 已使用 {{ activeAppointments.length }} 次 · 剩余 {{ remainingBookings }} 次</view>
</view>
<view v-for="course in list" :key="course.id" class="lf-card course-card">
<view class="course-card__top">
<view>
<view class="course-card__name">{{ course.name }}</view>
<view class="course-card__meta">{{ course.nextTime }} · {{ course.campus || '-' }} · {{ course.teacher || '-' }}</view>
</view>
<view class="course-card__points">+{{ course.pointsBack || 0 }}</view>
</view>
<view class="course-card__tags">
<text v-for="tag in course.tags" :key="tag">{{ tag }}</text>
</view>
<u-button
:color="isCourseUnavailable(course) ? '#b7c6bf' : '#0b4f46'"
:disabled="isCourseUnavailable(course)"
shape="circle"
:text="courseButtonText(course)"
@click="book(course)"
></u-button>
</view>
<view class="load-state">{{ loadText }}</view>
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import { bookCourse, getCoursesPage, getMyAppointments } from '../../services/educationApi'
import { getStoredAccount, refreshStoredAccount } from '../../services/accountSession'
export default {
data() {
return {
account: null,
list: [],
appointments: [],
total: 0,
loading: false,
finished: false,
filters: {
keyword: '',
pageNum: 1,
pageSize: 10,
},
}
},
computed: {
loadText() {
if (this.loading) return '加载中...'
if (this.finished) return this.list.length ? '没有更多了' : '暂无课程'
return '上拉加载更多'
},
activeAppointments() {
return this.appointments.filter((item) => item.status !== 'cancelled')
},
trialBookingLimit() {
const limit = Number(this.account?.trialBookingLimit ?? 1)
return Number.isInteger(limit) && limit >= 0 ? limit : 1
},
remainingBookings() {
return Math.max(0, this.trialBookingLimit - this.activeAppointments.length)
},
hasReachedLimit() {
return this.remainingBookings <= 0
},
bookedCourseIds() {
return this.activeAppointments.map((item) => item.courseId)
},
},
async onShow() {
await this.refresh()
},
async onPullDownRefresh() {
await this.refresh()
uni.stopPullDownRefresh()
},
onReachBottom() {
this.loadMore()
},
methods: {
async refresh() {
const account = await refreshStoredAccount()
if (account?.id) this.account = account
this.filters.pageNum = 1
this.finished = false
await this.load(true)
await this.loadAppointments()
},
async loadMore() {
if (this.loading || this.finished) return
this.filters.pageNum += 1
await this.load(false)
},
async load(reset) {
this.loading = true
try {
const res = await getCoursesPage(this.filters)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '课程加载失败', type: 'error' })
return
}
const data = res.data || res
this.total = data.total || 0
this.list = reset ? (data.list || []) : this.list.concat(data.list || [])
this.finished = this.list.length >= this.total
} finally {
this.loading = false
}
},
async loadAppointments() {
const user = this.account || getStoredAccount()
if (!user?.id) {
this.appointments = []
return
}
const res = await getMyAppointments(user.id)
if (res.ok !== false) this.appointments = res.data || res || []
},
async book(course) {
if (this.isBookedCourse(course)) {
this.$refs.toast.show({ message: '该试听课程已经预约', type: 'default' })
return
}
if (this.hasReachedLimit) {
this.$refs.toast.show({ message: this.trialBookingLimit ? `当前账号最多可预约 ${this.trialBookingLimit} 次试听课` : '当前账号暂不可预约试听课', type: 'default' })
return
}
const user = getStoredAccount()
if (!user) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
const confirmed = await this.confirmAction('确认预约试听', `预约「${course.name}」${course.nextTime} 的试听课?`)
if (!confirmed) return
const res = await bookCourse({ userId: user.id, childId: user.child?.id || '', courseId: course.id })
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '预约失败', type: 'error' })
return
}
this.$refs.toast.show({ message: '预约成功', type: 'success' })
await this.refresh()
},
isBookedCourse(course) {
return this.bookedCourseIds.includes(course.id)
},
isCourseUnavailable(course) {
return this.isBookedCourse(course) || this.hasReachedLimit
},
courseButtonText(course) {
if (this.isBookedCourse(course)) return '已预约'
if (this.hasReachedLimit) return this.trialBookingLimit ? '试听次数已用完' : '暂不可预约'
return '预约试听'
},
confirmAction(title, content) {
return new Promise((resolve) => {
uni.showModal({ title, content, confirmText: '确认', cancelText: '取消', confirmColor: '#0b4f46', success: (res) => resolve(Boolean(res.confirm)), fail: () => resolve(false) })
})
},
},
}
</script>
<style lang="scss" scoped>
.filter-card,
.course-card {
margin-top: 18rpx;
padding: 28rpx;
}
.search-box {
display: flex;
align-items: center;
gap: 12rpx;
height: 74rpx;
padding: 0 22rpx;
border-radius: 16rpx;
background: #f3f8ef;
}
.search-box input {
flex: 1;
color: #17312d;
font-size: 26rpx;
}
.query-button {
width: 100%;
height: 68rpx;
margin-top: 18rpx;
border-radius: 16rpx;
color: #ffffff;
background: #0b4f46;
font-size: 25rpx;
font-weight: 900;
}
.quota-summary {
margin-top: 18rpx;
color: #49635d;
font-size: 22rpx;
line-height: 1.5;
text-align: center;
}
.course-card__top {
display: flex;
justify-content: space-between;
gap: 18rpx;
}
.course-card__name {
color: #17312d;
font-size: 31rpx;
font-weight: 900;
}
.course-card__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.45;
}
.course-card__points {
flex: 0 0 auto;
min-width: 94rpx;
height: 54rpx;
border-radius: 14rpx;
color: #0b2f2a;
background: #f5c451;
font-size: 24rpx;
font-weight: 900;
line-height: 54rpx;
text-align: center;
}
.course-card__tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin: 18rpx 0;
}
.course-card__tags text {
padding: 8rpx 14rpx;
border-radius: 999rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 20rpx;
font-weight: 800;
}
.load-state {
padding: 28rpx 0 80rpx;
color: #6f7c78;
font-size: 23rpx;
text-align: center;
}
</style>

@ -0,0 +1,352 @@
<template>
<view class="lf-page courses-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">课程预约</view>
<view class="lf-nav__sub">到课后触发双方积分到账</view>
</view>
<view class="lf-pill">试听</view>
</view>
<view v-if="appointments.length" class="lf-card my-booking-card">
<view class="lf-row">
<view>
<view class="lf-section-title">我的预约</view>
<view class="lf-caption">可预约 {{ trialBookingLimit }} 次,已使用 {{ activeAppointments.length }} 次,剩余 {{ remainingBookings }} 次</view>
</view>
<view class="lf-pill">{{ appointmentStatusLabel(appointments[0].status) }}</view>
</view>
<view v-for="item in appointments" :key="item.id" class="booking-row">
<view>
<view class="booking-row__name">{{ item.course?.name || '试听课程' }}</view>
<view class="booking-row__meta">{{ item.course?.nextTime || '-' }} · {{ item.course?.campus || '-' }} · {{ item.course?.teacher || '-' }}</view>
</view>
<view class="booking-row__badge">{{ appointmentStatusLabel(item.status) }}</view>
</view>
</view>
<view class="lf-card timeline-card">
<view class="lf-row">
<view>
<view class="lf-section-title">本周可约课程</view>
<view class="lf-caption">首页展示前 3 条 · 剩余 {{ remainingBookings }} 次试听预约</view>
</view>
<button class="more-button" @click="go('/pages/courses-list/index')">查看更多</button>
</view>
<view v-for="course in previewCourses" :key="course.id" class="course-card">
<view class="course-card__head">
<view>
<view class="course-card__name">{{ course.name }}</view>
<view class="course-card__time">{{ course.nextTime }} · 余 {{ course.seats }} 席</view>
</view>
<view class="course-card__points">+{{ course.pointsBack }}</view>
</view>
<view class="course-card__tags">
<text v-for="tag in course.tags" :key="tag">{{ tag }}</text>
</view>
<u-button
:color="isCourseUnavailable(course) ? '#b7c6bf' : '#0b4f46'"
:disabled="isCourseUnavailable(course)"
shape="circle"
:text="courseButtonText(course)"
@click="book(course)"
></u-button>
</view>
</view>
<view class="lf-card confirm-card">
<view class="lf-section-title">课程兑换说明</view>
<view class="confirm-grid">
<view>
<text>1</text>
<text>积分兑换课程券</text>
</view>
<view>
<text>2</text>
<text>顾问确认适配班型</text>
</view>
<view>
<text>3</text>
<text>到课后自动核销</text>
</view>
</view>
</view>
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import { bookCourse, getCourses, getMyAppointments } from '../../services/educationApi'
import { getStoredAccount, refreshStoredAccount } from '../../services/accountSession'
export default {
data() {
return {
account: null,
courses: [],
appointments: [],
}
},
computed: {
activeAppointments() {
return this.appointments.filter((item) => item.status !== 'cancelled')
},
trialBookingLimit() {
const limit = Number(this.account?.trialBookingLimit ?? 1)
return Number.isInteger(limit) && limit >= 0 ? limit : 1
},
remainingBookings() {
return Math.max(0, this.trialBookingLimit - this.activeAppointments.length)
},
hasReachedLimit() {
return this.remainingBookings <= 0
},
bookedCourseIds() {
return this.activeAppointments.map((item) => item.courseId)
},
previewCourses() {
return this.courses.slice(0, 3)
},
},
async onShow() {
await this.load()
},
async onPullDownRefresh() {
await this.load()
uni.stopPullDownRefresh()
},
methods: {
async load() {
const user = await refreshStoredAccount()
if (!user?.id) {
this.account = null
this.appointments = []
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.account = user
const [res, appointmentRes] = await Promise.all([getCourses(), getMyAppointments(user.id)])
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '课程加载失败', type: 'error' })
return
}
this.courses = res.data || res || this.courses
if (appointmentRes.ok === false) {
this.$refs.toast.show({ message: appointmentRes.message || '预约记录加载失败', type: 'error' })
return
}
this.appointments = appointmentRes.data || appointmentRes || []
},
async book(course) {
if (this.isBookedCourse(course)) {
this.$refs.toast.show({ message: '该试听课程已经预约', type: 'default' })
return
}
if (this.hasReachedLimit) {
this.$refs.toast.show({ message: this.trialBookingLimit ? `当前账号最多可预约 ${this.trialBookingLimit} 次试听课` : '当前账号暂不可预约试听课', type: 'default' })
return
}
const confirmed = await this.confirmAction(
'确认预约试听',
`预约「${course.name}」${course.nextTime} 的试听课,到课后会触发积分到账。`,
)
if (!confirmed) return
const user = getStoredAccount()
if (!user) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
const res = await bookCourse({
userId: user.id,
childId: user.child?.id || '',
courseId: course.id,
})
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '预约失败', type: 'error' })
return
}
this.$refs.toast.show({ message: '预约成功,待到课确认', type: 'success' })
this.load()
},
isBookedCourse(course) {
return this.bookedCourseIds.includes(course.id)
},
isCourseUnavailable(course) {
return this.isBookedCourse(course) || this.hasReachedLimit
},
courseButtonText(course) {
if (this.isBookedCourse(course)) return '已预约'
if (this.hasReachedLimit) return this.trialBookingLimit ? '试听次数已用完' : '暂不可预约'
return '预约试听'
},
appointmentStatusLabel(status) {
const labels = {
booked: '已预约',
attended: '已到课',
cancelled: '已取消',
}
return labels[status] || status || '已预约'
},
go(url) {
uni.navigateTo({ url })
},
confirmAction(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '确认',
cancelText: '取消',
confirmColor: '#0b4f46',
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
},
},
}
</script>
<style lang="scss" scoped>
.timeline-card,
.my-booking-card,
.confirm-card {
margin-top: 18rpx;
padding: 28rpx;
}
.booking-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
margin-top: 22rpx;
padding: 22rpx;
border-radius: 14rpx;
background: #f3f8ef;
}
.booking-row__name {
color: #17312d;
font-size: 30rpx;
font-weight: 900;
}
.booking-row__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.45;
}
.booking-row__badge {
flex: 0 0 auto;
padding: 8rpx 14rpx;
border-radius: 999rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 20rpx;
font-weight: 900;
}
.course-card {
margin-top: 24rpx;
padding: 26rpx;
border: 1rpx solid #e4ecdf;
border-radius: 16rpx;
background: #fbfcf7;
}
.more-button {
min-width: 132rpx;
height: 52rpx;
border: 1rpx solid #b9d8d2;
border-radius: 999rpx;
color: #0b4f46;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
line-height: 52rpx;
}
.course-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.course-card__name {
color: #17312d;
font-size: 32rpx;
font-weight: 900;
}
.course-card__time {
margin-top: 10rpx;
color: #6f7c78;
font-size: 23rpx;
}
.course-card__points {
min-width: 94rpx;
padding: 12rpx 14rpx;
border-radius: 12rpx;
color: #0b2f2a;
text-align: center;
background: #f5c451;
font-size: 24rpx;
font-weight: 900;
}
.course-card__tags {
display: flex;
gap: 12rpx;
margin: 22rpx 0;
}
.course-card__tags text {
padding: 8rpx 14rpx;
border-radius: 999rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 20rpx;
font-weight: 800;
}
.confirm-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14rpx;
margin-top: 24rpx;
}
.confirm-grid view {
min-height: 150rpx;
padding: 18rpx;
border-radius: 14rpx;
background: #eef5e9;
}
.confirm-grid text:first-child {
display: grid;
place-items: center;
width: 42rpx;
height: 42rpx;
border-radius: 12rpx;
color: #ffffff;
background: #0b4f46;
font-size: 20rpx;
font-weight: 900;
}
.confirm-grid text:last-child {
display: block;
margin-top: 16rpx;
color: #17312d;
font-size: 22rpx;
font-weight: 800;
line-height: 1.45;
}
</style>

@ -0,0 +1,457 @@
<template>
<view class="lf-page home-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">立方和教育</view>
<view class="lf-nav__sub">{{ isAdmin ? '账号权限决定管理入口' : '推荐、到课、积分兑换一条线' }}</view>
</view>
<view class="lf-cube-mark">和</view>
</view>
<view v-if="isAdmin" class="hero lf-card lf-card--ink">
<view class="hero__eyebrow">{{ currentAccount.roleName }}</view>
<view class="hero__title">欢迎回来,{{ currentAccount.name }}</view>
<view class="hero__sub">今日线索 {{ admin.overview.leads }} · 到课 {{ admin.overview.attended }} · 已发放积分 {{ admin.overview.pointsCost }}</view>
<view class="admin-grid">
<view class="admin-grid__cell">
<text>报名成交</text>
<text>{{ admin.overview.purchased }}</text>
</view>
<view class="admin-grid__cell">
<text>营收</text>
<text>¥{{ admin.overview.revenue }}</text>
</view>
</view>
<view class="hero__actions">
<u-button color="#f5c451" shape="circle" text="移动管理台" @click="go('/pages/admin/index')"></u-button>
</view>
</view>
<view v-else class="hero lf-card lf-card--ink">
<view class="hero__top">
<view>
<view class="hero__eyebrow">成长账本</view>
<view class="hero__title">{{ profile.name }}</view>
<view class="hero__sub">{{ profile.child.name }} · {{ profile.child.grade }} · {{ profile.child.focus }}</view>
</view>
<view class="hero__points">
<text>{{ pointAccount.available }}</text>
<text>可用积分</text>
</view>
</view>
<view class="hero__point-grid">
<view>
<text>{{ pointAccount.pending }}</text>
<text>待到账</text>
</view>
<view>
<text>{{ pointAccount.expiring }}</text>
<text>将过期</text>
</view>
<view>
<text>{{ pointAccount.frozen }}</text>
<text>冻结</text>
</view>
</view>
<view class="hero__track">
<view class="hero__track-node">分享</view>
<view class="hero__track-line"></view>
<view class="hero__track-node">预约</view>
<view class="hero__track-line"></view>
<view class="hero__track-node">到课</view>
<view class="hero__track-line"></view>
<view class="hero__track-node hero__track-node--hot">兑换</view>
</view>
</view>
<view v-if="!isAdmin" class="quick-grid">
<view class="quick-card lf-card" @click="go('/pages/referral/index')">
<u-icon name="share-square" color="#0b4f46" size="28"></u-icon>
<text>生成推荐</text>
<text>朋友圈 / 微信好友 / 海报</text>
</view>
<view class="quick-card lf-card" @click="go('/pages/points/index')">
<u-icon name="coupon" color="#ff7a59" size="28"></u-icon>
<text>积分兑换</text>
<text>{{ pointAccount.pending }} 待到账</text>
</view>
<view class="quick-card lf-card" @click="go('/pages/courses/index')">
<u-icon name="calendar" color="#17bebb" size="28"></u-icon>
<text>预约试听</text>
<text>{{ courses[0]?.nextTime || '本周可约' }}</text>
</view>
</view>
<view v-if="!isAdmin" class="lf-card funnel-card">
<view class="lf-row">
<view class="lf-section-title">我的推荐漏斗</view>
<view class="lf-pill">转化 {{ stats.conversion }}%</view>
</view>
<view class="funnel-row">
<view v-for="item in funnel" :key="item.label" class="funnel-step">
<text>{{ item.value }}</text>
<text>{{ item.label }}</text>
</view>
</view>
</view>
<view v-else class="lf-card funnel-card">
<view class="lf-row">
<view class="lf-section-title">全校区转化链路</view>
<view class="lf-pill">按账号权限过滤</view>
</view>
<view class="bar-list">
<view v-for="item in admin.funnel" :key="item.label" class="bar-item">
<view class="bar-item__head">
<text>{{ item.label }}</text>
<text>{{ item.value }}</text>
</view>
<view class="bar-item__track">
<view class="bar-item__fill" :style="{ width: `${Math.max(12, item.value / admin.funnel[0].value * 100)}%` }"></view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { getBootstrap } from '../../services/educationApi'
import { getStoredAccount, isAdminAccount, refreshStoredAccount, setStoredAccount } from '../../services/accountSession'
const emptyPoints = { available: 0, pending: 0, frozen: 0, expiring: 0 }
const emptyProfile = { name: '', child: { name: '', grade: '', focus: '' }, points: emptyPoints }
const emptyStats = { invited: 0, registered: 0, booked: 0, attended: 0, purchased: 0, conversion: 0 }
const emptyAdmin = { overview: { leads: 0, attended: 0, purchased: 0, pointsCost: 0, revenue: 0 }, funnel: [], leads: [] }
function toNumber(value) {
const number = Number(value)
return Number.isFinite(number) ? number : 0
}
function normalizePoints(points = {}) {
return {
available: toNumber(points.available),
pending: toNumber(points.pending),
frozen: toNumber(points.frozen),
expiring: toNumber(points.expiring),
}
}
function normalizeStats(stats = {}) {
return {
invited: toNumber(stats.invited),
registered: toNumber(stats.registered),
booked: toNumber(stats.booked),
attended: toNumber(stats.attended),
purchased: toNumber(stats.purchased),
conversion: toNumber(stats.conversion),
}
}
export default {
data() {
return {
currentAccount: getStoredAccount(),
profile: emptyProfile,
stats: emptyStats,
courses: [],
admin: emptyAdmin,
}
},
computed: {
isAdmin() {
return isAdminAccount(this.currentAccount)
},
pointAccount() {
return normalizePoints(this.profile.points)
},
funnel() {
return [
{ label: '邀请', value: this.stats.invited },
{ label: '注册', value: this.stats.registered },
{ label: '预约', value: this.stats.booked },
{ label: '到课', value: this.stats.attended },
{ label: '报名', value: this.stats.purchased },
]
},
},
async onShow() {
this.currentAccount = await refreshStoredAccount()
if (!this.currentAccount) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
if (this.isAdmin) {
uni.reLaunch({ url: '/pages/admin/index' })
return
}
await this.load()
},
async onPullDownRefresh() {
await this.load()
uni.stopPullDownRefresh()
},
methods: {
async load() {
const res = await getBootstrap(this.currentAccount.id)
if (res.ok === false) return
const data = res.data || res
const nextProfile = data.profile || {}
this.currentAccount = setStoredAccount(nextProfile)
const nextPoints = normalizePoints(data.points || nextProfile.points || this.profile.points)
this.profile = {
...emptyProfile,
...nextProfile,
child: { ...emptyProfile.child, ...(nextProfile.child || {}) },
points: nextPoints,
}
this.stats = normalizeStats(data.referralStats || data.stats || this.stats)
this.courses = data.courses || this.courses
this.admin = data.admin || this.admin
},
go(url) {
uni.navigateTo({ url })
},
},
}
</script>
<style lang="scss" scoped>
.home-page {
padding-bottom: 80rpx;
}
.hero {
margin-top: 18rpx;
padding: 32rpx;
}
.hero__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
}
.hero__eyebrow {
color: #9ae9dc;
font-size: 22rpx;
font-weight: 800;
}
.hero__title {
margin-top: 10rpx;
font-size: 42rpx;
font-weight: 900;
line-height: 1.22;
}
.hero__sub {
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 24rpx;
line-height: 1.55;
}
.hero__points {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.hero__points text:first-child {
color: #f5c451;
font-size: 52rpx;
font-weight: 900;
}
.hero__points text:last-child {
color: rgba(255, 255, 255, 0.68);
font-size: 22rpx;
}
.hero__point-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
margin-top: 26rpx;
}
.hero__point-grid view {
padding: 16rpx 12rpx;
border: 1rpx solid rgba(255, 255, 255, 0.12);
border-radius: 14rpx;
background: rgba(255, 255, 255, 0.08);
text-align: center;
}
.hero__point-grid text {
display: block;
}
.hero__point-grid text:first-child {
color: #ffffff;
font-size: 28rpx;
font-weight: 900;
}
.hero__point-grid text:last-child {
margin-top: 4rpx;
color: rgba(255, 255, 255, 0.64);
font-size: 20rpx;
font-weight: 800;
}
.hero__track {
display: flex;
align-items: center;
margin-top: 34rpx;
}
.hero__track-node {
min-width: 82rpx;
padding: 14rpx 12rpx;
border: 1rpx solid rgba(255, 255, 255, 0.18);
border-radius: 12rpx;
color: #ffffff;
text-align: center;
font-size: 22rpx;
font-weight: 800;
background: rgba(255, 255, 255, 0.08);
}
.hero__track-node--hot {
color: #0b2f2a;
background: #f5c451;
}
.hero__track-line {
flex: 1;
height: 2rpx;
background: rgba(255, 255, 255, 0.22);
}
.hero__actions {
display: block;
margin-top: 28rpx;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
margin-top: 22rpx;
}
.quick-card {
min-height: 190rpx;
padding: 22rpx 18rpx;
}
.quick-card text:nth-child(2) {
display: block;
margin-top: 18rpx;
color: #17312d;
font-size: 26rpx;
font-weight: 900;
}
.quick-card text:nth-child(3) {
display: block;
margin-top: 8rpx;
color: #6f7c78;
font-size: 20rpx;
line-height: 1.4;
}
.funnel-card {
margin-top: 22rpx;
padding: 28rpx;
}
.funnel-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10rpx;
margin-top: 28rpx;
}
.funnel-step {
min-height: 118rpx;
padding: 16rpx 8rpx;
border-radius: 12rpx;
background: #eef5e9;
text-align: center;
}
.funnel-step text:first-child {
display: block;
color: #0b4f46;
font-size: 34rpx;
font-weight: 900;
}
.funnel-step text:last-child {
color: #6f7c78;
font-size: 20rpx;
}
.admin-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
margin-top: 28rpx;
}
.admin-grid__cell {
padding: 20rpx;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.08);
}
.admin-grid__cell text {
display: block;
}
.admin-grid__cell text:first-child {
color: rgba(255, 255, 255, 0.62);
font-size: 22rpx;
}
.admin-grid__cell text:last-child {
margin-top: 8rpx;
font-size: 34rpx;
font-weight: 900;
}
.bar-list {
margin-top: 24rpx;
}
.bar-item {
margin-bottom: 20rpx;
}
.bar-item__head {
display: flex;
justify-content: space-between;
margin-bottom: 10rpx;
color: #17312d;
font-size: 24rpx;
font-weight: 800;
}
.bar-item__track {
height: 16rpx;
overflow: hidden;
border-radius: 999rpx;
background: #e2ebde;
}
.bar-item__fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #17bebb, #ff7a59);
}
</style>

@ -0,0 +1,425 @@
<template>
<view class="lf-page login-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">账号登录</view>
<view class="lf-nav__sub">进入立方和教育推广积分系统</view>
</view>
<view class="lf-cube-mark">和</view>
</view>
<view class="lf-card login-card">
<view class="login-card__head">
<view>
<view class="lf-section-title">{{ wechatPending ? '绑定手机号' : '选择登录方式' }}</view>
<view class="login-card__hint">
{{ wechatPending ? '微信登录后需要绑定手机号才能继续使用' : '管理员和家长会按账号角色进入不同界面' }}
</view>
</view>
</view>
<view v-if="!wechatPending" class="mode-tabs">
<view :class="['mode-tab', loginMode === 'phone' && 'mode-tab--active']" @click="loginMode = 'phone'">
手机号登录
</view>
<view :class="['mode-tab', loginMode === 'password' && 'mode-tab--active']" @click="loginMode = 'password'">
账号密码
</view>
</view>
<template v-if="loginMode === 'password' && !wechatPending">
<view class="form-field">
<text>账号</text>
<input v-model="passwordForm.account" maxlength="40" placeholder="请输入账号 / 手机号" confirm-type="next" />
</view>
<view class="form-field">
<text>密码</text>
<input v-model="passwordForm.password" password maxlength="32" placeholder="请输入密码" confirm-type="done" @confirm="submit" />
</view>
</template>
<template v-else>
<view class="form-field">
<text>手机号</text>
<input v-model="form.phone" type="number" maxlength="11" placeholder="请输入手机号" confirm-type="next" />
</view>
<view class="form-field form-field--code">
<view class="code-input">
<text>验证码</text>
<input v-model="form.code" type="number" maxlength="6" placeholder="请输入验证码" confirm-type="done" @confirm="submit" />
</view>
<button class="code-button" :disabled="countdown > 0" @click="requestCode">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</button>
</view>
<view v-if="wechatPending" class="form-field">
<text>姓名</text>
<input v-model="form.name" maxlength="20" placeholder="请输入家长姓名" confirm-type="done" />
</view>
<view class="form-field">
<text>邀请码(选填)</text>
<input v-model="form.inviterCode" maxlength="20" placeholder="新用户可填写邀请码领取积分" confirm-type="done" />
</view>
</template>
<button class="login-button" :class="loading && 'login-button--loading'" @click="submit">
{{ submitText }}
</button>
<!-- #ifdef MP-WEIXIN -->
<view v-if="!wechatPending" class="login-divider"><text>或</text></view>
<button v-if="!wechatPending" class="wechat-button" :disabled="loading" @click="loginByWechat">
<u-icon name="weixin-fill" color="#ffffff" size="20"></u-icon>
<text>微信登录</text>
</button>
<!-- #endif -->
<view class="login-note">验证码将通过短信发送,请在有效期内完成登录或绑定。</view>
</view>
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import { bindWechatPhone, loginWithPassword, loginWithPhone, loginWithWechat, sendSmsCode } from '../../services/educationApi'
import { isAdminAccount, setStoredAccount } from '../../services/accountSession'
export default {
data() {
return {
loading: false,
countdown: 0,
timer: null,
wechatPending: false,
loginMode: 'phone',
form: {
phone: '',
code: '',
name: '',
inviterCode: '',
},
passwordForm: {
account: '',
password: '',
},
}
},
computed: {
submitText() {
if (this.loading) return '处理中'
if (this.wechatPending) return '绑定并登录'
return this.loginMode === 'password' ? '账号密码登录' : '手机号登录'
},
},
onLoad(query = {}) {
const inviterCode = this.resolveInviteCode(query)
if (inviterCode) this.form.inviterCode = inviterCode
},
onUnload() {
if (this.timer) clearInterval(this.timer)
},
methods: {
async requestCode() {
if (!/^1\d{10}$/.test(this.form.phone)) {
this.$refs.toast.show({ message: '请先输入正确手机号', type: 'error' })
return
}
const purpose = this.wechatPending ? 'bind_wechat' : 'login'
const res = await sendSmsCode(this.form.phone, purpose)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '验证码发送失败', type: 'error' })
return
}
const data = res.data || res
this.startCountdown(data.cooldown || 60)
this.$refs.toast.show({ message: res.message || data.message || '验证码已发送', type: 'success' })
},
startCountdown(seconds = 60) {
if (this.timer) clearInterval(this.timer)
this.countdown = Number(seconds || 60)
this.timer = setInterval(() => {
this.countdown -= 1
if (this.countdown <= 0) {
clearInterval(this.timer)
this.timer = null
}
}, 1000)
},
async submit() {
if (this.wechatPending) {
await this.submitPhoneForm(() => this.bindWechatPhone())
return
}
if (this.loginMode === 'password') {
await this.loginByPassword()
return
}
await this.submitPhoneForm(() => this.loginByPhone())
},
async submitPhoneForm(handler) {
if (!/^1\d{10}$/.test(this.form.phone)) {
this.$refs.toast.show({ message: '请输入正确手机号', type: 'error' })
return
}
if (!this.form.code) {
this.$refs.toast.show({ message: '请输入验证码', type: 'error' })
return
}
await handler()
},
async loginByPassword() {
if (!this.passwordForm.account.trim() || !this.passwordForm.password) {
this.$refs.toast.show({ message: '请输入账号和密码', type: 'error' })
return
}
this.loading = true
try {
const res = await loginWithPassword(this.passwordForm.account, this.passwordForm.password)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '登录失败', type: 'error' })
return
}
this.enterSystem(res.data || res)
} finally {
this.loading = false
}
},
async loginByPhone() {
this.loading = true
try {
const res = await loginWithPhone(this.form.phone, this.form.code, this.form.inviterCode)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '登录失败', type: 'error' })
return
}
this.enterSystem(res.data || res)
} finally {
this.loading = false
}
},
async loginByWechat() {
this.loading = true
try {
const wechatCode = await this.getWechatCode()
const res = await loginWithWechat(wechatCode)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '微信登录失败', type: 'error' })
return
}
const data = res.data || res
if (data.needPhoneBinding) {
this.wechatPending = true
this.loginMode = 'phone'
this.$refs.toast.show({ message: '请绑定手机号后继续', type: 'default' })
return
}
this.enterSystem(data.account)
} catch (error) {
this.$refs.toast.show({ message: error.message || '微信登录失败', type: 'error' })
} finally {
this.loading = false
}
},
async bindWechatPhone() {
this.loading = true
try {
const wechatCode = await this.getWechatCode()
const res = await bindWechatPhone({
wechatCode,
phone: this.form.phone,
code: this.form.code,
name: this.form.name,
inviterCode: this.form.inviterCode,
})
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '绑定失败', type: 'error' })
return
}
this.enterSystem(res.data || res)
} catch (error) {
this.$refs.toast.show({ message: error.message || '绑定失败', type: 'error' })
} finally {
this.loading = false
}
},
getWechatCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (result) => {
if (result.code) resolve(result.code)
else reject(new Error('未获取到微信登录 code'))
},
fail: () => reject(new Error('微信授权失败')),
})
})
},
enterSystem(account) {
setStoredAccount(account)
this.$refs.toast.show({ message: `已登录:${account.name}`, type: 'success' })
const target = isAdminAccount(account) ? '/pages/admin/index' : '/pages/index/index'
setTimeout(() => {
uni.reLaunch({ url: target })
}, 500)
},
resolveInviteCode(query = {}) {
if (query.inviteCode) return decodeURIComponent(query.inviteCode)
if (query.code) return decodeURIComponent(query.code)
if (!query.scene) return ''
const scene = decodeURIComponent(query.scene)
const pairs = scene.split('&').map((item) => item.split('='))
const match = pairs.find(([key]) => ['invite', 'inviteCode', 'code'].includes(key))
return match?.[1] || ''
},
},
}
</script>
<style lang="scss" scoped>
.login-card {
margin-top: 18rpx;
padding: 30rpx;
}
.login-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.login-card__hint {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.45;
}
.mode-tabs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8rpx;
margin-top: 24rpx;
padding: 8rpx;
border: 1rpx solid #dfe9dc;
border-radius: 18rpx;
background: #f8faf5;
}
.mode-tab {
display: flex;
align-items: center;
justify-content: center;
height: 64rpx;
border-radius: 14rpx;
color: #6f7c78;
font-size: 24rpx;
font-weight: 900;
line-height: normal;
}
.mode-tab--active {
color: #ffffff;
background: #0b4f46;
box-shadow: 0 10rpx 22rpx rgba(11, 79, 70, 0.18);
}
.form-field {
margin-top: 24rpx;
}
.form-field text {
display: block;
margin-bottom: 10rpx;
color: #6f7c78;
font-size: 22rpx;
font-weight: 900;
}
.form-field input {
height: 84rpx;
padding: 0 24rpx;
border: 1rpx solid #dfe9dc;
border-radius: 16rpx;
color: #17312d;
background: #f8faf5;
font-size: 28rpx;
line-height: 84rpx;
}
.form-field--code {
display: grid;
grid-template-columns: minmax(0, 1fr) 190rpx;
align-items: end;
gap: 16rpx;
}
.code-button,
.login-button,
.wechat-button {
display: flex;
align-items: center;
justify-content: center;
height: 84rpx;
border-radius: 18rpx;
font-size: 26rpx;
font-weight: 900;
line-height: normal;
}
.code-button {
color: #0b4f46;
border: 1rpx solid #b9d8d2;
background: #dff8ee;
}
.code-button[disabled] {
color: #7d8b86;
background: #eef3ec;
}
.login-button {
margin-top: 30rpx;
color: #ffffff;
background: #0b4f46;
}
.login-button--loading {
opacity: 0.72;
}
.login-divider {
display: flex;
align-items: center;
gap: 18rpx;
margin: 28rpx 0;
color: #8a9691;
font-size: 22rpx;
}
.login-divider::before,
.login-divider::after {
flex: 1;
height: 1rpx;
content: "";
background: #e2e9df;
}
.wechat-button {
gap: 10rpx;
color: #ffffff;
background: #1aad19;
}
.wechat-button text {
color: #ffffff;
}
.login-note {
margin-top: 18rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.5;
}
</style>

@ -0,0 +1,241 @@
<template>
<view class="lf-page records-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">{{ mode === 'redemptions' ? '兑换记录' : '积分流水' }}</view>
<view class="lf-nav__sub">分页查询,滚动加载更多</view>
</view>
<view class="lf-pill">{{ total }} 条</view>
</view>
<view class="lf-card filter-card">
<view class="mode-row">
<button :class="mode === 'transactions' && 'is-active'" @click="switchMode('transactions')">积分流水</button>
<button :class="mode === 'redemptions' && 'is-active'" @click="switchMode('redemptions')">兑换记录</button>
</view>
<view class="search-box">
<u-icon name="search" color="#6f7c78" size="22"></u-icon>
<input v-model="filters.keyword" placeholder="搜索事项、券码或权益名称" confirm-type="search" @confirm="refresh" />
</view>
<button class="query-button" @click="refresh">查询</button>
</view>
<view v-for="item in list" :key="item.id" class="lf-card record-card">
<template v-if="mode === 'transactions'">
<view>
<view class="record-card__name">{{ item.title }}</view>
<view class="record-card__meta">{{ item.createdAt }}</view>
</view>
<view class="record-card__right">
<strong :class="item.amount < 0 && 'is-minus'">{{ item.amount > 0 ? '+' : '' }}{{ item.amount }}</strong>
<text>{{ statusText(item.status) }}</text>
</view>
</template>
<template v-else>
<view>
<view class="record-card__name">{{ item.reward?.name || item.itemId }}</view>
<view class="record-card__meta">{{ item.createdAt }} · {{ item.costPoints }} 积分</view>
<view v-if="item.voucherCode" class="record-card__meta">券码 {{ item.voucherCode }}</view>
</view>
<view class="record-card__right">
<text>{{ item.statusLabel || item.status }}</text>
</view>
</template>
</view>
<view class="load-state">{{ loadText }}</view>
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import { POINT_STATUS } from '../../config'
import { getPointRedemptions, getPointTransactions } from '../../services/educationApi'
import { getStoredAccount, refreshStoredAccount } from '../../services/accountSession'
export default {
data() {
return {
mode: 'transactions',
list: [],
total: 0,
loading: false,
finished: false,
filters: {
keyword: '',
status: 'all',
pageNum: 1,
pageSize: 10,
},
}
},
computed: {
loadText() {
if (this.loading) return '加载中...'
if (this.finished) return this.list.length ? '没有更多了' : '暂无记录'
return '上拉加载更多'
},
},
onLoad(query = {}) {
if (query.mode === 'redemptions') this.mode = 'redemptions'
},
async onShow() {
await refreshStoredAccount()
await this.refresh()
},
async onPullDownRefresh() {
await this.refresh()
uni.stopPullDownRefresh()
},
onReachBottom() {
this.loadMore()
},
methods: {
switchMode(mode) {
this.mode = mode
this.refresh()
},
statusText(status) {
return POINT_STATUS[status] || status
},
async refresh() {
this.filters.pageNum = 1
this.finished = false
await this.load(true)
},
async loadMore() {
if (this.loading || this.finished) return
this.filters.pageNum += 1
await this.load(false)
},
async load(reset) {
const user = getStoredAccount()
if (!user) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.loading = true
try {
const loader = this.mode === 'redemptions' ? getPointRedemptions : getPointTransactions
const res = await loader(user.id, this.filters)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '记录加载失败', type: 'error' })
return
}
const data = res.data || res
this.total = data.total || 0
this.list = reset ? (data.list || []) : this.list.concat(data.list || [])
this.finished = this.list.length >= this.total
} finally {
this.loading = false
}
},
},
}
</script>
<style lang="scss" scoped>
.filter-card,
.record-card {
margin-top: 18rpx;
padding: 28rpx;
}
.mode-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12rpx;
}
.mode-row button,
.query-button {
height: 64rpx;
border-radius: 16rpx;
font-size: 24rpx;
font-weight: 900;
}
.mode-row button {
color: #0b4f46;
background: #eef8f4;
}
.mode-row button.is-active,
.query-button {
color: #ffffff;
background: #0b4f46;
}
.search-box {
display: flex;
align-items: center;
gap: 12rpx;
height: 74rpx;
margin-top: 18rpx;
padding: 0 22rpx;
border-radius: 16rpx;
background: #f3f8ef;
}
.search-box input {
flex: 1;
color: #17312d;
font-size: 26rpx;
}
.query-button {
width: 100%;
margin-top: 18rpx;
}
.record-card {
display: flex;
justify-content: space-between;
gap: 18rpx;
}
.record-card__name {
color: #17312d;
font-size: 29rpx;
font-weight: 900;
}
.record-card__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.45;
}
.record-card__right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10rpx;
}
.record-card__right strong {
color: #0b4f46;
font-size: 30rpx;
}
.record-card__right strong.is-minus {
color: #ff7a59;
}
.record-card__right text {
padding: 8rpx 14rpx;
border-radius: 999rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 20rpx;
font-weight: 900;
}
.load-state {
padding: 28rpx 0 80rpx;
color: #6f7c78;
font-size: 23rpx;
text-align: center;
}
</style>

@ -0,0 +1,258 @@
<template>
<view class="lf-page rewards-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">兑换权益</view>
<view class="lf-nav__sub">搜索可兑换课程、测评和服务权益</view>
</view>
<view class="lf-pill">{{ total }} 项</view>
</view>
<view class="lf-card filter-card">
<view class="search-box">
<u-icon name="search" color="#6f7c78" size="22"></u-icon>
<input v-model="filters.keyword" placeholder="搜索权益名称、类型" confirm-type="search" @confirm="refresh" />
</view>
<view class="chip-row">
<button v-for="item in stockOptions" :key="item.value" :class="['filter-chip', filters.stockStatus === item.value && 'filter-chip--active']" @click="changeStock(item.value)">
{{ item.label }}
</button>
</view>
<button class="query-button" @click="refresh">查询</button>
</view>
<view v-for="reward in list" :key="reward.id" class="lf-card reward-card">
<view class="reward-card__type">{{ reward.type }}</view>
<view class="reward-card__name">{{ reward.name }}</view>
<view class="reward-card__desc">{{ reward.description || '兑换后由校区审核发放权益券码' }}</view>
<view class="reward-card__foot">
<text>{{ reward.cost }} 积分 · 库存 {{ reward.stock }}</text>
<button :disabled="reward.stock <= 0" @click="redeem(reward)">{{ reward.stock > 0 ? '兑换' : '售罄' }}</button>
</view>
</view>
<view class="load-state">{{ loadText }}</view>
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import { getPointRewards, redeemReward } from '../../services/educationApi'
import { getStoredAccount, refreshStoredAccount } from '../../services/accountSession'
export default {
data() {
return {
list: [],
total: 0,
loading: false,
finished: false,
filters: {
keyword: '',
stockStatus: 'all',
pageNum: 1,
pageSize: 10,
},
stockOptions: [
{ label: '全部', value: 'all' },
{ label: '有库存', value: 'in_stock' },
{ label: '已售罄', value: 'sold_out' },
],
}
},
computed: {
loadText() {
if (this.loading) return '加载中...'
if (this.finished) return this.list.length ? '没有更多了' : '暂无权益'
return '上拉加载更多'
},
},
async onShow() {
await refreshStoredAccount()
await this.refresh()
},
async onPullDownRefresh() {
await this.refresh()
uni.stopPullDownRefresh()
},
onReachBottom() {
this.loadMore()
},
methods: {
changeStock(value) {
this.filters.stockStatus = value
this.refresh()
},
async refresh() {
this.filters.pageNum = 1
this.finished = false
await this.load(true)
},
async loadMore() {
if (this.loading || this.finished) return
this.filters.pageNum += 1
await this.load(false)
},
async load(reset) {
const user = getStoredAccount()
if (!user) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.loading = true
try {
const res = await getPointRewards(user.id, this.filters)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '权益加载失败', type: 'error' })
return
}
const data = res.data || res
this.total = data.total || 0
this.list = reset ? (data.list || []) : this.list.concat(data.list || [])
this.finished = this.list.length >= this.total
} finally {
this.loading = false
}
},
async redeem(reward) {
if (reward.stock <= 0) {
this.$refs.toast.show({ message: '该权益库存不足', type: 'warning' })
return
}
const user = getStoredAccount()
if (!user) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
const confirmed = await this.confirmAction('确认兑换权益', `将消耗 ${reward.cost} 积分兑换「${reward.name}」?`)
if (!confirmed) return
const res = await redeemReward({ userId: user.id, itemId: reward.id })
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '兑换失败', type: 'error' })
return
}
this.$refs.toast.show({ message: '兑换申请已提交', type: 'success' })
await this.refresh()
},
confirmAction(title, content) {
return new Promise((resolve) => {
uni.showModal({ title, content, confirmText: '确认', cancelText: '取消', confirmColor: '#0b4f46', success: (res) => resolve(Boolean(res.confirm)), fail: () => resolve(false) })
})
},
},
}
</script>
<style lang="scss" scoped>
.filter-card,
.reward-card {
margin-top: 18rpx;
padding: 28rpx;
}
.search-box {
display: flex;
align-items: center;
gap: 12rpx;
height: 74rpx;
padding: 0 22rpx;
border-radius: 16rpx;
background: #f3f8ef;
}
.search-box input {
flex: 1;
color: #17312d;
font-size: 26rpx;
}
.chip-row {
display: flex;
gap: 12rpx;
margin-top: 18rpx;
}
.filter-chip {
height: 52rpx;
padding: 0 18rpx;
border: 1rpx solid #dce8dc;
border-radius: 999rpx;
color: #6f7c78;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
}
.filter-chip--active {
color: #0b4f46;
border-color: #9ae9dc;
background: #dff8ee;
}
.query-button {
width: 100%;
height: 68rpx;
margin-top: 18rpx;
border-radius: 16rpx;
color: #ffffff;
background: #0b4f46;
font-size: 25rpx;
font-weight: 900;
}
.reward-card__type {
color: #ff7a59;
font-size: 21rpx;
font-weight: 900;
}
.reward-card__name {
margin-top: 10rpx;
color: #17312d;
font-size: 31rpx;
font-weight: 900;
}
.reward-card__desc {
margin-top: 10rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.5;
}
.reward-card__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 22rpx;
}
.reward-card__foot text {
color: #0b4f46;
font-size: 23rpx;
font-weight: 900;
}
.reward-card__foot button {
min-width: 110rpx;
height: 52rpx;
border-radius: 999rpx;
color: #ffffff;
background: #0b4f46;
font-size: 23rpx;
font-weight: 900;
}
.reward-card__foot button[disabled] {
color: #7a8580;
background: #dce5dd;
}
.load-state {
padding: 28rpx 0 80rpx;
color: #6f7c78;
font-size: 23rpx;
text-align: center;
}
</style>

@ -0,0 +1,523 @@
<template>
<view class="lf-page points-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">积分中心</view>
<view class="lf-nav__sub">积分只能换学习权益,不可提现</view>
</view>
<u-icon name="rmb-circle" color="#f5c451" size="34"></u-icon>
</view>
<view class="balance-card lf-card lf-card--ink">
<view class="balance-card__label">可用积分</view>
<view class="balance-card__amount">{{ account.available }}</view>
<view class="balance-card__meta">待发放 {{ account.pending }} · 即将过期 {{ account.expiring }}</view>
</view>
<view class="lf-card expiry-card">
<view class="lf-row">
<view>
<view class="lf-section-title">积分过期说明</view>
<view class="lf-caption">{{ expiryRule.description }}</view>
</view>
<view class="expiry-card__days">{{ expiryRule.validDays }} 天</view>
</view>
<view v-for="item in expirySchedules" :key="item.id" class="expiry-item">
<view>
<view class="expiry-item__source">{{ item.source }}</view>
<view class="expiry-item__time">过期时间 {{ item.expireAt }}</view>
</view>
<text>{{ item.points }} 积分</text>
</view>
</view>
<view class="lf-card reward-card">
<view class="lf-row">
<view class="lf-section-title">兑换学习权益</view>
<button class="more-button" @click="go('/pages/point-rewards/index')">查看更多</button>
</view>
<view class="reward-grid">
<view v-for="reward in previewRewards" :key="reward.id" class="reward-item">
<view class="reward-item__type">{{ reward.type }}</view>
<view class="reward-item__name">{{ reward.name }}</view>
<view class="reward-item__desc">{{ reward.description || '兑换后由校区审核发放权益券码' }}</view>
<view class="reward-item__foot">
<text>{{ reward.cost }} 积分</text>
<button :disabled="reward.stock <= 0" @click="redeem(reward)">{{ reward.stock > 0 ? '兑换' : '售罄' }}</button>
</view>
</view>
</view>
</view>
<view class="lf-card redemption-card">
<view class="lf-row">
<view class="lf-section-title">我的兑换</view>
<button class="more-button" @click="go('/pages/point-records/index?mode=redemptions')">查看更多</button>
</view>
<view v-if="redemptions.length" class="redemption-list">
<view v-for="item in previewRedemptions" :key="item.id" class="redemption-item">
<view class="redemption-item__head">
<view>
<view class="redemption-item__name">{{ item.reward?.name || item.itemId }}</view>
<view class="redemption-item__meta">{{ item.createdAt }} · {{ item.costPoints }} 积分</view>
</view>
<text :class="['redemption-status', `redemption-status--${item.status}`]">{{ item.statusLabel || redemptionStatusText(item.status) }}</text>
</view>
<view v-if="item.voucherCode" class="voucher-box">
<view>
<text>权益券码</text>
<strong>{{ item.voucherCode }}</strong>
</view>
<button @click="copyCode(item.voucherCode)">复制</button>
</view>
<view v-if="item.expireAt" class="redemption-item__hint">有效期至 {{ item.expireAt }}</view>
<view v-if="item.rejectedReason" class="redemption-item__hint">驳回原因:{{ item.rejectedReason }}</view>
</view>
</view>
<view v-else class="empty-redemption">暂无兑换申请</view>
</view>
<view class="lf-card flow-card">
<view class="lf-row">
<view class="lf-section-title">积分流水</view>
<button class="more-button" @click="go('/pages/point-records/index?mode=transactions')">查看更多</button>
</view>
<view v-for="tx in previewTransactions" :key="tx.id" class="tx-row">
<view>
<view class="tx-row__title">{{ tx.title }}</view>
<view class="tx-row__date">{{ tx.createdAt }}</view>
</view>
<view class="tx-row__amount">
<text>{{ tx.amount > 0 ? '+' : '' }}{{ tx.amount }}</text>
<text>{{ statusText(tx.status) }}</text>
</view>
</view>
</view>
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import { POINT_STATUS } from '../../config'
import { getPointCenter, redeemReward } from '../../services/educationApi'
import { getStoredAccount, refreshStoredAccount } from '../../services/accountSession'
export default {
data() {
return {
account: { available: 0, pending: 0, frozen: 0, expiring: 0 },
rewards: [],
redemptions: [],
transactions: [],
expiryRule: { title: '', validDays: 0, description: '', reminder: '' },
expirySchedules: [],
}
},
computed: {
previewRewards() {
return this.rewards.slice(0, 4)
},
previewRedemptions() {
return this.redemptions.slice(0, 3)
},
previewTransactions() {
return this.transactions.slice(0, 3)
},
},
async onShow() {
await this.load()
},
async onPullDownRefresh() {
await this.load()
uni.stopPullDownRefresh()
},
methods: {
async load() {
const user = await refreshStoredAccount()
if (!user) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
const res = await getPointCenter(user.id)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '积分加载失败', type: 'error' })
return
}
const data = res.data || res
this.account = data.account || this.account
this.rewards = data.rewards || this.rewards
this.redemptions = data.redemptions || this.redemptions
this.transactions = data.transactions || this.transactions
this.expiryRule = data.expiryRule || this.expiryRule
this.expirySchedules = data.expirySchedules || this.expirySchedules
},
statusText(status) {
return POINT_STATUS[status] || status
},
redemptionStatusText(status) {
const map = {
pending_review: '待审核',
approved: '已发券',
rejected: '已驳回',
used: '已核销',
expired: '已过期',
}
return map[status] || status
},
copyCode(code) {
uni.setClipboardData({
data: code,
success: () => this.$refs.toast.show({ message: '券码已复制', type: 'success' }),
})
},
go(url) {
uni.navigateTo({ url })
},
async redeem(reward) {
if (reward.stock <= 0) {
this.$refs.toast.show({ message: '该权益库存不足', type: 'warning' })
return
}
const confirmed = await this.confirmAction(
'确认兑换权益',
`将消耗 ${reward.cost} 积分兑换「${reward.name}」,课程权益提交后需审核。`,
)
if (!confirmed) return
const user = getStoredAccount()
if (!user) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
const res = await redeemReward({ userId: user.id, itemId: reward.id })
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '兑换失败', type: 'error' })
return
}
this.$refs.toast.show({ message: '兑换申请已提交', type: 'success' })
this.load()
},
confirmAction(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '确认',
cancelText: '取消',
confirmColor: '#0b4f46',
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
},
},
}
</script>
<style lang="scss" scoped>
.balance-card {
margin-top: 18rpx;
padding: 34rpx;
}
.balance-card__label {
color: #9ae9dc;
font-size: 22rpx;
font-weight: 800;
}
.balance-card__amount {
margin-top: 10rpx;
color: #f5c451;
font-size: 72rpx;
font-weight: 900;
}
.balance-card__meta {
color: rgba(255, 255, 255, 0.72);
font-size: 24rpx;
}
.expiry-card,
.reward-card,
.redemption-card,
.flow-card {
margin-top: 22rpx;
padding: 28rpx;
}
.expiry-card__days {
min-width: 86rpx;
height: 54rpx;
border-radius: 999rpx;
color: #0b2f2a;
background: #f5c451;
font-size: 22rpx;
font-weight: 900;
line-height: 54rpx;
text-align: center;
}
.expiry-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 18rpx;
padding: 18rpx;
border-radius: 14rpx;
background: #f3f8ef;
}
.expiry-item__source {
color: #17312d;
font-size: 24rpx;
font-weight: 900;
}
.expiry-item__time {
margin-top: 8rpx;
color: #6f7c78;
font-size: 21rpx;
}
.expiry-item > text {
color: #ff7a59;
font-size: 24rpx;
font-weight: 900;
}
.reward-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18rpx;
margin-top: 24rpx;
}
.more-button {
min-width: 132rpx;
height: 52rpx;
border: 1rpx solid #b9d8d2;
border-radius: 999rpx;
color: #0b4f46;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
line-height: 52rpx;
}
.reward-item {
min-height: 210rpx;
padding: 22rpx;
border: 1rpx solid #e4ecdf;
border-radius: 14rpx;
background: #fbfcf7;
}
.reward-item__type {
color: #ff7a59;
font-size: 20rpx;
font-weight: 900;
}
.reward-item__name {
margin-top: 12rpx;
color: #17312d;
font-size: 28rpx;
font-weight: 900;
line-height: 1.35;
}
.reward-item__desc {
display: -webkit-box;
overflow: hidden;
margin-top: 10rpx;
color: #6f7c78;
font-size: 21rpx;
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.reward-item__foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
}
.reward-item__foot text {
color: #0b4f46;
font-size: 22rpx;
font-weight: 900;
}
.reward-item__foot button {
min-width: 98rpx;
height: 48rpx;
border-radius: 999rpx;
color: #ffffff;
background: #0b4f46;
font-size: 22rpx;
font-weight: 800;
}
.reward-item__foot button[disabled] {
color: #7a8580;
background: #dce5dd;
}
.redemption-list {
display: grid;
gap: 18rpx;
margin-top: 22rpx;
}
.redemption-item {
padding: 22rpx;
border: 1rpx solid #e4ecdf;
border-radius: 14rpx;
background: #fbfcf7;
}
.redemption-item__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.redemption-item__name {
color: #17312d;
font-size: 28rpx;
font-weight: 900;
line-height: 1.35;
}
.redemption-item__meta,
.redemption-item__hint {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
}
.redemption-status {
flex-shrink: 0;
min-width: 104rpx;
height: 48rpx;
border-radius: 999rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 22rpx;
font-weight: 900;
line-height: 48rpx;
text-align: center;
}
.redemption-status--pending_review {
color: #8d5c00;
background: #fff3cf;
}
.redemption-status--rejected {
color: #b42318;
background: #ffe7e4;
}
.redemption-status--used,
.redemption-status--expired {
color: #56615d;
background: #edf1e9;
}
.voucher-box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-top: 18rpx;
padding: 18rpx;
border-radius: 14rpx;
background: #0b4f46;
}
.voucher-box view {
display: grid;
gap: 6rpx;
}
.voucher-box text {
color: #9ae9dc;
font-size: 20rpx;
font-weight: 900;
}
.voucher-box strong {
color: #f5c451;
font-size: 28rpx;
font-weight: 900;
letter-spacing: 0;
}
.voucher-box button {
flex-shrink: 0;
min-width: 92rpx;
height: 48rpx;
border-radius: 999rpx;
color: #0b2f2a;
background: #f5c451;
font-size: 22rpx;
font-weight: 900;
}
.empty-redemption {
margin-top: 22rpx;
padding: 28rpx;
border-radius: 14rpx;
color: #6f7c78;
background: #f3f8ef;
font-size: 24rpx;
text-align: center;
}
.tx-row {
display: flex;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.tx-row:last-child {
border-bottom: 0;
}
.tx-row__title {
color: #17312d;
font-size: 28rpx;
font-weight: 900;
}
.tx-row__date {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
}
.tx-row__amount {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.tx-row__amount text:first-child {
color: #ff7a59;
font-size: 28rpx;
font-weight: 900;
}
.tx-row__amount text:last-child {
margin-top: 8rpx;
color: #6f7c78;
font-size: 20rpx;
}
</style>

@ -0,0 +1,228 @@
<template>
<view class="lf-page records-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">推荐记录</view>
<view class="lf-nav__sub">按家长、手机号、渠道和状态查询</view>
</view>
<view class="lf-pill">{{ total }} 条</view>
</view>
<view class="lf-card filter-card">
<view class="search-box">
<u-icon name="search" color="#6f7c78" size="22"></u-icon>
<input v-model="filters.keyword" placeholder="搜索家长、手机号、渠道" confirm-type="search" @confirm="refresh" />
</view>
<view class="chip-row">
<button v-for="item in statusOptions" :key="item.value" :class="['filter-chip', filters.status === item.value && 'filter-chip--active']" @click="changeStatus(item.value)">
{{ item.label }}
</button>
</view>
<button class="query-button" @click="refresh">查询</button>
</view>
<view v-for="record in list" :key="record.id" class="lf-card record-card">
<view>
<view class="record-card__name">{{ record.name }}</view>
<view class="record-card__meta">{{ record.phone || '-' }} · {{ record.childGrade || '-' }} · {{ record.channel || '-' }}</view>
</view>
<view class="record-card__right">
<text>{{ record.status }}</text>
<strong>+{{ record.reward }}</strong>
</view>
</view>
<view class="load-state">{{ loadText }}</view>
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import { getReferralRecords } from '../../services/educationApi'
import { getStoredAccount, refreshStoredAccount } from '../../services/accountSession'
export default {
data() {
return {
list: [],
total: 0,
loading: false,
finished: false,
filters: {
keyword: '',
status: 'all',
pageNum: 1,
pageSize: 10,
},
statusOptions: [
{ label: '全部', value: 'all' },
{ label: '已注册', value: '已注册' },
{ label: '已预约', value: '已预约' },
{ label: '已到课', value: '已到课' },
{ label: '已报名', value: '已报名' },
],
}
},
computed: {
loadText() {
if (this.loading) return '加载中...'
if (this.finished) return this.list.length ? '没有更多了' : '暂无推荐记录'
return '上拉加载更多'
},
},
async onShow() {
await refreshStoredAccount()
await this.refresh()
},
async onPullDownRefresh() {
await this.refresh()
uni.stopPullDownRefresh()
},
onReachBottom() {
this.loadMore()
},
methods: {
changeStatus(status) {
this.filters.status = status
this.refresh()
},
async refresh() {
this.filters.pageNum = 1
this.finished = false
await this.load(true)
},
async loadMore() {
if (this.loading || this.finished) return
this.filters.pageNum += 1
await this.load(false)
},
async load(reset) {
const account = getStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.loading = true
try {
const res = await getReferralRecords(account.id, this.filters)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '推荐记录加载失败', type: 'error' })
return
}
const data = res.data?.records || res.records || {}
this.total = data.total || 0
this.list = reset ? (data.list || []) : this.list.concat(data.list || [])
this.finished = this.list.length >= this.total
} finally {
this.loading = false
}
},
},
}
</script>
<style lang="scss" scoped>
.filter-card,
.record-card {
margin-top: 18rpx;
padding: 28rpx;
}
.search-box {
display: flex;
align-items: center;
gap: 12rpx;
height: 74rpx;
padding: 0 22rpx;
border-radius: 16rpx;
background: #f3f8ef;
}
.search-box input {
flex: 1;
color: #17312d;
font-size: 26rpx;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.filter-chip {
height: 52rpx;
padding: 0 18rpx;
border: 1rpx solid #dce8dc;
border-radius: 999rpx;
color: #6f7c78;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
}
.filter-chip--active {
color: #0b4f46;
border-color: #9ae9dc;
background: #dff8ee;
}
.query-button {
width: 100%;
height: 68rpx;
margin-top: 18rpx;
border-radius: 16rpx;
color: #ffffff;
background: #0b4f46;
font-size: 25rpx;
font-weight: 900;
}
.record-card {
display: flex;
justify-content: space-between;
gap: 18rpx;
}
.record-card__name {
color: #17312d;
font-size: 30rpx;
font-weight: 900;
}
.record-card__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
line-height: 1.45;
}
.record-card__right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10rpx;
}
.record-card__right text {
padding: 8rpx 14rpx;
border-radius: 999rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 20rpx;
font-weight: 900;
}
.record-card__right strong {
color: #ff7a59;
font-size: 28rpx;
}
.load-state {
padding: 28rpx 0 80rpx;
color: #6f7c78;
font-size: 23rpx;
text-align: center;
}
</style>

@ -0,0 +1,592 @@
<template>
<view class="lf-page referral-page">
<view class="lf-nav">
<view>
<view class="lf-nav__title">推荐有礼</view>
<view class="lf-nav__sub">好友通过微信卡片进入小程序,新用户填写分享码即可领取积分</view>
</view>
<view class="lf-pill">双向积分</view>
</view>
<view class="invite-card lf-card lf-card--ink lf-ticket">
<view class="share-preview">
<image class="share-preview__image" :src="shareCover" mode="aspectFill" />
<view class="share-preview__caption">
<view>
<view class="share-preview__eyebrow">成长邀请</view>
<view class="share-preview__title">把好奇心,变成每一次成长</view>
</view>
<view class="share-preview__badge">双向积分</view>
</view>
</view>
<view class="invite-card__body">
<view class="invite-card__label">我的专属分享码</view>
<view class="invite-card__code">{{ profile.inviterCode || '-' }}</view>
<view class="invite-card__copy">新家长首次登录或注册时填写该分享码,双方都会获得注册积分。</view>
</view>
<view class="invite-card__actions">
<!-- #ifdef MP-WEIXIN -->
<button class="share-button share-button--gold" open-type="share" data-channel="微信好友" @click="prepareShare('微信好友')">
<u-icon name="chat" color="#0b2f2a" size="17"></u-icon>
<text>发给好友</text>
</button>
<!-- #endif -->
<button class="share-button" @click="copyInviteCode">
<u-icon name="file-text" color="#ffffff" size="17"></u-icon>
<text>复制分享码</text>
</button>
</view>
</view>
<view class="channel-grid">
<view v-for="channel in channels" :key="channel.name" class="channel-card lf-card" @click="handleChannel(channel)">
<u-icon :name="channel.icon" :color="channel.color" size="30"></u-icon>
<text>{{ channel.name }}</text>
<text>{{ channel.desc }}</text>
</view>
</view>
<view class="lf-card rule-card">
<view class="lf-row">
<view class="lf-section-title">积分阶梯</view>
<text class="lf-caption">推荐者 / 新家长</text>
</view>
<view class="rule-list">
<view v-for="(rule, index) in rules" :key="rule.title" class="rule-item">
<view class="rule-item__index">{{ index + 1 }}</view>
<view class="rule-item__body">
<text>{{ rule.title }}</text>
<text>推荐者 +{{ rule.inviter }} · 新家长 +{{ rule.invitee }}</text>
</view>
</view>
</view>
</view>
<view class="lf-card records-card">
<view class="lf-row">
<view class="lf-section-title">推荐进度</view>
<button class="more-button" @click="go('/pages/referral-records/index')">查看更多</button>
</view>
<view v-if="!records.length" class="empty-text">暂无推荐记录</view>
<view v-for="record in previewRecords" :key="record.id" class="record-row">
<view>
<view class="record-row__name">{{ record.name }}</view>
<view class="record-row__meta">{{ record.childGrade }} · {{ record.channel }}</view>
</view>
<view class="record-row__right">
<u-tag :text="record.status" plain shape="circle" color="#0b4f46" borderColor="#b9d8d2"></u-tag>
<text>+{{ record.reward }}</text>
</view>
</view>
</view>
<u-popup :show="qrVisible" mode="center" round="18" @close="qrVisible = false">
<view class="qr-panel">
<view class="qr-panel__title">小程序邀请二维码</view>
<view class="qr-panel__sub">扫码进入小程序登录页,邀请码会自动带入</view>
<image v-if="qrCode.image" class="qr-panel__image" :src="qrCode.image" mode="aspectFit" @click="previewQrCode" />
<view v-else class="qr-panel__empty">正在生成二维码</view>
<view class="qr-panel__code">邀请码 {{ qrCode.inviterCode || profile.inviterCode || '-' }}</view>
<button class="qr-panel__button" @click="saveQrCode">保存二维码</button>
</view>
</u-popup>
<u-toast ref="toast"></u-toast>
</view>
</template>
<script>
import { createMiniProgramCode, createShareMaterial, getReferralCenter } from '../../services/educationApi'
import { getStoredAccount, refreshStoredAccount } from '../../services/accountSession'
const emptyStats = { invited: 0, registered: 0, booked: 0, attended: 0, purchased: 0, conversion: 0 }
const shareCover = '/static/share/referral-share-cover.jpg'
export default {
data() {
return {
profile: { id: '', name: '', inviterCode: '' },
stats: emptyStats,
rules: [],
records: [],
channels: [
{ name: '朋友圈', desc: '使用微信原生分享', icon: 'photo', color: '#ff7a59' },
{ name: '小程序码', desc: '扫码进入登录页', icon: 'scan', color: '#17bebb' },
],
shareCover,
share: null,
incomingShareForwarding: false,
qrVisible: false,
qrCode: {},
}
},
onLoad(query = {}) {
if (this.forwardIncomingShare(query)) {
this.incomingShareForwarding = true
return
}
this.enableShareMenu()
},
async onShow() {
if (this.incomingShareForwarding) return
this.enableShareMenu()
await this.load()
},
async onPullDownRefresh() {
await this.load()
uni.stopPullDownRefresh()
},
onShareAppMessage(event) {
const channel = event?.target?.dataset?.channel || '微信好友'
return this.buildSharePayload(channel)
},
onShareTimeline() {
const inviteCode = this.profile.inviterCode || ''
return {
title: this.share?.title || `${this.profile.name || '立方和教育'}邀请你领取试听积分`,
query: `inviteCode=${encodeURIComponent(inviteCode)}&source=timeline`,
imageUrl: shareCover,
}
},
computed: {
previewRecords() {
return this.records.slice(0, 3)
},
},
methods: {
enableShareMenu() {
if (uni.showShareMenu) {
uni.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline'],
})
}
},
forwardIncomingShare(query = {}) {
const inviteCode = decodeURIComponent(String(query.inviteCode || '')).trim()
if (!inviteCode) return false
const account = getStoredAccount()
if (account?.inviterCode === inviteCode) return false
uni.reLaunch({
url: `/pages/login/index?inviteCode=${encodeURIComponent(inviteCode)}`,
})
return true
},
async load() {
const account = await refreshStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.profile = { ...this.profile, ...account }
const res = await getReferralCenter(account.id)
if (res.ok === false) {
this.$refs.toast.show({ message: res.message || '推荐数据加载失败', type: 'error' })
return
}
const data = res.data || res
this.stats = data.stats || emptyStats
this.rules = data.rules || []
this.records = data.records || []
this.share = data.share || this.share
},
go(url) {
uni.navigateTo({ url })
},
buildSharePayload(channel = '微信好友') {
const inviteCode = encodeURIComponent(this.profile.inviterCode || '')
const path = this.share?.path || `/pages/login/index?inviteCode=${inviteCode}`
return {
title: this.share?.title || `${this.profile.name || '立方和教育'}邀请你领取试听积分`,
path,
imageUrl: shareCover,
channel,
}
},
async prepareShare(channel) {
const account = getStoredAccount()
if (!account) return
const res = await createShareMaterial(channel, account.id)
if (res.ok !== false) this.share = res.data?.share || res.data || this.share
},
async handleChannel(channel) {
if (channel.name === '朋友圈') {
await this.shareToTimeline()
return
}
await this.generateQrCode()
},
async shareToTimeline() {
await this.prepareShare('朋友圈')
this.enableShareMenu()
uni.showModal({
title: '分享到朋友圈',
content: '朋友圈分享卡片已准备好,请点击右上角“…”并选择“分享到朋友圈”。',
showCancel: false,
confirmText: '知道了',
})
},
copyInviteCode() {
const inviteCode = String(this.profile.inviterCode || '').trim()
if (!inviteCode) {
this.$refs.toast.show({ message: '分享码尚未生成', type: 'error' })
return
}
uni.setClipboardData({
data: inviteCode,
success: () => this.$refs.toast.show({ message: '分享码已复制', type: 'success' }),
fail: () => this.$refs.toast.show({ message: '复制失败,请稍后重试', type: 'error' }),
})
},
async generateQrCode() {
const account = getStoredAccount()
if (!account) {
uni.reLaunch({ url: '/pages/login/index' })
return
}
this.qrVisible = true
const res = await createMiniProgramCode(account.id)
if (res.ok === false) {
this.qrVisible = false
this.$refs.toast.show({ message: res.message || '二维码生成失败', type: 'error' })
return
}
this.qrCode = res.data || res
},
previewQrCode() {
if (!this.qrCode.image) return
uni.previewImage({ urls: [this.qrCode.image] })
},
saveQrCode() {
if (!this.qrCode.image) return
if (typeof wx === 'undefined' || !wx.getFileSystemManager) {
this.$refs.toast.show({ message: '请长按二维码保存', type: 'default' })
return
}
const base64 = this.qrCode.image.replace(/^data:image\/\w+;base64,/, '')
const filePath = `${wx.env.USER_DATA_PATH}/lifanghe-${Date.now()}.png`
wx.getFileSystemManager().writeFile({
filePath,
data: base64,
encoding: 'base64',
success: () => {
wx.saveImageToPhotosAlbum({
filePath,
success: () => this.$refs.toast.show({ message: '二维码已保存', type: 'success' }),
fail: () => this.$refs.toast.show({ message: '保存失败,请检查相册权限', type: 'error' }),
})
},
fail: () => this.$refs.toast.show({ message: '二维码保存失败', type: 'error' }),
})
},
},
}
</script>
<style lang="scss" scoped>
.invite-card {
margin-top: 18rpx;
padding: 16rpx 16rpx 30rpx;
}
.share-preview {
position: relative;
width: 100%;
height: 360rpx;
overflow: hidden;
border-radius: 14rpx;
background: #17312d;
}
.share-preview__image {
display: block;
width: 100%;
height: 100%;
}
.share-preview__caption {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18rpx;
min-height: 104rpx;
padding: 18rpx 20rpx;
box-sizing: border-box;
background: rgba(11, 47, 42, 0.92);
}
.share-preview__eyebrow {
color: #9ae9dc;
font-size: 19rpx;
font-weight: 800;
}
.share-preview__title {
margin-top: 6rpx;
color: #ffffff;
font-size: 27rpx;
font-weight: 900;
line-height: 1.25;
}
.share-preview__badge {
flex: 0 0 auto;
padding: 8rpx 14rpx;
border: 1rpx solid rgba(154, 233, 220, 0.52);
border-radius: 999rpx;
color: #f5c451;
font-size: 19rpx;
font-weight: 900;
}
.invite-card__body {
padding: 26rpx 18rpx 0;
}
.invite-card__label {
color: #9ae9dc;
font-size: 22rpx;
font-weight: 800;
}
.invite-card__code {
margin-top: 18rpx;
color: #f5c451;
font-size: 58rpx;
font-weight: 900;
letter-spacing: 0;
}
.invite-card__copy {
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 24rpx;
line-height: 1.6;
}
.invite-card__actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18rpx;
margin: 24rpx 18rpx 0;
}
.share-button,
.qr-panel__button {
display: flex;
align-items: center;
justify-content: center;
height: 72rpx;
border-radius: 999rpx;
font-size: 25rpx;
font-weight: 900;
line-height: normal;
}
.share-button {
gap: 10rpx;
padding: 0 16rpx;
box-sizing: border-box;
border: 1rpx solid rgba(255, 255, 255, 0.7);
color: #ffffff;
background: transparent;
}
.share-button::after {
border: 0;
}
.invite-card__actions > .share-button:only-child {
grid-column: 1 / -1;
}
.share-button--gold {
color: #0b2f2a;
border-color: #f5c451;
background: #f5c451;
}
.channel-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
margin-top: 22rpx;
}
.channel-card {
min-height: 178rpx;
padding: 22rpx 16rpx;
}
.channel-card text:nth-child(2) {
display: block;
margin-top: 18rpx;
font-size: 25rpx;
font-weight: 900;
}
.channel-card text:nth-child(3) {
display: block;
margin-top: 8rpx;
color: #6f7c78;
font-size: 20rpx;
line-height: 1.4;
}
.rule-card,
.records-card {
margin-top: 22rpx;
padding: 28rpx;
}
.rule-list {
margin-top: 24rpx;
}
.rule-item {
display: flex;
gap: 20rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.rule-item:last-child {
border-bottom: 0;
}
.rule-item__index {
display: grid;
place-items: center;
width: 48rpx;
height: 48rpx;
border-radius: 14rpx;
color: #0b2f2a;
background: #f5c451;
font-size: 22rpx;
font-weight: 900;
}
.rule-item__body text {
display: block;
}
.rule-item__body text:first-child {
color: #17312d;
font-size: 28rpx;
font-weight: 900;
}
.rule-item__body text:last-child {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
}
.empty-text {
padding: 34rpx 0 10rpx;
color: #6f7c78;
font-size: 24rpx;
text-align: center;
}
.more-button {
min-width: 132rpx;
height: 52rpx;
border: 1rpx solid #b9d8d2;
border-radius: 999rpx;
color: #0b4f46;
background: #ffffff;
font-size: 22rpx;
font-weight: 900;
line-height: 52rpx;
}
.record-row {
display: flex;
justify-content: space-between;
gap: 18rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #edf1e9;
}
.record-row:last-child {
border-bottom: 0;
}
.record-row__name {
color: #17312d;
font-size: 28rpx;
font-weight: 900;
}
.record-row__meta {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
}
.record-row__right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10rpx;
}
.record-row__right text {
color: #ff7a59;
font-size: 26rpx;
font-weight: 900;
}
.qr-panel {
width: 600rpx;
padding: 34rpx;
text-align: center;
background: #ffffff;
}
.qr-panel__title {
color: #17312d;
font-size: 32rpx;
font-weight: 900;
}
.qr-panel__sub {
margin-top: 8rpx;
color: #6f7c78;
font-size: 22rpx;
}
.qr-panel__image,
.qr-panel__empty {
width: 390rpx;
height: 390rpx;
margin: 28rpx auto 0;
border-radius: 18rpx;
background: #f8faf5;
}
.qr-panel__empty {
display: grid;
place-items: center;
color: #6f7c78;
font-size: 24rpx;
}
.qr-panel__code {
margin-top: 18rpx;
color: #0b4f46;
font-size: 24rpx;
font-weight: 900;
}
.qr-panel__button {
margin-top: 24rpx;
color: #ffffff;
background: #0b4f46;
}
</style>

@ -0,0 +1,38 @@
import { getCurrentAccount } from './educationApi'
const STORAGE_KEY = 'lfh_current_account'
export function getStoredAccount() {
try {
return uni.getStorageSync(STORAGE_KEY) || null
} catch (error) {
return null
}
}
export function setStoredAccount(account) {
const current = getStoredAccount()
const next = {
...account,
sessionToken: account?.sessionToken || current?.sessionToken || '',
}
uni.setStorageSync(STORAGE_KEY, next)
return next
}
export function clearStoredAccount() {
uni.removeStorageSync(STORAGE_KEY)
}
export async function refreshStoredAccount() {
const current = getStoredAccount()
if (!current?.sessionToken) return current
const response = await getCurrentAccount()
if (response?.ok === false) return current
return setStoredAccount(response?.data || response)
}
export function isAdminAccount(account = getStoredAccount()) {
if (!account) return false
return ['super_admin', 'campus_admin', 'advisor', 'teacher', 'finance'].includes(account.role)
}

@ -0,0 +1,186 @@
import { http } from './http'
import { API_BASE_URL } from '../config'
function toQuery(params = {}) {
const query = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
return query ? `?${query}` : ''
}
export function getBootstrap(accountId) {
return http.get(`/bootstrap?accountId=${encodeURIComponent(accountId)}`)
}
export function loginWithPhone(phone, code, inviterCode = '') {
return http.post('/auth/phone-login', { phone, code, inviterCode })
}
export function sendSmsCode(phone, purpose = 'login') {
return http.post('/auth/sms-code', { phone, purpose })
}
export function loginWithPassword(account, password) {
return http.post('/auth/login', { account, password })
}
export function loginWithWechat(code) {
return http.post('/auth/wechat-login', { code })
}
export function bindWechatPhone(payload) {
return http.post('/auth/wechat-bind-phone', payload)
}
export function getCurrentAccount() {
return http.get('/auth/current')
}
export function updateProfile(accountId, payload) {
return http.patch(`/auth/profile/${encodeURIComponent(accountId)}`, payload)
}
export function updatePassword(accountId, payload) {
return http.patch(`/auth/password/${encodeURIComponent(accountId)}`, payload)
}
export function updateBoundPhone(accountId, payload) {
return http.patch(`/auth/phone/${encodeURIComponent(accountId)}`, payload)
}
export function uploadAvatar(filePath) {
return new Promise((resolve) => {
const sessionToken = uni.getStorageSync('lfh_current_account')?.sessionToken || ''
uni.uploadFile({
url: `${API_BASE_URL}/uploads/avatar`,
filePath,
name: 'file',
header: sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {},
success: (res) => {
try {
resolve(JSON.parse(res.data))
} catch (error) {
resolve({ ok: false, message: '头像上传响应格式错误' })
}
},
fail: () => resolve({ ok: false, message: '头像上传失败' }),
})
})
}
export function getReferralCenter(inviterId) {
return http.get(`/referrals${inviterId ? `?inviterId=${encodeURIComponent(inviterId)}` : ''}`)
}
export function getReferralRecords(inviterId, filters = {}) {
return http.get(`/referrals${toQuery({ ...filters, inviterId, paged: 1 })}`)
}
export function createShareMaterial(channel, inviterId) {
return http.post('/referrals/share', { channel, inviterId })
}
export function createMiniProgramCode(inviterId) {
return http.post('/referrals/mini-code', { inviterId })
}
export function bindReferral(payload) {
return http.post('/referrals/bind', payload)
}
export function getPointCenter(userId) {
return http.get(`/points/${userId}`)
}
export function getPointRewards(userId, filters = {}) {
return http.get(`/points/${userId}/rewards${toQuery(filters)}`)
}
export function getPointTransactions(userId, filters = {}) {
return http.get(`/points/${userId}/transactions${toQuery(filters)}`)
}
export function getPointRedemptions(userId, filters = {}) {
return http.get(`/points/${userId}/redemptions${toQuery(filters)}`)
}
export function redeemReward(payload) {
return http.post('/redemptions', payload)
}
export function getCourses() {
return http.get('/courses')
}
export function getCoursesPage(filters = {}) {
return http.get(`/courses${toQuery({ ...filters, paged: 1 })}`)
}
export function getMyAppointments(userId) {
return http.get(`/appointments?userId=${encodeURIComponent(userId)}`)
}
export function getAdminCourses(filters = {}) {
return http.get(`/admin/courses${toQuery(filters)}`)
}
export function getAdminCourse(id) {
return http.get(`/admin/courses/${encodeURIComponent(id)}`)
}
export function getAdminAppointments(filters = {}) {
return http.get(`/admin/appointments${toQuery(filters)}`)
}
export function attendAdminAppointment(id) {
return http.patch(`/admin/appointments/${encodeURIComponent(id)}/attend`, {})
}
export function createAdminCourse(payload) {
return http.post('/admin/courses', payload)
}
export function updateAdminCourse(id, payload) {
return http.patch(`/admin/courses/${id}`, payload)
}
export function deleteAdminCourse(id) {
return http.delete(`/admin/courses/${id}`)
}
export function bookCourse(payload) {
return http.post('/appointments', payload)
}
export function getAdminDashboard() {
return http.get('/admin/dashboard')
}
export function getAdminLeads(filters = {}) {
return http.get(`/admin/leads${toQuery(filters)}`)
}
export function updateLeadStage(id, stage) {
return http.patch(`/admin/leads/${id}/stage`, { stage })
}
export function confirmLeadPurchase(id) {
return http.post(`/admin/leads/${id}/purchase`, {})
}
export function getAdminPointManager(filters = {}) {
return http.get(`/admin/points${toQuery(filters)}`)
}
export function getAdminPointAccounts(filters = {}) {
return http.get(`/admin/point-accounts${toQuery(filters)}`)
}
export function getAdminPointTransactions(filters = {}) {
return http.get(`/admin/point-transactions${toQuery(filters)}`)
}
export function adjustPoints(payload) {
return http.post('/admin/points/adjust', payload)
}

@ -0,0 +1,57 @@
import { API_BASE_URL } from '../config'
let requestSequence = 0
function getSessionToken() {
try {
return uni.getStorageSync('lfh_current_account')?.sessionToken || ''
} catch (error) {
return ''
}
}
function request(path, options = {}) {
return new Promise((resolve) => {
const sessionToken = getSessionToken()
const requestPath = options.method === 'GET' || !options.method
? `${path}${path.includes('?') ? '&' : '?'}_t=${Date.now()}_${requestSequence += 1}`
: path
uni.request({
url: `${API_BASE_URL}${requestPath}`,
method: options.method || 'GET',
data: options.data || {},
enableCache: false,
header: {
'content-type': 'application/json',
'cache-control': 'no-cache',
...(sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {}),
...(options.header || {}),
},
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data)
return
}
resolve({ ok: false, message: res.data?.message || '请求失败' })
},
fail: () => {
resolve({ ok: false, message: '网络异常,请确认后端服务已启动' })
},
})
})
}
export const http = {
get(path) {
return request(path, { method: 'GET' })
},
post(path, data) {
return request(path, { method: 'POST', data })
},
patch(path, data) {
return request(path, { method: 'PATCH', data })
},
delete(path) {
return request(path, { method: 'DELETE' })
},
}

@ -0,0 +1,6 @@
export {};
declare module "vue" {
type Hooks = App.AppInstance & Page.PageInstance;
interface ComponentCustomOptions extends Hooks {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

@ -0,0 +1,121 @@
.lf-page {
min-height: 100vh;
padding: calc(28rpx + var(--status-bar-height)) 28rpx 56rpx;
background:
radial-gradient(circle at 8% 4%, rgba(23, 190, 187, 0.2), transparent 34%),
linear-gradient(180deg, #0b2f2a 0%, #0f3d35 260rpx, #f3f6ed 261rpx);
}
.lf-page--light {
min-height: 100vh;
padding-top: calc(28rpx + var(--status-bar-height));
background:
linear-gradient(135deg, rgba(255, 122, 89, 0.14), transparent 32%),
linear-gradient(180deg, #f3f6ed 0%, #ffffff 100%);
}
.lf-nav {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 92rpx;
padding-top: 24rpx;
color: #ffffff;
}
.lf-nav__title {
font-size: 38rpx;
font-weight: 800;
letter-spacing: 0;
}
.lf-nav__sub {
margin-top: 6rpx;
color: rgba(255, 255, 255, 0.7);
font-size: 22rpx;
}
.lf-card {
border: 1rpx solid rgba(11, 79, 70, 0.1);
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 18rpx 48rpx rgba(11, 47, 42, 0.08);
}
.lf-card--ink {
color: #ffffff;
border-color: rgba(255, 255, 255, 0.14);
background:
linear-gradient(145deg, rgba(23, 190, 187, 0.25), transparent 38%),
#0b2f2a;
box-shadow: 0 24rpx 58rpx rgba(11, 47, 42, 0.22);
}
.lf-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.lf-section-title {
color: #17312d;
font-size: 34rpx;
font-weight: 800;
}
.lf-caption {
color: #6f7c78;
font-size: 24rpx;
line-height: 1.6;
}
.lf-pill {
display: inline-flex;
align-items: center;
min-height: 44rpx;
padding: 0 18rpx;
border-radius: 999rpx;
color: #0b4f46;
background: #dff8ee;
font-size: 22rpx;
font-weight: 700;
}
.lf-ticket {
position: relative;
overflow: hidden;
}
.lf-ticket::before,
.lf-ticket::after {
position: absolute;
top: 50%;
width: 28rpx;
height: 28rpx;
content: "";
border-radius: 50%;
background: #f3f6ed;
transform: translateY(-50%);
}
.lf-ticket::before {
left: -14rpx;
}
.lf-ticket::after {
right: -14rpx;
}
.lf-cube-mark {
display: grid;
place-items: center;
width: 76rpx;
height: 76rpx;
border-radius: 18rpx;
color: #0b2f2a;
background:
linear-gradient(135deg, #17bebb 0 50%, #f5c451 50% 100%);
font-size: 26rpx;
font-weight: 900;
box-shadow: 8rpx 8rpx 0 #ff7a59;
}

@ -0,0 +1,83 @@
@import "uview-plus/theme.scss";
$u-primary: #0b4f46;
$u-success: #17bebb;
$u-warning: #f5c451;
$u-error: #ff7a59;
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color: #333; // 基本色
$uni-text-color-inverse: #fff; // 反色
$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable: #c0c0c0;
/* 背景颜色 */
$uni-bg-color: #fff;
$uni-bg-color-grey: #f8f8f8;
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
$uni-bg-color-mask: rgba(0, 0, 0, 0.4); // 遮罩颜色
/* 边框颜色 */
$uni-border-color: #c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm: 12px;
$uni-font-size-base: 14px;
$uni-font-size-lg: 16;
/* 图片尺寸 */
$uni-img-size-sm: 20px;
$uni-img-size-base: 26px;
$uni-img-size-lg: 40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2c405a; // 文章标题颜色
$uni-font-size-title: 20px;
$uni-color-subtitle: #555; // 二级标题颜色
$uni-font-size-subtitle: 18px;
$uni-color-paragraph: #3f536e; // 文章段落颜色
$uni-font-size-paragraph: 15px;

@ -0,0 +1,14 @@
export function formatPoint(value) {
return Number(value || 0).toLocaleString('zh-CN')
}
export function formatDate(value) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return `${date.getMonth() + 1}月${date.getDate()}日`
}
export function maskPhone(phone = '') {
return phone.replace(/^(\d{3})\d{4}(\d+)/, '$1****$2')
}

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
uni(),
],
})

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save