d263c7bf48
- GameGlobal.js: keep upstream SERVER_URL with /ws suffix - en.js/zh.js: merge both settings.nickname and settings.profile keys - SettingsScene.js: keep both nickname row and profile button - server/index.js: merge express app + content security proxy with noServer WebSocket mode and path validation - Add .gitignore for node_modules and .codebuddy
597 lines
16 KiB
JavaScript
597 lines
16 KiB
JavaScript
/**
|
|
* ProfileScene.js
|
|
* Player profile editing scene.
|
|
* Supports nickname, signature, description editing and avatar upload.
|
|
* All user-generated content is checked against content security before saving.
|
|
*/
|
|
|
|
const {
|
|
SCREEN_WIDTH,
|
|
SCREEN_HEIGHT,
|
|
COLORS,
|
|
SCENE,
|
|
} = require('../base/GameGlobal');
|
|
const { t } = require('../i18n/I18n');
|
|
const ContentSecurityManager = require('../managers/ContentSecurityManager');
|
|
|
|
// ============================================================
|
|
// Layout Constants
|
|
// ============================================================
|
|
const CENTER_X = SCREEN_WIDTH / 2;
|
|
const INPUT_WIDTH = Math.min(SCREEN_WIDTH * 0.7, 280);
|
|
const INPUT_HEIGHT = 36;
|
|
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.5, 200);
|
|
const BTN_HEIGHT = 36;
|
|
|
|
// Field constraints
|
|
const NICKNAME_MIN = 2;
|
|
const NICKNAME_MAX = 20;
|
|
const SIGNATURE_MAX = 50;
|
|
const DESCRIPTION_MAX = 200;
|
|
|
|
// Profile field IDs
|
|
const FIELD = {
|
|
NICKNAME: 'nickname',
|
|
SIGNATURE: 'signature',
|
|
DESCRIPTION: 'description',
|
|
AVATAR: 'avatar',
|
|
};
|
|
|
|
// ============================================================
|
|
// Profile Scene
|
|
// ============================================================
|
|
const ProfileScene = {
|
|
// Current editing state
|
|
_editingField: null, // Which field is being edited
|
|
_inputText: '', // Current input text
|
|
_errorMessage: '', // Error message to display
|
|
_isSubmitting: false, // Whether a submission is in progress
|
|
_localViolation: false, // Whether local check found violation
|
|
|
|
// User profile data
|
|
_profile: {
|
|
nickname: '',
|
|
signature: '',
|
|
description: '',
|
|
avatarUrl: '',
|
|
},
|
|
|
|
// Open ID for API calls
|
|
_openid: '',
|
|
|
|
// Button rects for touch handling
|
|
_backBtnRect: null,
|
|
_nicknameRect: null,
|
|
_signatureRect: null,
|
|
_descriptionRect: null,
|
|
_avatarRect: null,
|
|
_saveBtnRect: null,
|
|
|
|
enter() {
|
|
this._editingField = null;
|
|
this._inputText = '';
|
|
this._errorMessage = '';
|
|
this._isSubmitting = false;
|
|
this._localViolation = false;
|
|
|
|
// Load profile from storage
|
|
this._loadProfile();
|
|
|
|
// Get openid
|
|
try {
|
|
this._openid = wx.getStorageSync('player_openid') || '';
|
|
} catch (e) {
|
|
this._openid = '';
|
|
}
|
|
|
|
// Calculate button positions
|
|
this._backBtnRect = { x: 10, y: 10, w: 60, h: 30 };
|
|
this._avatarRect = {
|
|
x: CENTER_X - 40,
|
|
y: 70,
|
|
w: 80,
|
|
h: 80,
|
|
};
|
|
this._nicknameRect = {
|
|
x: CENTER_X - INPUT_WIDTH / 2,
|
|
y: 175,
|
|
w: INPUT_WIDTH,
|
|
h: INPUT_HEIGHT,
|
|
};
|
|
this._signatureRect = {
|
|
x: CENTER_X - INPUT_WIDTH / 2,
|
|
y: 240,
|
|
w: INPUT_WIDTH,
|
|
h: INPUT_HEIGHT,
|
|
};
|
|
this._descriptionRect = {
|
|
x: CENTER_X - INPUT_WIDTH / 2,
|
|
y: 305,
|
|
w: INPUT_WIDTH,
|
|
h: 80,
|
|
};
|
|
this._saveBtnRect = {
|
|
x: CENTER_X - BTN_WIDTH / 2,
|
|
y: SCREEN_HEIGHT - 100,
|
|
w: BTN_WIDTH,
|
|
h: BTN_HEIGHT,
|
|
};
|
|
},
|
|
|
|
exit() {
|
|
// Save profile to storage
|
|
this._saveProfile();
|
|
},
|
|
|
|
update(dt) {},
|
|
|
|
render(ctx) {
|
|
// Background
|
|
ctx.fillStyle = COLORS.MENU_BG;
|
|
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
|
|
|
const cx = CENTER_X;
|
|
|
|
// Back button
|
|
this._renderBackButton(ctx);
|
|
|
|
// Title
|
|
ctx.fillStyle = COLORS.MENU_TITLE;
|
|
ctx.font = 'bold 24px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(t('profile.title'), cx, 50);
|
|
|
|
// Avatar
|
|
this._renderAvatar(ctx);
|
|
|
|
// Nickname field
|
|
this._renderField(ctx, this._nicknameRect, t('profile.nickname'), this._profile.nickname, FIELD.NICKNAME);
|
|
|
|
// Signature field
|
|
this._renderField(ctx, this._signatureRect, t('profile.signature'), this._profile.signature, FIELD.SIGNATURE);
|
|
|
|
// Description field
|
|
this._renderDescriptionField(ctx);
|
|
|
|
// Error message
|
|
if (this._errorMessage) {
|
|
ctx.fillStyle = '#FF4444';
|
|
ctx.font = '12px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(this._errorMessage, cx, SCREEN_HEIGHT - 140);
|
|
}
|
|
|
|
// Save button
|
|
this._renderSaveButton(ctx);
|
|
|
|
// Editing overlay
|
|
if (this._editingField) {
|
|
this._renderEditOverlay(ctx);
|
|
}
|
|
},
|
|
|
|
handleTouch(type, e) {
|
|
if (type !== 'touchstart') return;
|
|
const touch = e.touches[0];
|
|
const x = touch.clientX;
|
|
const y = touch.clientY;
|
|
|
|
// If editing, handle save/cancel in overlay
|
|
if (this._editingField) {
|
|
this._handleEditOverlayTouch(x, y);
|
|
return;
|
|
}
|
|
|
|
// Back button
|
|
if (this._hitTest(x, y, this._backBtnRect)) {
|
|
const sm = GameGlobal.sceneManager;
|
|
sm.switchTo(SCENE.MENU);
|
|
return;
|
|
}
|
|
|
|
// Avatar click
|
|
if (this._hitTest(x, y, this._avatarRect)) {
|
|
this._handleChangeAvatar();
|
|
return;
|
|
}
|
|
|
|
// Nickname click
|
|
if (this._hitTest(x, y, this._nicknameRect)) {
|
|
this._startEditing(FIELD.NICKNAME, this._profile.nickname);
|
|
return;
|
|
}
|
|
|
|
// Signature click
|
|
if (this._hitTest(x, y, this._signatureRect)) {
|
|
this._startEditing(FIELD.SIGNATURE, this._profile.signature);
|
|
return;
|
|
}
|
|
|
|
// Description click
|
|
if (this._hitTest(x, y, this._descriptionRect)) {
|
|
this._startEditing(FIELD.DESCRIPTION, this._profile.description);
|
|
return;
|
|
}
|
|
},
|
|
|
|
// ============================================================
|
|
// Editing
|
|
// ============================================================
|
|
|
|
_startEditing(field, currentValue) {
|
|
const csm = GameGlobal.contentSecurityManager;
|
|
if (!csm || !csm.isInitialized()) return;
|
|
|
|
this._editingField = field;
|
|
this._inputText = currentValue || '';
|
|
this._errorMessage = '';
|
|
this._localViolation = false;
|
|
|
|
// Determine input constraints
|
|
let maxLength = 200;
|
|
if (field === FIELD.NICKNAME) maxLength = NICKNAME_MAX;
|
|
if (field === FIELD.SIGNATURE) maxLength = SIGNATURE_MAX;
|
|
if (field === FIELD.DESCRIPTION) maxLength = DESCRIPTION_MAX;
|
|
|
|
// Show keyboard
|
|
wx.showKeyboard({
|
|
defaultValue: this._inputText,
|
|
maxLength,
|
|
multiple: field === FIELD.DESCRIPTION,
|
|
confirmHold: false,
|
|
confirmType: 'done',
|
|
});
|
|
|
|
// Listen for keyboard input
|
|
this._onKeyboardInput = (res) => {
|
|
this._inputText = res.value;
|
|
|
|
// Real-time local sensitive word check
|
|
if (this._inputText) {
|
|
const localCheck = csm.checkLocalText(this._inputText);
|
|
if (localCheck.hasViolation) {
|
|
this._localViolation = true;
|
|
this._errorMessage = '内容包含违规信息,请修改';
|
|
} else {
|
|
this._localViolation = false;
|
|
this._errorMessage = '';
|
|
}
|
|
|
|
// Length validation
|
|
if (field === FIELD.NICKNAME) {
|
|
if (this._inputText.length < NICKNAME_MIN) {
|
|
this._errorMessage = `昵称至少需要${NICKNAME_MIN}个字符`;
|
|
this._localViolation = true;
|
|
} else if (this._inputText.length > NICKNAME_MAX) {
|
|
this._errorMessage = `昵称不能超过${NICKNAME_MAX}个字符`;
|
|
this._localViolation = true;
|
|
}
|
|
} else if (field === FIELD.SIGNATURE && this._inputText.length > SIGNATURE_MAX) {
|
|
this._errorMessage = `签名不能超过${SIGNATURE_MAX}个字符`;
|
|
this._localViolation = true;
|
|
} else if (field === FIELD.DESCRIPTION && this._inputText.length > DESCRIPTION_MAX) {
|
|
this._errorMessage = `描述不能超过${DESCRIPTION_MAX}个字符`;
|
|
this._localViolation = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
this._onKeyboardConfirm = () => {
|
|
this._handleSubmit();
|
|
};
|
|
|
|
wx.onKeyboardInput(this._onKeyboardInput);
|
|
wx.onKeyboardConfirm(this._onKeyboardConfirm);
|
|
},
|
|
|
|
_stopEditing() {
|
|
if (this._onKeyboardInput) {
|
|
wx.offKeyboardInput(this._onKeyboardInput);
|
|
this._onKeyboardInput = null;
|
|
}
|
|
if (this._onKeyboardConfirm) {
|
|
wx.offKeyboardConfirm(this._onKeyboardConfirm);
|
|
this._onKeyboardConfirm = null;
|
|
}
|
|
wx.hideKeyboard();
|
|
this._editingField = null;
|
|
},
|
|
|
|
async _handleSubmit() {
|
|
if (this._isSubmitting || this._localViolation) return;
|
|
|
|
const csm = GameGlobal.contentSecurityManager;
|
|
if (!csm || !this._editingField) return;
|
|
|
|
const content = this._inputText.trim();
|
|
if (!content) {
|
|
this._errorMessage = '内容不能为空';
|
|
return;
|
|
}
|
|
|
|
this._isSubmitting = true;
|
|
this._errorMessage = '审核中...';
|
|
|
|
// Determine scene
|
|
const sceneMap = {
|
|
[FIELD.NICKNAME]: ContentSecurityManager.SCENE.NICKNAME,
|
|
[FIELD.SIGNATURE]: ContentSecurityManager.SCENE.SIGNATURE,
|
|
[FIELD.DESCRIPTION]: ContentSecurityManager.SCENE.DESCRIPTION,
|
|
};
|
|
const scene = sceneMap[this._editingField];
|
|
|
|
// Full text check (local + server)
|
|
const result = await csm.fullTextCheck(this._openid, content, scene);
|
|
|
|
this._isSubmitting = false;
|
|
|
|
if (result.pass) {
|
|
// Save the content
|
|
this._profile[this._editingField] = content;
|
|
this._saveProfile();
|
|
this._errorMessage = '';
|
|
this._stopEditing();
|
|
} else {
|
|
this._errorMessage = result.errorMessage;
|
|
}
|
|
},
|
|
|
|
// ============================================================
|
|
// Avatar Upload
|
|
// ============================================================
|
|
|
|
_handleChangeAvatar() {
|
|
const csm = GameGlobal.contentSecurityManager;
|
|
if (!csm || !csm.isInitialized()) return;
|
|
|
|
wx.chooseImage({
|
|
count: 1,
|
|
sizeType: ['compressed'],
|
|
sourceType: ['album', 'camera'],
|
|
success: async (res) => {
|
|
const filePath = res.tempFilePaths[0];
|
|
|
|
// Check file size
|
|
const fileInfo = wx.getFileInfo({ filePath });
|
|
if (fileInfo && fileInfo.size > 1024 * 1024) {
|
|
this._errorMessage = '图片大小不能超过1MB';
|
|
return;
|
|
}
|
|
|
|
this._isSubmitting = true;
|
|
this._errorMessage = '审核中...';
|
|
|
|
// Check image content
|
|
const result = await csm.checkImageContent(filePath, this._openid);
|
|
|
|
this._isSubmitting = false;
|
|
|
|
if (result.pass) {
|
|
this._profile.avatarUrl = filePath;
|
|
this._saveProfile();
|
|
this._errorMessage = '';
|
|
} else {
|
|
this._errorMessage = result.errmsg || '图片内容违规,请更换';
|
|
}
|
|
},
|
|
fail: () => {
|
|
// User cancelled or error
|
|
},
|
|
});
|
|
},
|
|
|
|
// ============================================================
|
|
// Render Helpers
|
|
// ============================================================
|
|
|
|
_renderBackButton(ctx) {
|
|
ctx.fillStyle = '#AAAAAA';
|
|
ctx.font = '14px Arial';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('← ' + t('common.back'), 15, 25);
|
|
},
|
|
|
|
_renderAvatar(ctx) {
|
|
const rect = this._avatarRect;
|
|
const cx = rect.x + rect.w / 2;
|
|
const cy = rect.y + rect.h / 2;
|
|
const r = rect.w / 2;
|
|
|
|
// Circle background
|
|
ctx.fillStyle = '#4a90d9';
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Camera icon
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = '24px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('📷', cx, cy);
|
|
|
|
// Hint text
|
|
ctx.fillStyle = '#888888';
|
|
ctx.font = '10px Arial';
|
|
ctx.fillText(t('profile.changeAvatar'), cx, rect.y + rect.h + 14);
|
|
},
|
|
|
|
_renderField(ctx, rect, label, value, field) {
|
|
const isEditing = this._editingField === field;
|
|
|
|
// Label
|
|
ctx.fillStyle = '#CCCCCC';
|
|
ctx.font = '12px Arial';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText(label, rect.x, rect.y - 4);
|
|
|
|
// Input box
|
|
ctx.fillStyle = isEditing ? 'rgba(74, 144, 226, 0.2)' : 'rgba(255, 255, 255, 0.1)';
|
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
|
|
|
// Border
|
|
ctx.strokeStyle = isEditing ? '#4a90d9' : '#555555';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
|
|
|
|
// Value
|
|
ctx.fillStyle = value ? '#FFFFFF' : '#666666';
|
|
ctx.font = '13px Arial';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
const displayText = value || t('profile.tapToEdit');
|
|
const truncated = displayText.length > 20 ? displayText.substring(0, 20) + '...' : displayText;
|
|
ctx.fillText(truncated, rect.x + 8, rect.y + rect.h / 2);
|
|
},
|
|
|
|
_renderDescriptionField(ctx) {
|
|
const rect = this._descriptionRect;
|
|
const isEditing = this._editingField === FIELD.DESCRIPTION;
|
|
|
|
// Label
|
|
ctx.fillStyle = '#CCCCCC';
|
|
ctx.font = '12px Arial';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText(t('profile.description'), rect.x, rect.y - 4);
|
|
|
|
// Input box (taller for description)
|
|
ctx.fillStyle = isEditing ? 'rgba(74, 144, 226, 0.2)' : 'rgba(255, 255, 255, 0.1)';
|
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
|
|
|
ctx.strokeStyle = isEditing ? '#4a90d9' : '#555555';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
|
|
|
|
// Value (multi-line)
|
|
ctx.fillStyle = this._profile.description ? '#FFFFFF' : '#666666';
|
|
ctx.font = '12px Arial';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'top';
|
|
const displayText = this._profile.description || t('profile.tapToEdit');
|
|
const lines = this._wrapText(ctx, displayText, rect.w - 16, 4);
|
|
for (let i = 0; i < lines.length; i++) {
|
|
ctx.fillText(lines[i], rect.x + 8, rect.y + 6 + i * 16);
|
|
}
|
|
},
|
|
|
|
_renderSaveButton(ctx) {
|
|
const rect = this._saveBtnRect;
|
|
const isActive = this._editingField && !this._localViolation && !this._isSubmitting;
|
|
|
|
ctx.fillStyle = isActive ? '#4a90d9' : '#555555';
|
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
|
|
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = 'bold 14px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(this._isSubmitting ? '审核中...' : t('profile.save'), rect.x + rect.w / 2, rect.y + rect.h / 2);
|
|
},
|
|
|
|
_renderEditOverlay(ctx) {
|
|
// Semi-transparent overlay at bottom
|
|
const overlayY = SCREEN_HEIGHT * 0.6;
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
ctx.fillRect(0, overlayY, SCREEN_WIDTH, SCREEN_HEIGHT - overlayY);
|
|
|
|
// Current input text preview
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = '14px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
const previewText = this._inputText || '';
|
|
const lines = this._wrapText(ctx, previewText, INPUT_WIDTH, 3);
|
|
for (let i = 0; i < lines.length; i++) {
|
|
ctx.fillText(lines[i], CENTER_X, overlayY + 30 + i * 20);
|
|
}
|
|
|
|
// Character count
|
|
let maxLen = 200;
|
|
if (this._editingField === FIELD.NICKNAME) maxLen = NICKNAME_MAX;
|
|
if (this._editingField === FIELD.SIGNATURE) maxLen = SIGNATURE_MAX;
|
|
|
|
ctx.fillStyle = '#888888';
|
|
ctx.font = '11px Arial';
|
|
ctx.fillText(`${this._inputText.length}/${maxLen}`, CENTER_X, overlayY + 100);
|
|
|
|
// Violation warning
|
|
if (this._localViolation) {
|
|
ctx.fillStyle = '#FF4444';
|
|
ctx.font = 'bold 12px Arial';
|
|
ctx.fillText('⚠ ' + this._errorMessage, CENTER_X, overlayY + 120);
|
|
}
|
|
},
|
|
|
|
_handleEditOverlayTouch(x, y) {
|
|
// Save button
|
|
if (this._hitTest(x, y, this._saveBtnRect)) {
|
|
if (!this._localViolation && !this._isSubmitting) {
|
|
this._handleSubmit();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Tap outside to cancel
|
|
this._stopEditing();
|
|
},
|
|
|
|
// ============================================================
|
|
// Utility
|
|
// ============================================================
|
|
|
|
_hitTest(x, y, rect) {
|
|
return rect && x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
|
|
},
|
|
|
|
_wrapText(ctx, text, maxWidth, maxLines) {
|
|
const words = text.split('');
|
|
const lines = [];
|
|
let currentLine = '';
|
|
|
|
for (const char of words) {
|
|
const testLine = currentLine + char;
|
|
const metrics = ctx.measureText(testLine);
|
|
if (metrics.width > maxWidth && currentLine.length > 0) {
|
|
lines.push(currentLine);
|
|
currentLine = char;
|
|
if (lines.length >= maxLines) {
|
|
lines[lines.length - 1] += '...';
|
|
break;
|
|
}
|
|
} else {
|
|
currentLine = testLine;
|
|
}
|
|
}
|
|
if (currentLine && lines.length < maxLines) {
|
|
lines.push(currentLine);
|
|
}
|
|
|
|
return lines;
|
|
},
|
|
|
|
_loadProfile() {
|
|
try {
|
|
const saved = wx.getStorageSync('player_profile');
|
|
if (saved) {
|
|
this._profile = { ...this._profile, ...JSON.parse(saved) };
|
|
}
|
|
} catch (e) {
|
|
console.warn('[ProfileScene] Failed to load profile:', e);
|
|
}
|
|
},
|
|
|
|
_saveProfile() {
|
|
try {
|
|
wx.setStorageSync('player_profile', JSON.stringify(this._profile));
|
|
} catch (e) {
|
|
console.warn('[ProfileScene] Failed to save profile:', e);
|
|
}
|
|
},
|
|
};
|
|
|
|
module.exports = ProfileScene; |