diff --git a/.env.example b/.env.example index ca997e4..312cdfd 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,4 @@ JWT_ADMIN_EXPIRES=2h JWT_AGENT_EXPIRES=8h PORT=3000 NODE_ENV=development +UPLOAD_DIR= diff --git a/.gitignore b/.gitignore index 6e64ddc..563464c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,17 @@ dist/ coverage/ .turbo/ *.tsbuildinfo +# 勿将 tsc 编译产物提交到 player/src(会导致 Vite 加载过期 .js) +apps/player/src/**/*.js +!apps/player/src/router/index.js +!apps/player/src/utils/localeDisplay.js apps/api/prisma/migrations/*_migration_lock.toml + +# 用户上传文件(保留目录结构与示例 Banner) +uploads/**/* +!uploads/banners/ +!uploads/banners/** +!uploads/teams/ +!uploads/teams/.gitkeep +!uploads/contents/ +!uploads/contents/.gitkeep diff --git a/apps/admin/index.html b/apps/admin/index.html index 23bb51a..d9a1580 100644 --- a/apps/admin/index.html +++ b/apps/admin/index.html @@ -3,7 +3,9 @@ - + + + TheBet365 Admin diff --git a/apps/admin/src/assets/images/.gitkeep b/apps/admin/src/assets/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/agent/index.html b/apps/agent/index.html index adc07f4..6192801 100644 --- a/apps/agent/index.html +++ b/apps/agent/index.html @@ -1,5 +1,12 @@ -TheBet365 Agent + + + + + + + TheBet365 Agent +
diff --git a/apps/agent/src/assets/images/.gitkeep b/apps/agent/src/assets/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/prisma/migrations/20260602065556_init/migration.sql b/apps/api/prisma/migrations/20260602065556_init/migration.sql new file mode 100644 index 0000000..bd75b87 --- /dev/null +++ b/apps/api/prisma/migrations/20260602065556_init/migration.sql @@ -0,0 +1,703 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" BIGSERIAL NOT NULL, + "username" VARCHAR(64) NOT NULL, + "user_type" VARCHAR(20) NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "parent_id" BIGINT, + "agent_level" INTEGER, + "locale" VARCHAR(10) NOT NULL DEFAULT 'en-US', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_auth" ( + "id" BIGSERIAL NOT NULL, + "user_id" BIGINT NOT NULL, + "password_hash" VARCHAR(255) NOT NULL, + "login_fail_count" INTEGER NOT NULL DEFAULT 0, + "locked_until" TIMESTAMP(3), + "last_login_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_auth_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_preferences" ( + "id" BIGSERIAL NOT NULL, + "user_id" BIGINT NOT NULL, + "locale" VARCHAR(10) NOT NULL DEFAULT 'en-US', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_preferences_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "roles" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(64) NOT NULL, + "name" VARCHAR(128) NOT NULL, + "description" VARCHAR(255), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "roles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "permissions" ( + "id" BIGSERIAL NOT NULL, + "code" VARCHAR(128) NOT NULL, + "name" VARCHAR(128) NOT NULL, + "module" VARCHAR(64) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "permissions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "role_permissions" ( + "role_id" BIGINT NOT NULL, + "permission_id" BIGINT NOT NULL, + + CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_id") +); + +-- CreateTable +CREATE TABLE "admin_user_roles" ( + "user_id" BIGINT NOT NULL, + "role_id" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "agent_profiles" ( + "id" BIGSERIAL NOT NULL, + "user_id" BIGINT NOT NULL, + "level" INTEGER NOT NULL, + "parent_agent_id" BIGINT, + "credit_limit" DECIMAL(18,4) NOT NULL DEFAULT 0, + "used_credit" DECIMAL(18,4) NOT NULL DEFAULT 0, + "direct_player_liability" DECIMAL(18,4) NOT NULL DEFAULT 0, + "child_agent_exposure" DECIMAL(18,4) NOT NULL DEFAULT 0, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "max_single_deposit" DECIMAL(18,4), + "max_daily_deposit" DECIMAL(18,4), + "cashback_rate" DECIMAL(8,4) NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "agent_profiles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "agent_closure" ( + "ancestor_id" BIGINT NOT NULL, + "descendant_id" BIGINT NOT NULL, + "depth" INTEGER NOT NULL, + + CONSTRAINT "agent_closure_pkey" PRIMARY KEY ("ancestor_id","descendant_id") +); + +-- CreateTable +CREATE TABLE "agent_credit_transactions" ( + "id" BIGSERIAL NOT NULL, + "agent_id" BIGINT NOT NULL, + "transaction_type" VARCHAR(32) NOT NULL, + "amount" DECIMAL(18,4) NOT NULL, + "credit_before" DECIMAL(18,4) NOT NULL, + "credit_after" DECIMAL(18,4) NOT NULL, + "reference_type" VARCHAR(32), + "reference_id" VARCHAR(64), + "operator_id" BIGINT, + "request_id" VARCHAR(128), + "remark" VARCHAR(500), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "agent_credit_transactions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "wallets" ( + "id" BIGSERIAL NOT NULL, + "user_id" BIGINT NOT NULL, + "available_balance" DECIMAL(18,4) NOT NULL DEFAULT 0, + "frozen_balance" DECIMAL(18,4) NOT NULL DEFAULT 0, + "currency" VARCHAR(16) NOT NULL DEFAULT 'USD', + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "version" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "wallets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "wallet_transactions" ( + "id" BIGSERIAL NOT NULL, + "transaction_id" VARCHAR(64) NOT NULL, + "user_id" BIGINT NOT NULL, + "wallet_id" BIGINT NOT NULL, + "transaction_type" VARCHAR(32) NOT NULL, + "amount" DECIMAL(18,4) NOT NULL, + "balance_before" DECIMAL(18,4) NOT NULL, + "balance_after" DECIMAL(18,4) NOT NULL, + "frozen_before" DECIMAL(18,4) NOT NULL, + "frozen_after" DECIMAL(18,4) NOT NULL, + "reference_type" VARCHAR(32), + "reference_id" VARCHAR(64), + "operator_id" BIGINT, + "remark" VARCHAR(500), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "wallet_transactions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "leagues" ( + "id" BIGSERIAL NOT NULL, + "sport_type" VARCHAR(20) NOT NULL DEFAULT 'FOOTBALL', + "code" VARCHAR(64) NOT NULL, + "display_order" INTEGER NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "leagues_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "teams" ( + "id" BIGSERIAL NOT NULL, + "sport_type" VARCHAR(20) NOT NULL DEFAULT 'FOOTBALL', + "code" VARCHAR(64) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "teams_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "entity_translations" ( + "id" BIGSERIAL NOT NULL, + "entity_type" VARCHAR(32) NOT NULL, + "entity_id" BIGINT NOT NULL, + "locale" VARCHAR(10) NOT NULL, + "field_name" VARCHAR(32) NOT NULL, + "value" VARCHAR(500) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "entity_translations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "matches" ( + "id" BIGSERIAL NOT NULL, + "sport_type" VARCHAR(20) NOT NULL DEFAULT 'FOOTBALL', + "league_id" BIGINT NOT NULL, + "home_team_id" BIGINT NOT NULL, + "away_team_id" BIGINT NOT NULL, + "start_time" TIMESTAMP(3) NOT NULL, + "status" VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + "is_hot" BOOLEAN NOT NULL DEFAULT false, + "display_order" INTEGER NOT NULL DEFAULT 0, + "publish_time" TIMESTAMP(3), + "close_time" TIMESTAMP(3), + "is_outright" BOOLEAN NOT NULL DEFAULT false, + "created_by" BIGINT, + "updated_by" BIGINT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "matches_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "match_scores" ( + "id" BIGSERIAL NOT NULL, + "match_id" BIGINT NOT NULL, + "ht_home_score" INTEGER, + "ht_away_score" INTEGER, + "ft_home_score" INTEGER, + "ft_away_score" INTEGER, + "winner_team_id" BIGINT, + "recorded_by" BIGINT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "match_scores_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "markets" ( + "id" BIGSERIAL NOT NULL, + "match_id" BIGINT NOT NULL, + "market_type" VARCHAR(64) NOT NULL, + "period" VARCHAR(16) NOT NULL, + "line_value" DECIMAL(8,2), + "status" VARCHAR(20) NOT NULL DEFAULT 'OPEN', + "allow_single" BOOLEAN NOT NULL DEFAULT true, + "allow_parlay" BOOLEAN NOT NULL DEFAULT true, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "markets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "market_selections" ( + "id" BIGSERIAL NOT NULL, + "market_id" BIGINT NOT NULL, + "selection_code" VARCHAR(64) NOT NULL, + "selection_name" VARCHAR(255) NOT NULL, + "odds" DECIMAL(18,6) NOT NULL, + "odds_version" BIGINT NOT NULL DEFAULT 1, + "status" VARCHAR(20) NOT NULL DEFAULT 'OPEN', + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "market_selections_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "odds_change_logs" ( + "id" BIGSERIAL NOT NULL, + "selection_id" BIGINT NOT NULL, + "old_odds" DECIMAL(18,6) NOT NULL, + "new_odds" DECIMAL(18,6) NOT NULL, + "odds_version" BIGINT NOT NULL, + "changed_by" BIGINT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "odds_change_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "bets" ( + "id" BIGSERIAL NOT NULL, + "bet_no" VARCHAR(64) NOT NULL, + "user_id" BIGINT NOT NULL, + "agent_id" BIGINT, + "bet_type" VARCHAR(20) NOT NULL, + "stake" DECIMAL(18,4) NOT NULL, + "total_odds" DECIMAL(18,6), + "potential_return" DECIMAL(18,4), + "actual_return" DECIMAL(18,4) NOT NULL DEFAULT 0, + "status" VARCHAR(32) NOT NULL DEFAULT 'PENDING', + "settlement_status" VARCHAR(32), + "currency" VARCHAR(16) NOT NULL DEFAULT 'USD', + "request_id" VARCHAR(128) NOT NULL, + "placed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "settled_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "bets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "bet_selections" ( + "id" BIGSERIAL NOT NULL, + "bet_id" BIGINT NOT NULL, + "match_id" BIGINT, + "market_id" BIGINT NOT NULL, + "selection_id" BIGINT NOT NULL, + "market_type" VARCHAR(64) NOT NULL, + "period" VARCHAR(16), + "selection_name_snapshot" VARCHAR(255) NOT NULL, + "handicap_line" DECIMAL(8,2), + "total_line" DECIMAL(8,2), + "odds" DECIMAL(18,6) NOT NULL, + "odds_version" BIGINT NOT NULL, + "result_status" VARCHAR(32), + "effective_odds" DECIMAL(18,6), + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "bet_selections_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "settlement_batches" ( + "id" BIGSERIAL NOT NULL, + "match_id" BIGINT NOT NULL, + "batch_no" VARCHAR(64) NOT NULL, + "ht_home_score" INTEGER, + "ht_away_score" INTEGER, + "ft_home_score" INTEGER, + "ft_away_score" INTEGER, + "status" VARCHAR(20) NOT NULL DEFAULT 'PREVIEW', + "total_bets" INTEGER NOT NULL DEFAULT 0, + "total_payout" DECIMAL(18,4) NOT NULL DEFAULT 0, + "total_refund" DECIMAL(18,4) NOT NULL DEFAULT 0, + "operator_id" BIGINT, + "confirmed_at" TIMESTAMP(3), + "is_resettle" BOOLEAN NOT NULL DEFAULT false, + "reason" VARCHAR(500), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "settlement_batches_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "settlement_items" ( + "id" BIGSERIAL NOT NULL, + "batch_id" BIGINT NOT NULL, + "bet_id" BIGINT NOT NULL, + "user_id" BIGINT NOT NULL, + "result" VARCHAR(32) NOT NULL, + "payout" DECIMAL(18,4) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "settlement_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cashback_rules" ( + "id" BIGSERIAL NOT NULL, + "name" VARCHAR(128) NOT NULL, + "target_type" VARCHAR(32) NOT NULL, + "target_id" BIGINT, + "rate" DECIMAL(8,4) NOT NULL, + "market_type" VARCHAR(64), + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "cashback_rules_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cashback_batches" ( + "id" BIGSERIAL NOT NULL, + "batch_no" VARCHAR(64) NOT NULL, + "period_start" TIMESTAMP(3) NOT NULL, + "period_end" TIMESTAMP(3) NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'PREVIEW', + "total_amount" DECIMAL(18,4) NOT NULL DEFAULT 0, + "player_count" INTEGER NOT NULL DEFAULT 0, + "operator_id" BIGINT, + "confirmed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "cashback_batches_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cashback_items" ( + "id" BIGSERIAL NOT NULL, + "batch_id" BIGINT NOT NULL, + "user_id" BIGINT NOT NULL, + "effective_stake" DECIMAL(18,4) NOT NULL, + "rate" DECIMAL(8,4) NOT NULL, + "amount" DECIMAL(18,4) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "cashback_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "contents" ( + "id" BIGSERIAL NOT NULL, + "content_type" VARCHAR(32) NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "status" VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + "link_type" VARCHAR(32), + "link_target" VARCHAR(500), + "start_time" TIMESTAMP(3), + "end_time" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contents_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "content_translations" ( + "id" BIGSERIAL NOT NULL, + "content_id" BIGINT NOT NULL, + "locale" VARCHAR(10) NOT NULL, + "title" VARCHAR(255), + "body" TEXT, + "image_url" VARCHAR(500), + + CONSTRAINT "content_translations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "i18n_messages" ( + "id" BIGSERIAL NOT NULL, + "msg_key" VARCHAR(128) NOT NULL, + "locale" VARCHAR(10) NOT NULL, + "value" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "i18n_messages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "system_configs" ( + "id" BIGSERIAL NOT NULL, + "config_key" VARCHAR(128) NOT NULL, + "config_value" TEXT NOT NULL, + "description" VARCHAR(255), + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "system_configs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_logs" ( + "id" BIGSERIAL NOT NULL, + "operator_id" BIGINT, + "operator_type" VARCHAR(20) NOT NULL, + "action" VARCHAR(128) NOT NULL, + "module" VARCHAR(64) NOT NULL, + "target_type" VARCHAR(32), + "target_id" VARCHAR(64), + "before_data" TEXT, + "after_data" TEXT, + "ip_address" VARCHAR(45), + "user_agent" VARCHAR(500), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); + +-- CreateIndex +CREATE INDEX "users_user_type_idx" ON "users"("user_type"); + +-- CreateIndex +CREATE INDEX "users_parent_id_idx" ON "users"("parent_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_auth_user_id_key" ON "user_auth"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_preferences_user_id_key" ON "user_preferences"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "roles_code_key" ON "roles"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "permissions_code_key" ON "permissions"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "admin_user_roles_user_id_key" ON "admin_user_roles"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "agent_profiles_user_id_key" ON "agent_profiles"("user_id"); + +-- CreateIndex +CREATE INDEX "agent_profiles_parent_agent_id_idx" ON "agent_profiles"("parent_agent_id"); + +-- CreateIndex +CREATE INDEX "agent_closure_descendant_id_idx" ON "agent_closure"("descendant_id"); + +-- CreateIndex +CREATE INDEX "agent_credit_transactions_agent_id_idx" ON "agent_credit_transactions"("agent_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "agent_credit_transactions_operator_id_request_id_key" ON "agent_credit_transactions"("operator_id", "request_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "wallets_user_id_key" ON "wallets"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "wallet_transactions_transaction_id_key" ON "wallet_transactions"("transaction_id"); + +-- CreateIndex +CREATE INDEX "wallet_transactions_user_id_idx" ON "wallet_transactions"("user_id"); + +-- CreateIndex +CREATE INDEX "wallet_transactions_wallet_id_idx" ON "wallet_transactions"("wallet_id"); + +-- CreateIndex +CREATE INDEX "wallet_transactions_created_at_idx" ON "wallet_transactions"("created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "leagues_code_key" ON "leagues"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "teams_code_key" ON "teams"("code"); + +-- CreateIndex +CREATE INDEX "entity_translations_entity_type_entity_id_idx" ON "entity_translations"("entity_type", "entity_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "entity_translations_entity_type_entity_id_locale_field_name_key" ON "entity_translations"("entity_type", "entity_id", "locale", "field_name"); + +-- CreateIndex +CREATE INDEX "matches_status_idx" ON "matches"("status"); + +-- CreateIndex +CREATE INDEX "matches_start_time_idx" ON "matches"("start_time"); + +-- CreateIndex +CREATE INDEX "matches_league_id_idx" ON "matches"("league_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "match_scores_match_id_key" ON "match_scores"("match_id"); + +-- CreateIndex +CREATE INDEX "markets_match_id_idx" ON "markets"("match_id"); + +-- CreateIndex +CREATE INDEX "markets_market_type_idx" ON "markets"("market_type"); + +-- CreateIndex +CREATE INDEX "market_selections_market_id_idx" ON "market_selections"("market_id"); + +-- CreateIndex +CREATE INDEX "odds_change_logs_selection_id_idx" ON "odds_change_logs"("selection_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "bets_bet_no_key" ON "bets"("bet_no"); + +-- CreateIndex +CREATE INDEX "bets_user_id_idx" ON "bets"("user_id"); + +-- CreateIndex +CREATE INDEX "bets_agent_id_idx" ON "bets"("agent_id"); + +-- CreateIndex +CREATE INDEX "bets_status_idx" ON "bets"("status"); + +-- CreateIndex +CREATE INDEX "bets_placed_at_idx" ON "bets"("placed_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "bets_user_id_request_id_key" ON "bets"("user_id", "request_id"); + +-- CreateIndex +CREATE INDEX "bet_selections_bet_id_idx" ON "bet_selections"("bet_id"); + +-- CreateIndex +CREATE INDEX "bet_selections_match_id_idx" ON "bet_selections"("match_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "settlement_batches_batch_no_key" ON "settlement_batches"("batch_no"); + +-- CreateIndex +CREATE INDEX "settlement_batches_match_id_idx" ON "settlement_batches"("match_id"); + +-- CreateIndex +CREATE INDEX "settlement_items_batch_id_idx" ON "settlement_items"("batch_id"); + +-- CreateIndex +CREATE INDEX "settlement_items_bet_id_idx" ON "settlement_items"("bet_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "cashback_batches_batch_no_key" ON "cashback_batches"("batch_no"); + +-- CreateIndex +CREATE INDEX "cashback_items_batch_id_idx" ON "cashback_items"("batch_id"); + +-- CreateIndex +CREATE INDEX "cashback_items_user_id_idx" ON "cashback_items"("user_id"); + +-- CreateIndex +CREATE INDEX "contents_content_type_status_idx" ON "contents"("content_type", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "content_translations_content_id_locale_key" ON "content_translations"("content_id", "locale"); + +-- CreateIndex +CREATE UNIQUE INDEX "i18n_messages_msg_key_locale_key" ON "i18n_messages"("msg_key", "locale"); + +-- CreateIndex +CREATE UNIQUE INDEX "system_configs_config_key_key" ON "system_configs"("config_key"); + +-- CreateIndex +CREATE INDEX "audit_logs_operator_id_idx" ON "audit_logs"("operator_id"); + +-- CreateIndex +CREATE INDEX "audit_logs_module_idx" ON "audit_logs"("module"); + +-- CreateIndex +CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at"); + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_auth" ADD CONSTRAINT "user_auth_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "admin_user_roles" ADD CONSTRAINT "admin_user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "admin_user_roles" ADD CONSTRAINT "admin_user_roles_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "agent_profiles" ADD CONSTRAINT "agent_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "wallets" ADD CONSTRAINT "wallets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "wallet_transactions" ADD CONSTRAINT "wallet_transactions_wallet_id_fkey" FOREIGN KEY ("wallet_id") REFERENCES "wallets"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "matches" ADD CONSTRAINT "matches_league_id_fkey" FOREIGN KEY ("league_id") REFERENCES "leagues"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "matches" ADD CONSTRAINT "matches_home_team_id_fkey" FOREIGN KEY ("home_team_id") REFERENCES "teams"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "matches" ADD CONSTRAINT "matches_away_team_id_fkey" FOREIGN KEY ("away_team_id") REFERENCES "teams"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "match_scores" ADD CONSTRAINT "match_scores_match_id_fkey" FOREIGN KEY ("match_id") REFERENCES "matches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "markets" ADD CONSTRAINT "markets_match_id_fkey" FOREIGN KEY ("match_id") REFERENCES "matches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "market_selections" ADD CONSTRAINT "market_selections_market_id_fkey" FOREIGN KEY ("market_id") REFERENCES "markets"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "odds_change_logs" ADD CONSTRAINT "odds_change_logs_selection_id_fkey" FOREIGN KEY ("selection_id") REFERENCES "market_selections"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "bets" ADD CONSTRAINT "bets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "bet_selections" ADD CONSTRAINT "bet_selections_bet_id_fkey" FOREIGN KEY ("bet_id") REFERENCES "bets"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "settlement_batches" ADD CONSTRAINT "settlement_batches_match_id_fkey" FOREIGN KEY ("match_id") REFERENCES "matches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "settlement_items" ADD CONSTRAINT "settlement_items_batch_id_fkey" FOREIGN KEY ("batch_id") REFERENCES "settlement_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cashback_items" ADD CONSTRAINT "cashback_items_batch_id_fkey" FOREIGN KEY ("batch_id") REFERENCES "cashback_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "content_translations" ADD CONSTRAINT "content_translations_content_id_fkey" FOREIGN KEY ("content_id") REFERENCES "contents"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3f00286..427333f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -55,6 +55,8 @@ model UserPreference { id BigInt @id @default(autoincrement()) userId BigInt @unique @map("user_id") locale String @default("en-US") @db.VarChar(10) + phone String? @db.VarChar(32) + email String? @db.VarChar(128) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 0122237..675f6de 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -3,6 +3,569 @@ import * as bcrypt from 'bcryptjs'; const prisma = new PrismaClient(); +/** 为演示赛事补齐详情页玩法(与后台 markets 模板一致) */ +async function seedDemoMarkets(matchId: bigint) { + const configs: Array<{ + marketType: string; + period: string; + lineValue?: number; + sortOrder: number; + selections: Array<{ code: string; name: string; odds: number }>; + }> = [ + { + marketType: 'FT_1X2', + period: 'FT', + sortOrder: 1, + selections: [ + { code: 'HOME', name: '主胜', odds: 2.5 }, + { code: 'DRAW', name: '和', odds: 3.2 }, + { code: 'AWAY', name: '客胜', odds: 2.8 }, + ], + }, + { + marketType: 'FT_HANDICAP', + period: 'FT', + lineValue: -0.5, + sortOrder: 2, + selections: [ + { code: 'HOME', name: '主 -0.5', odds: 1.9 }, + { code: 'AWAY', name: '客 +0.5', odds: 1.9 }, + ], + }, + { + marketType: 'FT_OVER_UNDER', + period: 'FT', + lineValue: 2.5, + sortOrder: 3, + selections: [ + { code: 'OVER', name: '大 2.5', odds: 1.85 }, + { code: 'UNDER', name: '小 2.5', odds: 1.95 }, + ], + }, + { + marketType: 'FT_ODD_EVEN', + period: 'FT', + sortOrder: 4, + selections: [ + { code: 'ODD', name: '单', odds: 1.9 }, + { code: 'EVEN', name: '双', odds: 1.9 }, + ], + }, + { + marketType: 'HT_1X2', + period: 'HT', + sortOrder: 5, + selections: [ + { code: 'HOME', name: '半场主', odds: 3.0 }, + { code: 'DRAW', name: '半场和', odds: 2.0 }, + { code: 'AWAY', name: '半场客', odds: 3.5 }, + ], + }, + { + marketType: 'HT_HANDICAP', + period: 'HT', + lineValue: -0.5, + sortOrder: 6, + selections: [ + { code: 'HOME', name: '半场主 -0.5', odds: 1.9 }, + { code: 'AWAY', name: '半场客 +0.5', odds: 1.9 }, + ], + }, + { + marketType: 'HT_OVER_UNDER', + period: 'HT', + lineValue: 1.5, + sortOrder: 7, + selections: [ + { code: 'OVER', name: '半场大 1.5', odds: 2.0 }, + { code: 'UNDER', name: '半场小 1.5', odds: 1.75 }, + ], + }, + { + marketType: 'FT_CORRECT_SCORE', + period: 'FT', + sortOrder: 8, + selections: [ + { code: 'SCORE_1_0', name: '1-0', odds: 4.86 }, + { code: 'SCORE_2_0', name: '2-0', odds: 5.22 }, + { code: 'SCORE_2_1', name: '2-1', odds: 7.92 }, + { code: 'SCORE_3_0', name: '3-0', odds: 8.28 }, + { code: 'SCORE_0_0', name: '0-0', odds: 8.64 }, + { code: 'SCORE_1_1', name: '1-1', odds: 7.47 }, + { code: 'SCORE_2_2', name: '2-2', odds: 24.3 }, + { code: 'SCORE_3_3', name: '3-3', odds: 175.5 }, + { code: 'SCORE_0_1', name: '0-1', odds: 14.4 }, + { code: 'SCORE_0_2', name: '0-2', odds: 45.9 }, + { code: 'SCORE_1_2', name: '1-2', odds: 23.4 }, + { code: 'SCORE_0_3', name: '0-3', odds: 207 }, + ], + }, + { + marketType: 'HT_CORRECT_SCORE', + period: 'HT', + sortOrder: 9, + selections: [ + { code: 'SCORE_1_0', name: '1-0', odds: 4.5 }, + { code: 'SCORE_2_0', name: '2-0', odds: 8.0 }, + { code: 'SCORE_0_0', name: '0-0', odds: 5.5 }, + { code: 'SCORE_1_1', name: '1-1', odds: 7.0 }, + { code: 'SCORE_0_1', name: '0-1', odds: 6.5 }, + { code: 'SCORE_0_2', name: '0-2', odds: 18.0 }, + ], + }, + { + marketType: 'SH_CORRECT_SCORE', + period: 'SH', + sortOrder: 10, + selections: [ + { code: 'SCORE_1_0', name: '1-0', odds: 4.5 }, + { code: 'SCORE_2_0', name: '2-0', odds: 8.0 }, + { code: 'SCORE_0_0', name: '0-0', odds: 5.5 }, + { code: 'SCORE_1_1', name: '1-1', odds: 7.0 }, + { code: 'SCORE_0_1', name: '0-1', odds: 6.5 }, + { code: 'SCORE_0_2', name: '0-2', odds: 18.0 }, + ], + }, + ]; + + for (const cfg of configs) { + const exists = await prisma.market.findFirst({ + where: { matchId, marketType: cfg.marketType }, + include: { _count: { select: { selections: true } } }, + }); + if (exists) { + const needRefresh = + cfg.marketType.includes('CORRECT_SCORE') && + exists._count.selections < cfg.selections.length; + if (needRefresh) { + await prisma.market.delete({ where: { id: exists.id } }); + } else { + continue; + } + } + await prisma.market.create({ + data: { + matchId, + marketType: cfg.marketType, + period: cfg.period, + lineValue: cfg.lineValue, + allowSingle: true, + allowParlay: true, + sortOrder: cfg.sortOrder, + status: 'OPEN', + selections: { + create: cfg.selections.map((s, i) => ({ + selectionCode: s.code, + selectionName: s.name, + odds: s.odds, + sortOrder: i, + status: 'OPEN', + })), + }, + }, + }); + } +} + +async function upsertLeagueName(leagueId: bigint, names: Record) { + for (const [locale, value] of Object.entries(names)) { + await prisma.entityTranslation.upsert({ + where: { + entityType_entityId_locale_fieldName: { + entityType: 'LEAGUE', + entityId: leagueId, + locale, + fieldName: 'name', + }, + }, + create: { entityType: 'LEAGUE', entityId: leagueId, locale, fieldName: 'name', value }, + update: { value }, + }); + } +} + +async function upsertTeam( + code: string, + names: Record, +) { + const team = await prisma.team.upsert({ + where: { code }, + create: { code }, + update: {}, + }); + for (const [locale, value] of Object.entries(names)) { + await prisma.entityTranslation.upsert({ + where: { + entityType_entityId_locale_fieldName: { + entityType: 'TEAM', + entityId: team.id, + locale, + fieldName: 'name', + }, + }, + create: { entityType: 'TEAM', entityId: team.id, locale, fieldName: 'name', value }, + update: { value }, + }); + } + return team; +} + +async function ensurePublishedMatch(opts: { + leagueId: bigint; + homeTeamId: bigint; + awayTeamId: bigint; + startTime: Date; + isHot?: boolean; + displayOrder?: number; +}) { + let match = await prisma.match.findFirst({ + where: { + leagueId: opts.leagueId, + homeTeamId: opts.homeTeamId, + awayTeamId: opts.awayTeamId, + status: 'PUBLISHED', + }, + }); + if (!match) { + match = await prisma.match.create({ + data: { + leagueId: opts.leagueId, + homeTeamId: opts.homeTeamId, + awayTeamId: opts.awayTeamId, + startTime: opts.startTime, + status: 'PUBLISHED', + isHot: opts.isHot ?? false, + displayOrder: opts.displayOrder ?? 0, + publishTime: new Date(), + }, + }); + } else { + match = await prisma.match.update({ + where: { id: match.id }, + data: { + startTime: opts.startTime, + isHot: opts.isHot ?? match.isHot, + displayOrder: opts.displayOrder ?? match.displayOrder, + }, + }); + } + await seedDemoMarkets(match.id); + return match; +} + +function hoursFromNow(hours: number) { + return new Date(Date.now() + hours * 3600 * 1000); +} + +async function seedSportsDemo() { + const epl = await prisma.league.upsert({ + where: { code: 'EPL' }, + create: { code: 'EPL' }, + update: {}, + }); + await upsertLeagueName(epl.id, { 'zh-CN': '英超', 'en-US': 'Premier League' }); + + const wc = await prisma.league.upsert({ + where: { code: 'WC2026' }, + create: { code: 'WC2026' }, + update: {}, + }); + await upsertLeagueName(wc.id, { + 'zh-CN': '2026世界杯(在加拿大,墨西哥和美国)', + 'en-US': '2026 FIFA World Cup', + }); + + const teams: Array<[string, Record]> = [ + ['MUN', { 'zh-CN': '曼联', 'en-US': 'Man United' }], + ['CHE', { 'zh-CN': '切尔西', 'en-US': 'Chelsea' }], + ['MEX', { 'zh-CN': '墨西哥', 'en-US': 'Mexico' }], + ['RSA', { 'zh-CN': '南非', 'en-US': 'South Africa' }], + ['CZE', { 'zh-CN': '捷克', 'en-US': 'Czech Republic' }], + ['KOR', { 'zh-CN': '韩国', 'en-US': 'South Korea' }], + ['CAN', { 'zh-CN': '加拿大', 'en-US': 'Canada' }], + ['BIH', { 'zh-CN': '波黑', 'en-US': 'Bosnia' }], + ['USA', { 'zh-CN': '美国', 'en-US': 'USA' }], + ['PAR', { 'zh-CN': '巴拉圭', 'en-US': 'Paraguay' }], + ['SUI', { 'zh-CN': '瑞士', 'en-US': 'Switzerland' }], + ['BRA', { 'zh-CN': '巴西', 'en-US': 'Brazil' }], + ['SCO', { 'zh-CN': '苏格兰', 'en-US': 'Scotland' }], + ['TUR', { 'zh-CN': '土耳其', 'en-US': 'Turkey' }], + ['ARG', { 'zh-CN': '阿根廷', 'en-US': 'Argentina' }], + ['FRA', { 'zh-CN': '法国', 'en-US': 'France' }], + ]; + + const teamMap = new Map(); + for (const [code, names] of teams) { + teamMap.set(code, await upsertTeam(code, names)); + } + + const get = (code: string) => { + const t = teamMap.get(code); + if (!t) throw new Error(`Team ${code} missing`); + return t; + }; + + // 英超:明日开赛 → 早盘 + await ensurePublishedMatch({ + leagueId: epl.id, + homeTeamId: get('MUN').id, + awayTeamId: get('CHE').id, + startTime: hoursFromNow(26), + isHot: true, + displayOrder: 1, + }); + + // 英超:今晚开赛 → 今日 + await ensurePublishedMatch({ + leagueId: epl.id, + homeTeamId: get('CHE').id, + awayTeamId: get('MUN').id, + startTime: hoursFromNow(8), + isHot: false, + displayOrder: 2, + }); + + const wcFixtures: Array<{ + home: string; + away: string; + start: Date; + hot?: boolean; + order: number; + }> = [ + { home: 'MEX', away: 'RSA', start: new Date('2026-06-12T03:00:00Z'), hot: true, order: 1 }, + { home: 'CZE', away: 'KOR', start: new Date('2026-06-12T07:00:00Z'), order: 2 }, + { home: 'CAN', away: 'BIH', start: new Date('2026-06-13T00:00:00Z'), order: 3 }, + { home: 'USA', away: 'PAR', start: new Date('2026-06-13T03:00:00Z'), hot: true, order: 4 }, + { home: 'SUI', away: 'BRA', start: new Date('2026-06-14T16:00:00Z'), order: 5 }, + { home: 'SCO', away: 'TUR', start: new Date('2026-06-14T19:00:00Z'), order: 6 }, + { home: 'FRA', away: 'ARG', start: new Date('2026-06-15T20:00:00Z'), hot: true, order: 7 }, + ]; + + for (const f of wcFixtures) { + await ensurePublishedMatch({ + leagueId: wc.id, + homeTeamId: get(f.home).id, + awayTeamId: get(f.away).id, + startTime: f.start, + isHot: f.hot, + displayOrder: f.order, + }); + } + + console.log(` Sports demo: ${wcFixtures.length + 2} published matches`); +} + +async function seedOutrightDemo() { + const wc = await prisma.league.findUnique({ where: { code: 'WC2026' } }); + if (!wc) return; + + const placeholder = await upsertTeam('OUT', { 'zh-CN': '冠军盘', 'en-US': 'Outright' }); + + const outrightOdds: Array<[string, Record, number]> = [ + ['FRA', { 'zh-CN': '法国', 'en-US': 'France' }, 4.95], + ['ESP', { 'zh-CN': '西班牙', 'en-US': 'Spain' }, 4.95], + ['ENG', { 'zh-CN': '英格兰', 'en-US': 'England' }, 6.3], + ['BRA', { 'zh-CN': '巴西', 'en-US': 'Brazil' }, 8.55], + ['ARG', { 'zh-CN': '阿根廷', 'en-US': 'Argentina' }, 8.55], + ['POR', { 'zh-CN': '葡萄牙', 'en-US': 'Portugal' }, 9.0], + ['GER', { 'zh-CN': '德国', 'en-US': 'Germany' }, 15.3], + ['NED', { 'zh-CN': '荷兰', 'en-US': 'Netherlands' }, 18.9], + ['NOR', { 'zh-CN': '挪威', 'en-US': 'Norway' }, 32.4], + ['BEL', { 'zh-CN': '比利时', 'en-US': 'Belgium' }, 35.1], + ['COL', { 'zh-CN': '哥伦比亚', 'en-US': 'Colombia' }, 45.9], + ['JPN', { 'zh-CN': '日本', 'en-US': 'Japan' }, 45.9], + ['URU', { 'zh-CN': '乌拉圭', 'en-US': 'Uruguay' }, 63.9], + ['USA', { 'zh-CN': '美国', 'en-US': 'USA' }, 63.9], + ['MAR', { 'zh-CN': '摩洛哥', 'en-US': 'Morocco' }, 63.9], + ['CRO', { 'zh-CN': '克罗地亚', 'en-US': 'Croatia' }, 81.0], + ['MEX', { 'zh-CN': '墨西哥', 'en-US': 'Mexico' }, 85.0], + ['SUI', { 'zh-CN': '瑞士', 'en-US': 'Switzerland' }, 90.0], + ['TUR', { 'zh-CN': '土耳其', 'en-US': 'Turkey' }, 95.0], + ['SEN', { 'zh-CN': '塞内加尔', 'en-US': 'Senegal' }, 100.0], + ]; + + for (const [code, names] of outrightOdds) { + await upsertTeam(code, names); + } + + let match = await prisma.match.findFirst({ + where: { leagueId: wc.id, isOutright: true }, + }); + if (!match) { + match = await prisma.match.create({ + data: { + leagueId: wc.id, + homeTeamId: placeholder.id, + awayTeamId: placeholder.id, + isOutright: true, + startTime: new Date('2027-07-01T00:00:00Z'), + status: 'PUBLISHED', + publishTime: new Date(), + isHot: true, + displayOrder: 0, + }, + }); + } + + const marketExists = await prisma.market.findFirst({ + where: { matchId: match.id, marketType: 'OUTRIGHT_WINNER' }, + }); + if (!marketExists) { + await prisma.market.create({ + data: { + matchId: match.id, + marketType: 'OUTRIGHT_WINNER', + period: 'OUTRIGHT', + allowSingle: true, + allowParlay: false, + sortOrder: 1, + status: 'OPEN', + selections: { + create: outrightOdds.map(([code, names, odds], i) => ({ + selectionCode: code, + selectionName: names['zh-CN'], + odds, + sortOrder: i, + status: 'OPEN', + })), + }, + }, + }); + } + + console.log(' Outright demo: World Cup winner market'); +} + +async function seedPlayerDemo() { + const player = await prisma.user.findUnique({ + where: { username: 'player1' }, + include: { wallet: true }, + }); + if (!player?.wallet) return; + + await prisma.wallet.update({ + where: { id: player.wallet.id }, + data: { availableBalance: 88888.88 }, + }); + + await prisma.walletTransaction.upsert({ + where: { transactionId: 'DEMO-DEP-001' }, + create: { + transactionId: 'DEMO-DEP-001', + userId: player.id, + walletId: player.wallet.id, + transactionType: 'DEPOSIT', + amount: 50000, + balanceBefore: 1000, + balanceAfter: 51000, + frozenBefore: 0, + frozenAfter: 0, + remark: '演示充值', + }, + update: {}, + }); + + await prisma.walletTransaction.upsert({ + where: { transactionId: 'DEMO-DEP-002' }, + create: { + transactionId: 'DEMO-DEP-002', + userId: player.id, + walletId: player.wallet.id, + transactionType: 'DEPOSIT', + amount: 37888.88, + balanceBefore: 51000, + balanceAfter: 88888.88, + frozenBefore: 0, + frozenAfter: 0, + remark: '演示充值(二笔)', + }, + update: {}, + }); + + const sampleSel = await prisma.marketSelection.findFirst({ + where: { + status: 'OPEN', + market: { marketType: 'FT_1X2', match: { status: 'PUBLISHED' } }, + }, + include: { market: { include: { match: true } } }, + }); + + if (sampleSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-001' } }))) { + const odds = Number(sampleSel.odds); + const stake = 200; + await prisma.bet.create({ + data: { + betNo: 'DEMO-BET-001', + userId: player.id, + agentId: player.parentId, + betType: 'SINGLE', + stake, + totalOdds: odds, + potentialReturn: stake * odds, + status: 'PENDING', + requestId: 'seed-demo-bet-001', + selections: { + create: { + matchId: sampleSel.market.matchId, + marketId: sampleSel.marketId, + selectionId: sampleSel.id, + marketType: sampleSel.market.marketType, + period: sampleSel.market.period, + selectionNameSnapshot: sampleSel.selectionName, + odds: sampleSel.odds, + oddsVersion: sampleSel.oddsVersion, + }, + }, + }, + }); + } + + const settledSel = await prisma.marketSelection.findFirst({ + where: { + market: { marketType: 'FT_1X2' }, + selectionCode: 'DRAW', + }, + include: { market: true }, + }); + + if (settledSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-002' } }))) { + const odds = Number(settledSel.odds); + const stake = 50; + await prisma.bet.create({ + data: { + betNo: 'DEMO-BET-002', + userId: player.id, + agentId: player.parentId, + betType: 'SINGLE', + stake, + totalOdds: odds, + potentialReturn: stake * odds, + actualReturn: stake * odds, + status: 'WON', + settlementStatus: 'SETTLED', + settledAt: new Date(Date.now() - 86400000), + requestId: 'seed-demo-bet-002', + selections: { + create: { + matchId: settledSel.market.matchId, + marketId: settledSel.marketId, + selectionId: settledSel.id, + marketType: settledSel.market.marketType, + period: settledSel.market.period, + selectionNameSnapshot: settledSel.selectionName, + odds: settledSel.odds, + oddsVersion: settledSel.oddsVersion, + resultStatus: 'WIN', + effectiveOdds: settledSel.odds, + }, + }, + }, + }); + } + + console.log(' Player demo: wallet + transactions + sample bets'); +} + async function main() { console.log('Seeding database...'); @@ -107,70 +670,79 @@ async function main() { } } - const league = await prisma.league.upsert({ - where: { code: 'EPL' }, - create: { code: 'EPL' }, - update: {}, - }); - - await prisma.entityTranslation.upsert({ - where: { entityType_entityId_locale_fieldName: { entityType: 'LEAGUE', entityId: league.id, locale: 'zh-CN', fieldName: 'name' } }, - create: { entityType: 'LEAGUE', entityId: league.id, locale: 'zh-CN', fieldName: 'name', value: '英超' }, - update: {}, - }); - - for (const [code, name] of [['MUN', '曼联'], ['CHE', '切尔西']] as const) { - const team = await prisma.team.upsert({ where: { code }, create: { code }, update: {} }); - await prisma.entityTranslation.upsert({ - where: { entityType_entityId_locale_fieldName: { entityType: 'TEAM', entityId: team.id, locale: 'zh-CN', fieldName: 'name' } }, - create: { entityType: 'TEAM', entityId: team.id, locale: 'zh-CN', fieldName: 'name', value: name }, - update: { value: name }, - }); - } - - const mun = await prisma.team.findUnique({ where: { code: 'MUN' } }); - const che = await prisma.team.findUnique({ where: { code: 'CHE' } }); - - if (mun && che) { - const existing = await prisma.match.findFirst({ where: { homeTeamId: mun.id, awayTeamId: che.id } }); - if (!existing) { - const match = await prisma.match.create({ - data: { - leagueId: league.id, - homeTeamId: mun.id, - awayTeamId: che.id, - startTime: new Date(Date.now() + 86400000), - status: 'PUBLISHED', - isHot: true, - publishTime: new Date(), - }, - }); - await prisma.market.create({ - data: { - matchId: match.id, - marketType: 'FT_1X2', - period: 'FT', - selections: { - create: [ - { selectionCode: 'HOME', selectionName: 'Home', odds: 2.5 }, - { selectionCode: 'DRAW', selectionName: 'Draw', odds: 3.2 }, - { selectionCode: 'AWAY', selectionName: 'Away', odds: 2.8 }, - ], - }, - }, - }); - } - } + await seedSportsDemo(); + await seedOutrightDemo(); + await seedPlayerDemo(); await prisma.content.create({ data: { contentType: 'BANNER', status: 'ACTIVE', sortOrder: 1, + linkType: 'ROUTE', + linkTarget: '/football', translations: { create: [ - { locale: 'zh-CN', title: '欢迎投注', body: '足球赛事火热进行中' }, - { locale: 'en-US', title: 'Welcome', body: 'Football matches available' }, + { locale: 'zh-CN', title: '欢迎投注', body: '足球赛事火热进行中', imageUrl: '/uploads/banners/welcome.svg' }, + { locale: 'en-US', title: 'Welcome', body: 'Football matches available', imageUrl: '/uploads/banners/welcome.svg' }, + ], + }, + }, + }).catch(() => {}); + + await prisma.content.create({ + data: { + contentType: 'BANNER', + status: 'ACTIVE', + sortOrder: 2, + translations: { + create: [ + { locale: 'zh-CN', title: '首存礼遇', body: '新会员专属优惠', imageUrl: '/uploads/banners/promo.svg' }, + { locale: 'en-US', title: 'First Deposit', body: 'New member offer', imageUrl: '/uploads/banners/promo.svg' }, + ], + }, + }, + }).catch(() => {}); + + await prisma.content.create({ + data: { + contentType: 'BANNER', + status: 'ACTIVE', + sortOrder: 3, + linkType: 'ROUTE', + linkTarget: '/football', + translations: { + create: [ + { locale: 'zh-CN', title: '热门赛事', body: '五大联赛天天有球', imageUrl: '/uploads/banners/hot-matches.svg' }, + { locale: 'en-US', title: 'Hot Matches', body: 'Top leagues daily', imageUrl: '/uploads/banners/hot-matches.svg' }, + ], + }, + }, + }).catch(() => {}); + + await prisma.content.create({ + data: { + contentType: 'TICKER', + status: 'ACTIVE', + sortOrder: 1, + translations: { + create: [ + { locale: 'zh-CN', body: '欢迎来到 TheBet365 · 热门赛事每日更新 · 请理性投注' }, + { locale: 'en-US', body: 'Welcome to TheBet365 · Daily hot matches · Bet responsibly' }, + ], + }, + }, + }).catch(() => {}); + + await prisma.content.create({ + data: { + contentType: 'NOTICE', + status: 'ACTIVE', + sortOrder: 1, + translations: { + create: [ + { locale: 'zh-CN', title: '系统维护通知:每周一 04:00-05:00 例行维护,敬请谅解' }, + { locale: 'en-US', title: 'Maintenance: Every Mon 04:00-05:00 UTC' }, ], }, }, diff --git a/apps/api/src/applications/player/player.controller.ts b/apps/api/src/applications/player/player.controller.ts index 9c1312c..b9dae16 100644 --- a/apps/api/src/applications/player/player.controller.ts +++ b/apps/api/src/applications/player/player.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, + Patch, Body, Param, Query, @@ -62,6 +63,16 @@ class LocaleDto { locale!: string; } +class UpdateProfileDto { + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + email?: string; +} + @ApiTags('Player') @Controller('player') @UseGuards(JwtAuthGuard, PlayerGuard) @@ -88,6 +99,12 @@ export class PlayerController { return jsonResponse(result); } + @Patch('profile') + async updateProfile(@CurrentUser('id') userId: bigint, @Body() dto: UpdateProfileDto) { + const user = await this.users.updateProfile(userId, dto); + return jsonResponse(user); + } + @Get('home') async home(@CurrentUser('locale') locale: string) { const [banners, notices, ticker, hotMatches, todayMatches] = await Promise.all([ @@ -115,6 +132,12 @@ export class PlayerController { return jsonResponse(items); } + @Get('outrights') + async listOutrights(@CurrentUser('locale') locale: string) { + const items = await this.matches.listOutrights(locale); + return jsonResponse(items); + } + @Get('matches/:id') async matchDetail(@Param('id') id: string, @CurrentUser('locale') locale: string) { const match = await this.matches.getMatchDetail(BigInt(id), locale); @@ -149,11 +172,13 @@ export class PlayerController { @Get('bets') async myBets( @CurrentUser('id') userId: bigint, + @CurrentUser('locale') locale: string, @Query('status') status?: string, @Query('page') page?: string, ) { const result = await this.bets.getUserBets(userId, status, page ? parseInt(page) : 1); - return jsonResponse(result); + const items = await this.matches.enrichBetsForHistory(result.items, locale); + return jsonResponse({ ...result, items }); } @Get('bets/:betNo') diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts index c62bd4b..7ca1c00 100644 --- a/apps/api/src/domains/catalog/matches.service.ts +++ b/apps/api/src/domains/catalog/matches.service.ts @@ -96,9 +96,8 @@ export class MatchesService { leagueId: bigint; homeTeamId: bigint; awayTeamId: bigint; - league?: unknown; - homeTeam?: unknown; - awayTeam?: unknown; + homeTeam?: { code: string }; + awayTeam?: { code: string }; markets?: unknown[]; }; const [leagueName, homeName, awayName] = await Promise.all([ @@ -108,9 +107,13 @@ export class MatchesService { ]); return { ...match, + id: m.id.toString(), + leagueId: m.leagueId.toString(), leagueName, homeTeamName: homeName, awayTeamName: awayName, + homeTeamCode: m.homeTeam?.code ?? '', + awayTeamCode: m.awayTeam?.code ?? '', }; } @@ -118,9 +121,12 @@ export class MatchesService { const matches = await this.prisma.match.findMany({ where: { status: 'PUBLISHED', + isOutright: false, ...(leagueId ? { leagueId } : {}), }, include: { + homeTeam: true, + awayTeam: true, markets: { where: { status: 'OPEN' }, include: { selections: { where: { status: 'OPEN' } } }, @@ -136,8 +142,11 @@ export class MatchesService { const match = await this.prisma.match.findUnique({ where: { id: matchId }, include: { + homeTeam: true, + awayTeam: true, markets: { - include: { selections: true }, + where: { status: 'OPEN' }, + include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } }, orderBy: { sortOrder: 'asc' }, }, score: true, @@ -147,12 +156,180 @@ export class MatchesService { return this.enrichMatch(match, locale); } + async listOutrights(locale = 'en-US') { + const matches = await this.prisma.match.findMany({ + where: { status: 'PUBLISHED', isOutright: true }, + include: { + markets: { + where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' }, + include: { + selections: { + where: { status: 'OPEN' }, + orderBy: { sortOrder: 'asc' }, + }, + }, + }, + }, + orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }], + }); + + const results = []; + for (const match of matches) { + const leagueName = await this.getTranslation('LEAGUE', match.leagueId, locale); + const market = match.markets[0]; + if (!market) continue; + + const selections = await Promise.all( + market.selections.map(async (sel) => { + const teamCode = sel.selectionCode.replace(/^TEAM_/, ''); + const team = await this.prisma.team.findUnique({ where: { code: teamCode } }); + const teamName = team + ? await this.getTranslation('TEAM', team.id, locale) + : sel.selectionName; + return { + id: sel.id.toString(), + teamCode, + teamName, + odds: sel.odds.toString(), + oddsVersion: sel.oddsVersion.toString(), + }; + }), + ); + + results.push({ + id: match.id.toString(), + leagueId: match.leagueId.toString(), + leagueName, + title: `*${leagueName} 冠军`, + marketId: market.id.toString(), + selections, + }); + } + return results; + } + + private marketLabelKey(marketType: string): string { + const keys: Record = { + FT_1X2: '全场独赢', + FT_HANDICAP: '全场让球', + FT_OVER_UNDER: '全场大小', + FT_ODD_EVEN: '全场单双', + HT_1X2: '半场独赢', + HT_HANDICAP: '半场让球', + HT_OVER_UNDER: '半场大小', + OUTRIGHT_WINNER: '冠军', + FT_CORRECT_SCORE: '波胆', + HT_CORRECT_SCORE: '上半场波胆', + SH_CORRECT_SCORE: '下半场波胆', + }; + return keys[marketType] ?? marketType; + } + + async enrichBetsForHistory( + bets: Array<{ + betNo: string; + betType: string; + stake: unknown; + totalOdds: unknown; + potentialReturn: unknown; + actualReturn: unknown; + status: string; + placedAt: Date; + selections: Array<{ + matchId: bigint | null; + marketType: string; + selectionNameSnapshot: string; + odds: unknown; + resultStatus?: string | null; + }>; + }>, + locale: string, + ) { + const matchIds = [ + ...new Set( + bets.flatMap((b) => + b.selections.map((s) => s.matchId).filter((id): id is bigint => id != null), + ), + ), + ]; + + const matches = + matchIds.length > 0 + ? await this.prisma.match.findMany({ + where: { id: { in: matchIds } }, + include: { homeTeam: true, awayTeam: true }, + }) + : []; + + const matchMeta = new Map< + string, + { leagueName: string; matchTitle: string; isOutright: boolean } + >(); + + for (const m of matches) { + const [leagueName, homeName, awayName] = await Promise.all([ + this.getTranslation('LEAGUE', m.leagueId, locale), + this.getTranslation('TEAM', m.homeTeamId, locale), + this.getTranslation('TEAM', m.awayTeamId, locale), + ]); + matchMeta.set(m.id.toString(), { + leagueName, + matchTitle: m.isOutright ? leagueName : `${homeName} vs ${awayName}`, + isOutright: m.isOutright, + }); + } + + return bets.map((bet) => { + const firstMatchId = bet.selections.find((s) => s.matchId)?.matchId?.toString(); + const meta = firstMatchId ? matchMeta.get(firstMatchId) : undefined; + const isParlay = bet.betType === 'PARLAY' || bet.selections.length > 1; + + const legs = bet.selections.map((sel) => { + const mid = sel.matchId?.toString(); + const m = mid ? matchMeta.get(mid) : undefined; + return { + marketType: sel.marketType, + marketLabel: this.marketLabelKey(sel.marketType), + selectionName: sel.selectionNameSnapshot, + odds: sel.odds, + resultStatus: sel.resultStatus, + matchTitle: m?.matchTitle ?? sel.selectionNameSnapshot, + leagueName: m?.leagueName ?? '', + }; + }); + + return { + betNo: bet.betNo, + betType: bet.betType, + stake: bet.stake, + totalOdds: bet.totalOdds, + potentialReturn: bet.potentialReturn, + actualReturn: bet.actualReturn, + status: bet.status, + placedAt: bet.placedAt, + leagueName: isParlay + ? 'Parlay' + : meta?.leagueName ?? legs[0]?.leagueName ?? '', + legCount: bet.selections.length, + matchTitle: isParlay + ? '' + : meta?.matchTitle ?? legs[0]?.matchTitle ?? bet.betNo, + pickLabel: isParlay + ? '' + : `${legs[0]?.marketLabel ?? ''}: ${legs[0]?.selectionName ?? ''}`, + legs, + isParlay, + }; + }); + } + @Cron(CronExpression.EVERY_MINUTE) async autoCloseMatches() { const now = new Date(); await this.prisma.match.updateMany({ where: { status: 'PUBLISHED', + isOutright: false, startTime: { lte: now }, }, data: { status: 'CLOSED', closeTime: now }, diff --git a/apps/api/src/domains/identity/users.service.ts b/apps/api/src/domains/identity/users.service.ts index c308b27..e340c11 100644 --- a/apps/api/src/domains/identity/users.service.ts +++ b/apps/api/src/domains/identity/users.service.ts @@ -12,6 +12,17 @@ export class UsersService { }); } + async updateProfile(userId: bigint, data: { phone?: string; email?: string }) { + const phone = data.phone?.trim() || null; + const email = data.email?.trim() || null; + await this.prisma.userPreference.upsert({ + where: { userId }, + create: { userId, phone, email }, + update: { phone, email }, + }); + return this.findById(userId); + } + async updateLocale(userId: bigint, locale: string) { await this.prisma.user.update({ where: { id: userId }, diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 113dc32..a385c53 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,10 +1,15 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { join } from 'path'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); + + const uploadDir = process.env.UPLOAD_DIR || join(__dirname, '..', '..', 'uploads'); + app.useStaticAssets(uploadDir, { prefix: '/uploads/' }); app.setGlobalPrefix('api'); app.enableCors({ origin: true, credentials: true }); diff --git a/apps/api/src/shared/common/decorators.ts b/apps/api/src/shared/common/decorators.ts index fa32ef3..dd593cd 100644 --- a/apps/api/src/shared/common/decorators.ts +++ b/apps/api/src/shared/common/decorators.ts @@ -32,10 +32,18 @@ export function generateBatchNo(prefix: string): string { return `${prefix}${ts}`; } +function isDecimalLike(obj: object): obj is { toJSON: () => string } { + return ( + typeof (obj as { toJSON?: unknown }).toJSON === 'function' && + typeof (obj as { toFixed?: unknown }).toFixed === 'function' + ); +} + export function serializeBigInt(obj: unknown): unknown { if (obj === null || obj === undefined) return obj; if (typeof obj === 'bigint') return obj.toString(); if (obj instanceof Date) return obj.toISOString(); + if (typeof obj === 'object' && isDecimalLike(obj)) return obj.toJSON(); if (Array.isArray(obj)) return obj.map(serializeBigInt); if (typeof obj === 'object') { const result: Record = {}; diff --git a/apps/player/index.html b/apps/player/index.html index 8c37de1..d0cf53b 100644 --- a/apps/player/index.html +++ b/apps/player/index.html @@ -3,7 +3,10 @@ - + + + + TheBet365 diff --git a/apps/player/src/assets/images/banner.png b/apps/player/src/assets/images/banner.png new file mode 100644 index 0000000..3304710 Binary files /dev/null and b/apps/player/src/assets/images/banner.png differ diff --git a/apps/player/src/assets/images/banner.svg b/apps/player/src/assets/images/banner.svg new file mode 100644 index 0000000..56d644a --- /dev/null +++ b/apps/player/src/assets/images/banner.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + TheBet365 + 足球赛事火热进行中 + + diff --git a/apps/player/src/assets/images/empty-matches.svg b/apps/player/src/assets/images/empty-matches.svg new file mode 100644 index 0000000..0aa0aa0 --- /dev/null +++ b/apps/player/src/assets/images/empty-matches.svg @@ -0,0 +1,8 @@ + diff --git a/apps/player/src/assets/images/h5bg.png b/apps/player/src/assets/images/h5bg.png new file mode 100644 index 0000000..c2daa37 Binary files /dev/null and b/apps/player/src/assets/images/h5bg.png differ diff --git a/apps/player/src/assets/images/saishi.png b/apps/player/src/assets/images/saishi.png new file mode 100644 index 0000000..c8ea841 Binary files /dev/null and b/apps/player/src/assets/images/saishi.png differ diff --git a/apps/player/src/components/AnnouncementMarquee.vue b/apps/player/src/components/AnnouncementMarquee.vue new file mode 100644 index 0000000..e3c3345 --- /dev/null +++ b/apps/player/src/components/AnnouncementMarquee.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/apps/player/src/components/BannerCarousel.vue b/apps/player/src/components/BannerCarousel.vue new file mode 100644 index 0000000..79725cb --- /dev/null +++ b/apps/player/src/components/BannerCarousel.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/apps/player/src/components/BetHistoryCard.vue b/apps/player/src/components/BetHistoryCard.vue new file mode 100644 index 0000000..1a13e98 --- /dev/null +++ b/apps/player/src/components/BetHistoryCard.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/apps/player/src/components/BetSlipDrawer.vue b/apps/player/src/components/BetSlipDrawer.vue index 76cdd1b..28c8326 100644 --- a/apps/player/src/components/BetSlipDrawer.vue +++ b/apps/player/src/components/BetSlipDrawer.vue @@ -32,7 +32,7 @@ async function placeBet() { const requestId = genId(); if (slip.mode === 'parlay' && slip.items.length >= 2) { if (slip.hasSameMatch) { - error.value = '同一场比赛不能串关'; + error.value = t('bet.parlay_same_match'); return; } await api.post('/player/bets/parlay', { @@ -52,7 +52,7 @@ async function placeBet() { requestId, }); } else { - error.value = '请选择投注项'; + error.value = t('bet.parlay_need_more'); return; } success.value = '下注成功!'; @@ -70,15 +70,15 @@ async function placeBet() {
-

{{ t('bet.bet_slip') }} ({{ slip.count }})

- +

{{ t('bet.bet_slip') }} ({{ slip.count }})

+
点击赔率添加投注
{{ item.matchName }}
-
{{ item.selectionName }} @ {{ item.odds }}
+
{{ item.selectionName }} @ {{ item.odds }}
@@ -88,7 +88,7 @@ async function placeBet() {
{{ t('bet.parlay') }} · 赔率 {{ slip.totalOdds.toFixed(2) }}
-
预计返还: {{ slip.potentialReturn.toFixed(2) }}
+
预计返还: {{ slip.potentialReturn.toFixed(2) }}

{{ error }}

@@ -102,20 +102,49 @@ async function placeBet() { diff --git a/apps/player/src/components/BottomNavIcon.vue b/apps/player/src/components/BottomNavIcon.vue new file mode 100644 index 0000000..35084bd --- /dev/null +++ b/apps/player/src/components/BottomNavIcon.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/apps/player/src/components/CashBalanceChip.vue b/apps/player/src/components/CashBalanceChip.vue new file mode 100644 index 0000000..0f9c430 --- /dev/null +++ b/apps/player/src/components/CashBalanceChip.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/apps/player/src/components/LeagueAccordionItem.vue b/apps/player/src/components/LeagueAccordionItem.vue new file mode 100644 index 0000000..25b8c0e --- /dev/null +++ b/apps/player/src/components/LeagueAccordionItem.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/apps/player/src/components/LocaleFlag.vue b/apps/player/src/components/LocaleFlag.vue new file mode 100644 index 0000000..fd3ee81 --- /dev/null +++ b/apps/player/src/components/LocaleFlag.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/apps/player/src/components/MatchBetCard.vue b/apps/player/src/components/MatchBetCard.vue new file mode 100644 index 0000000..743a05b --- /dev/null +++ b/apps/player/src/components/MatchBetCard.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/apps/player/src/components/RobotVerify.vue b/apps/player/src/components/RobotVerify.vue new file mode 100644 index 0000000..4df0882 --- /dev/null +++ b/apps/player/src/components/RobotVerify.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/apps/player/src/components/UserAvatarMenu.vue b/apps/player/src/components/UserAvatarMenu.vue new file mode 100644 index 0000000..41c0f39 --- /dev/null +++ b/apps/player/src/components/UserAvatarMenu.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/apps/player/src/components/match-detail/CorrectScorePanel.vue b/apps/player/src/components/match-detail/CorrectScorePanel.vue new file mode 100644 index 0000000..9b2cf62 --- /dev/null +++ b/apps/player/src/components/match-detail/CorrectScorePanel.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/apps/player/src/components/match-detail/FeaturedMarketRow.vue b/apps/player/src/components/match-detail/FeaturedMarketRow.vue new file mode 100644 index 0000000..256fb54 --- /dev/null +++ b/apps/player/src/components/match-detail/FeaturedMarketRow.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/apps/player/src/components/match-detail/MarketSelectionsPanel.vue b/apps/player/src/components/match-detail/MarketSelectionsPanel.vue new file mode 100644 index 0000000..e6d04d0 --- /dev/null +++ b/apps/player/src/components/match-detail/MarketSelectionsPanel.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/apps/player/src/components/match-detail/MarketTypeTile.vue b/apps/player/src/components/match-detail/MarketTypeTile.vue new file mode 100644 index 0000000..f57ec2a --- /dev/null +++ b/apps/player/src/components/match-detail/MarketTypeTile.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/apps/player/src/components/outright/OutrightBetModal.vue b/apps/player/src/components/outright/OutrightBetModal.vue new file mode 100644 index 0000000..871ce77 --- /dev/null +++ b/apps/player/src/components/outright/OutrightBetModal.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/apps/player/src/components/outright/OutrightOptionCard.vue b/apps/player/src/components/outright/OutrightOptionCard.vue new file mode 100644 index 0000000..4e21a20 --- /dev/null +++ b/apps/player/src/components/outright/OutrightOptionCard.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/apps/player/src/components/outright/OutrightPanel.vue b/apps/player/src/components/outright/OutrightPanel.vue new file mode 100644 index 0000000..05561cb --- /dev/null +++ b/apps/player/src/components/outright/OutrightPanel.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/apps/player/src/components/parlay/ParlayPanel.vue b/apps/player/src/components/parlay/ParlayPanel.vue new file mode 100644 index 0000000..48ffe31 --- /dev/null +++ b/apps/player/src/components/parlay/ParlayPanel.vue @@ -0,0 +1,403 @@ + + + + + diff --git a/apps/player/src/composables/useAnnouncements.ts b/apps/player/src/composables/useAnnouncements.ts new file mode 100644 index 0000000..bed1ec9 --- /dev/null +++ b/apps/player/src/composables/useAnnouncements.ts @@ -0,0 +1,35 @@ +import { ref } from 'vue'; +import api from '../api'; +import { resolveAnnouncements } from '../constants/defaultAnnouncement'; + +function collectAnnouncementLines(data: { + ticker?: Array<{ translation?: { title?: string; body?: string } }>; + notices?: Array<{ translation?: { title?: string; body?: string } }>; +} | null): string[] { + const lines: string[] = []; + if (!data) return lines; + for (const item of data.ticker ?? []) { + const text = item.translation?.body || item.translation?.title; + if (text) lines.push(text); + } + for (const item of data.notices ?? []) { + const text = item.translation?.title || item.translation?.body; + if (text) lines.push(text); + } + return lines; +} + +export function useAnnouncements() { + const items = ref(resolveAnnouncements([])); + + async function load() { + try { + const { data } = await api.get('/player/home'); + items.value = resolveAnnouncements(collectAnnouncementLines(data.data)); + } catch { + items.value = resolveAnnouncements([]); + } + } + + return { items, load }; +} diff --git a/apps/player/src/constants/defaultAnnouncement.ts b/apps/player/src/constants/defaultAnnouncement.ts new file mode 100644 index 0000000..1d71d1f --- /dev/null +++ b/apps/player/src/constants/defaultAnnouncement.ts @@ -0,0 +1,8 @@ +export const DEFAULT_ANNOUNCEMENTS = [ + '欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行', +]; + +export function resolveAnnouncements(items: string[]): string[] { + const list = items.map((s) => s.trim()).filter(Boolean); + return list.length ? list : DEFAULT_ANNOUNCEMENTS; +} diff --git a/apps/player/src/constants/defaultBanner.ts b/apps/player/src/constants/defaultBanner.ts new file mode 100644 index 0000000..dbcaef1 --- /dev/null +++ b/apps/player/src/constants/defaultBanner.ts @@ -0,0 +1,41 @@ +import type { BannerItem } from '../components/BannerCarousel.vue'; + +/** 默认轮播首图:apps/player/src/assets/images/banner.png */ +import defaultBannerImg from '../assets/images/banner.png'; + +const FALLBACK_BANNER_URL = '/uploads/banners/welcome.svg'; + +export const DEFAULT_BANNER: BannerItem = { + id: 'default', + linkType: 'ROUTE', + linkTarget: '/bet', + translation: { + title: '', + imageUrl: defaultBannerImg, + }, +}; + +function pickImageUrl(url?: string | null): string { + if (!url) return defaultBannerImg; + return url; +} + +export function resolveBanners(banners: BannerItem[] | undefined | null): BannerItem[] { + const fromApi = (banners ?? []).map((banner) => ({ + ...banner, + translation: { + ...banner.translation, + imageUrl: pickImageUrl(banner.translation?.imageUrl), + }, + })); + + const defaultSlide: BannerItem = { + ...DEFAULT_BANNER, + translation: { + ...DEFAULT_BANNER.translation, + imageUrl: defaultBannerImg || FALLBACK_BANNER_URL, + }, + }; + + return [defaultSlide, ...fromApi]; +} diff --git a/apps/player/src/layouts/MainLayout.vue b/apps/player/src/layouts/MainLayout.vue index 8e27710..bf7d686 100644 --- a/apps/player/src/layouts/MainLayout.vue +++ b/apps/player/src/layouts/MainLayout.vue @@ -4,13 +4,19 @@ import { useI18n } from 'vue-i18n'; import { useAuthStore } from '../stores/auth'; import { useBetSlipStore } from '../stores/betSlip'; import BetSlipDrawer from '../components/BetSlipDrawer.vue'; -import { ref } from 'vue'; +import CashBalanceChip from '../components/CashBalanceChip.vue'; +import UserAvatarMenu from '../components/UserAvatarMenu.vue'; +import LocaleFlag from '../components/LocaleFlag.vue'; +import AnnouncementMarquee from '../components/AnnouncementMarquee.vue'; +import BottomNavIcon from '../components/BottomNavIcon.vue'; +import { computed, onMounted } from 'vue'; +import { getLocaleDisplay } from '../utils/localeDisplay'; +import { useAnnouncements } from '../composables/useAnnouncements'; const { t, locale } = useI18n(); const auth = useAuthStore(); -const slip = useBetSlipStore(); const route = useRoute(); -const showSlip = ref(false); +const slip = useBetSlipStore(); const locales = [ { code: 'zh-CN', label: '中文' }, @@ -18,6 +24,11 @@ const locales = [ { code: 'ms-MY', label: 'BM' }, ]; +const showAnnouncement = computed(() => !route.path.startsWith('/profile')); +const { items: announcements, load: loadAnnouncements } = useAnnouncements(); + +onMounted(loadAnnouncements); + function setLocale(code: string) { locale.value = code; localStorage.setItem('locale', code); @@ -29,56 +40,164 @@ function setLocale(code: string) {
- - {{ auth.user?.username }} +
+ + +
+ +
+
+ +
+
-
diff --git a/apps/player/src/main.ts b/apps/player/src/main.ts index 010d1a7..b6043ee 100644 --- a/apps/player/src/main.ts +++ b/apps/player/src/main.ts @@ -2,7 +2,7 @@ import { createApp } from 'vue'; import { createPinia } from 'pinia'; import { createI18n } from 'vue-i18n'; import App from './App.vue'; -import router from './router'; +import router from './router/index.ts'; import './styles.css'; const i18n = createI18n({ @@ -11,22 +11,331 @@ const i18n = createI18n({ fallbackLocale: 'en-US', messages: { 'zh-CN': { - nav: { home: '首页', football: '足球', my_bets: '我的投注', profile: '我的' }, - auth: { login: '登录', username: '账号', password: '密码' }, - wallet: { balance: '余额' }, - bet: { bet_slip: '投注单', stake: '投注金额', place_bet: '确认下注', parlay: '串关' }, + nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' }, + history: { + league_default: '足球', + stake: '投注 Stake', + return: '回报 Return', + est_return: '预计回报 Est. Return', + parlay_title: '串关 · {n} 场', + parlay_league: '串关 Parlay', + empty: '暂无投注记录', + status_won: 'WON 赢', + status_pending: 'PENDING 待定', + status_lost: 'LOST 输', + status_push: 'PUSH 走盘', + }, + auth: { + login: '登录', + logout: '退出登录', + username: '账号', + password: '密码', + captcha_placeholder: 'Captcha', + captcha_refresh: '点击换一张', + captcha_wrong: '验证码错误', + }, + wallet: { + balance: '余额', + cash_balance: '现金余额', + unsettled: '未结算', + available: '可用', + no_records: '暂无账单记录', + }, + bet: { + bet_slip: '投注单', + stake: '投注金额', + place_bet: '确认下注', + place_bet_short: '下注', + parlay: '串关', + tab_matches: '球赛', + tab_outright: '优胜冠军', + tab_parlay: '串关投注', + tab_today: '今日', + tab_early: '早盘', + loading: '加载中…', + no_matches: '暂无赛事', + outright_coming: '优胜冠军玩法即将上线', + outright_enter_stake: '请输入投注金额', + outright_balance: '结余', + outright_stake_amount: '投注额度', + outright_success: '下注成功', + outright_done: '完毕', + outright_bet_failed: '下注失败', + no_outright: '暂无冠军盘口', + cancel: '取消', + parlay_title: '串关投注', + parlay_desc: '选择3-10场比赛来创建串关投注。组合赔率相乘可赢得更多!', + parlay_filter_all: '全部', + parlay_empty: '暂无可用串关赛事', + parlay_same_match: '同一场比赛不能串关', + parlay_need_more: '请至少选择 2 项进行串关', + back: '返回', + refresh: '刷新', + download: '下载', + reward_active: '奖励生效中!', + market_closed: '暂未开盘', + expand_market: '展开玩法', + collapse_market: '收起玩法', + market_cs: '波胆', + market_ht_cs: '上半场波胆', + market_sh_cs: '下半场波胆', + market_ft_handicap: '全场 让球', + market_ft_ou: '全场 大小', + market_ft_1x2: '全场 独赢盘', + market_ft_oe: '全场 单/双', + market_ht_handicap: '半场 让球', + market_ht_ou: '半场 大小', + market_ht_1x2: '半场 独赢盘', + col_home: '主场', + col_draw: '平', + col_away: '客场', + cs_stake_required: '请至少在一个比分输入投注金额', + cs_place_success: '下注成功', + cs_place_failed: '下注失败', + }, + profile: { + edit: '修改资料', + language: '语言', + phone: '手机号', + email: '邮箱', + phone_placeholder: '请输入手机号', + email_placeholder: '请输入邮箱', + save: '保存', + password_optional_hint: '不修改密码可留空', + old_password_placeholder: '留空则不修改', + new_password_placeholder: '留空则不修改', + confirm_password_placeholder: '留空则不修改', + old_password: '当前密码', + new_password: '新密码', + confirm_password: '确认新密码', + back: '返回', + saved: '联系方式已保存', + save_failed: '保存失败', + password_changed: '密码已更新', + password_failed: '密码修改失败', + password_mismatch: '两次新密码不一致', + password_incomplete: '修改密码需填写当前密码、新密码及确认密码', + }, }, 'en-US': { - nav: { home: 'Home', football: 'Football', my_bets: 'My Bets', profile: 'Profile' }, - auth: { login: 'Login', username: 'Username', password: 'Password' }, - wallet: { balance: 'Balance' }, - bet: { bet_slip: 'Bet Slip', stake: 'Stake', place_bet: 'Place Bet', parlay: 'Parlay' }, + nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' }, + history: { + league_default: 'Football', + stake: 'Stake 投注', + return: 'Return 回报', + est_return: 'Est. Return 预计回报', + parlay_title: 'Parlay · {n} legs', + parlay_league: 'Parlay 串关', + empty: 'No bets yet', + status_won: 'WON 赢', + status_pending: 'PENDING 待定', + status_lost: 'LOST 输', + status_push: 'PUSH 走盘', + }, + auth: { + login: 'Login', + logout: 'Log out', + username: 'Username', + password: 'Password', + captcha_placeholder: 'Captcha', + captcha_refresh: 'Click to refresh', + captcha_wrong: 'Invalid captcha', + }, + wallet: { + balance: 'Balance', + cash_balance: 'Cash Balance', + unsettled: 'Unsettled', + available: 'Available', + no_records: 'No records', + }, + bet: { + bet_slip: 'Bet Slip', + stake: 'Stake', + place_bet: 'Place Bet', + place_bet_short: 'Bet', + parlay: 'Parlay', + tab_matches: 'Matches', + tab_outright: 'Outright', + tab_parlay: 'Parlay', + tab_today: 'Today', + tab_early: 'Early', + loading: 'Loading…', + no_matches: 'No matches', + outright_coming: 'Outright markets coming soon', + outright_enter_stake: 'Enter stake', + outright_balance: 'Balance', + outright_stake_amount: 'Stake', + outright_success: 'Bet placed', + outright_done: 'Done', + outright_bet_failed: 'Bet failed', + no_outright: 'No outright markets', + cancel: 'Cancel', + parlay_title: 'Parlay', + parlay_desc: 'Pick 3-10 matches. Combined odds multiply your potential win!', + parlay_filter_all: 'All', + parlay_empty: 'No matches available for parlay betting', + parlay_same_match: 'Cannot parlay selections from the same match', + parlay_need_more: 'Select at least 2 legs for parlay', + back: 'Back', + refresh: 'Refresh', + download: 'Download', + reward_active: 'Reward active!', + market_closed: 'Not open', + expand_market: 'Expand', + collapse_market: 'Collapse', + market_cs: 'Correct Score', + market_ht_cs: '1H Correct Score', + market_sh_cs: '2H Correct Score', + market_ft_handicap: 'FT Handicap', + market_ft_ou: 'FT O/U', + market_ft_1x2: 'FT 1X2', + market_ft_oe: 'FT Odd/Even', + market_ht_handicap: 'HT Handicap', + market_ht_ou: 'HT O/U', + market_ht_1x2: 'HT 1X2', + col_home: 'Home', + col_draw: 'Draw', + col_away: 'Away', + cs_stake_required: 'Enter stake on at least one score', + cs_place_success: 'Bet placed', + cs_place_failed: 'Bet failed', + }, + profile: { + edit: 'Edit Profile', + language: 'Language', + phone: 'Phone', + email: 'Email', + phone_placeholder: 'Phone number', + email_placeholder: 'Email address', + save: 'Save', + password_optional_hint: 'Leave password fields blank to keep current password', + old_password_placeholder: 'Leave blank to skip', + new_password_placeholder: 'Leave blank to skip', + confirm_password_placeholder: 'Leave blank to skip', + old_password: 'Current password', + new_password: 'New password', + confirm_password: 'Confirm password', + back: 'Back', + saved: 'Contact saved', + save_failed: 'Save failed', + password_changed: 'Password updated', + password_failed: 'Password change failed', + password_mismatch: 'Passwords do not match', + password_incomplete: 'Fill current, new and confirm password to change password', + }, }, 'ms-MY': { - nav: { home: 'Laman Utama', football: 'Bola Sepak', my_bets: 'Pertaruhan Saya', profile: 'Profil' }, - auth: { login: 'Log Masuk', username: 'Nama Pengguna', password: 'Kata Laluan' }, - wallet: { balance: 'Baki' }, - bet: { bet_slip: 'Slip Pertaruhan', stake: 'Jumlah', place_bet: 'Letak Pertaruhan', parlay: 'Berganda' }, + nav: { + home: 'Laman Utama', + bet: 'Pertaruhan', + bet_history: 'Sejarah', + wallet: 'Bil', + profile: 'Profil', + }, + history: { + league_default: 'Bola Sepak', + stake: 'Stake 投注', + return: 'Return 回报', + est_return: 'Est. Return 预计回报', + parlay_title: 'Parlay · {n} perlawanan', + parlay_league: 'Parlay 串关', + empty: 'Tiada rekod pertaruhan', + status_won: 'WON 赢', + status_pending: 'PENDING 待定', + status_lost: 'LOST 输', + status_push: 'PUSH 走盘', + }, + auth: { + login: 'Log Masuk', + logout: 'Log Keluar', + username: 'Nama Pengguna', + password: 'Kata Laluan', + captcha_placeholder: 'Captcha', + captcha_refresh: 'Klik untuk muat semula', + captcha_wrong: 'Kod pengesahan salah', + }, + wallet: { + balance: 'Baki', + cash_balance: 'Baki Tunai', + unsettled: 'Belum Selesai', + available: 'Tersedia', + no_records: 'Tiada rekod', + }, + bet: { + bet_slip: 'Slip Pertaruhan', + stake: 'Jumlah', + place_bet: 'Letak Pertaruhan', + place_bet_short: 'Pertaruhan', + parlay: 'Berganda', + tab_matches: 'Perlawanan', + tab_outright: 'Juara', + tab_parlay: 'Berganda', + tab_today: 'Hari Ini', + tab_early: 'Awal', + loading: 'Memuatkan…', + no_matches: 'Tiada perlawanan', + outright_coming: 'Pasaran juara akan datang', + outright_enter_stake: 'Masukkan jumlah', + outright_balance: 'Baki', + outright_stake_amount: 'Jumlah pertaruhan', + outright_success: 'Pertaruhan berjaya', + outright_done: 'Selesai', + outright_bet_failed: 'Pertaruhan gagal', + no_outright: 'Tiada pasaran juara', + cancel: 'Batal', + parlay_title: 'Pertaruhan Berganda', + parlay_desc: 'Pilih 3-10 perlawanan. Gabungan odds didarab!', + parlay_filter_all: 'Semua', + parlay_empty: 'No matches available for parlay betting', + parlay_same_match: 'Perlawanan sama tidak boleh berganda', + parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan', + back: 'Kembali', + refresh: 'Muat semula', + download: 'Muat turun', + reward_active: 'Ganjaran aktif!', + market_closed: 'Belum dibuka', + expand_market: 'Kembang', + collapse_market: 'Tutup', + market_cs: 'Skor Tepat', + market_ht_cs: 'Skor Tepat PB1', + market_sh_cs: 'Skor Tepat PB2', + market_ft_handicap: 'Handicap Penuh', + market_ft_ou: 'Atas/Bawah Penuh', + market_ft_1x2: '1X2 Penuh', + market_ft_oe: 'Ganjil/Genap Penuh', + market_ht_handicap: 'Handicap Separuh', + market_ht_ou: 'Atas/Bawah Separuh', + market_ht_1x2: '1X2 Separuh', + col_home: 'Home', + col_draw: 'Seri', + col_away: 'Away', + cs_stake_required: 'Masukkan jumlah pada sekurang-kurangnya satu skor', + cs_place_success: 'Pertaruhan berjaya', + cs_place_failed: 'Pertaruhan gagal', + }, + profile: { + edit: 'Edit Profil', + language: 'Bahasa', + phone: 'Telefon', + email: 'E-mel', + phone_placeholder: 'Nombor telefon', + email_placeholder: 'Alamat e-mel', + save: 'Simpan', + password_optional_hint: 'Biarkan kosong jika tidak mahu tukar kata laluan', + old_password_placeholder: 'Biarkan kosong untuk langkau', + new_password_placeholder: 'Biarkan kosong untuk langkau', + confirm_password_placeholder: 'Biarkan kosong untuk langkau', + old_password: 'Kata laluan semasa', + new_password: 'Kata laluan baharu', + confirm_password: 'Sahkan kata laluan', + back: 'Kembali', + saved: 'Hubungan disimpan', + save_failed: 'Gagal simpan', + password_changed: 'Kata laluan dikemas kini', + password_failed: 'Gagal tukar kata laluan', + password_mismatch: 'Kata laluan tidak sepadan', + password_incomplete: 'Isi kata laluan semasa, baharu dan pengesahan untuk menukar', + }, }, }, }); diff --git a/apps/player/src/router/index.ts b/apps/player/src/router/index.ts index f1a357e..a2782e7 100644 --- a/apps/player/src/router/index.ts +++ b/apps/player/src/router/index.ts @@ -1,5 +1,5 @@ import { createRouter, createWebHistory } from 'vue-router'; -import { useAuthStore } from '../stores/auth'; +import { useAuthStore } from '../stores/auth.ts'; const router = createRouter({ history: createWebHistory(), @@ -11,10 +11,13 @@ const router = createRouter({ meta: { requiresAuth: true }, children: [ { path: '', component: () => import('../views/HomeView.vue') }, - { path: 'football', component: () => import('../views/FootballView.vue') }, + { path: 'bet', component: () => import('../views/FootballView.vue') }, + { path: 'football', redirect: '/bet' }, { path: 'match/:id', component: () => import('../views/MatchDetailView.vue') }, { path: 'bets', component: () => import('../views/MyBetsView.vue') }, + { path: 'wallet', component: () => import('../views/WalletView.vue') }, { path: 'profile', component: () => import('../views/ProfileView.vue') }, + { path: 'profile/edit', component: () => import('../views/ProfileEditView.vue') }, ], }, ], diff --git a/apps/player/src/stores/betSlip.ts b/apps/player/src/stores/betSlip.ts index 2bdc078..872e9b3 100644 --- a/apps/player/src/stores/betSlip.ts +++ b/apps/player/src/stores/betSlip.ts @@ -25,12 +25,26 @@ export const useBetSlipStore = defineStore('betSlip', () => { ); if (existing >= 0) { items.value.splice(existing, 1); + if (items.value.length < 2) mode.value = 'single'; return; } items.value.push(item); if (items.value.length >= 2) mode.value = 'parlay'; } + /** 串关:同场只保留一个选项;再次点击已选项则取消 */ + function addParlayLeg(item: SlipItem) { + const samePick = items.value.findIndex((i) => i.selectionId === item.selectionId); + if (samePick >= 0) { + items.value.splice(samePick, 1); + if (items.value.length < 2) mode.value = 'single'; + return; + } + items.value = items.value.filter((i) => i.matchId !== item.matchId); + items.value.push(item); + mode.value = items.value.length >= 2 ? 'parlay' : 'single'; + } + function removeItem(selectionId: string) { items.value = items.value.filter((i) => i.selectionId !== selectionId); if (items.value.length < 2) mode.value = 'single'; @@ -58,6 +72,16 @@ export const useBetSlipStore = defineStore('betSlip', () => { return new Set(matchIds).size !== matchIds.length; }); + const drawerOpen = ref(false); + + function openDrawer() { + drawerOpen.value = true; + } + + function closeDrawer() { + drawerOpen.value = false; + } + return { items, stake, @@ -67,8 +91,12 @@ export const useBetSlipStore = defineStore('betSlip', () => { totalOdds, potentialReturn, hasSameMatch, + drawerOpen, addItem, + addParlayLeg, removeItem, clear, + openDrawer, + closeDrawer, }; }); diff --git a/apps/player/src/styles.css b/apps/player/src/styles.css index dc5a4e9..f428f30 100644 --- a/apps/player/src/styles.css +++ b/apps/player/src/styles.css @@ -1,58 +1,299 @@ * { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --primary: #D4AF37; + --primary-dark: #A8841F; + --primary-light: #F0D875; + --secondary: #1A1A1A; + --tertiary: #000000; + --neutral: #8E8E93; + --bg-body: #000000; + --bg-card: #141414; + --bg-hover: #222222; + --bg-elevated: #2A2A2A; + --text: #FFFFFF; + --text-muted: #8E8E93; + --border: #333333; + --border-gold: #D4AF37; + --border-gold-soft: rgba(212, 175, 55, 0.28); + --border-w: 1px; + --border-w-thick: 1px; + --danger: #FF453A; + --radius: 12px; + --radius-sm: 8px; + --shadow: 0 4px 16px rgba(0, 0, 0, 0.45); + --shadow-gold: 0 2px 12px rgba(212, 175, 55, 0.12); + --glow-gold: 0 0 8px rgba(212, 175, 55, 0.2); + --gradient-gold: linear-gradient( + 180deg, + #FFF4C8 0%, + #F0D875 18%, + #D4AF37 50%, + #B8942B 78%, + #8B6914 100% + ); + --gradient-gold-border: linear-gradient( + 180deg, + #FFF8DC 0%, + #E8C96A 35%, + #A8841F 70%, + #5C4A12 100% + ); + --gradient-card: linear-gradient(160deg, #1E1A12 0%, #141414 40%, #0A0A0A 100%); +} + +/** 金色描边按钮(避免大面积实心填充) */ +.btn-gold-outline { + background: #141414; + border: 1px solid var(--border-gold-soft); + color: var(--primary-light); + font-weight: 800; +} +.btn-gold-outline:active:not(:disabled) { + background: rgba(212, 175, 55, 0.1); + border-color: var(--border-gold); +} + +/** 选中态:浅底 + 金边,非整块金色 */ +.tab-gold-active { + background: rgba(212, 175, 55, 0.08) !important; + border-color: var(--border-gold) !important; + color: var(--primary-light) !important; +} + +/* 登录等关键区域:轻量金边(非厚重 PS 描边) */ +.ps-gold-frame { + position: relative; + border: 1px solid var(--border-gold-soft) !important; + border-radius: var(--radius); + background: rgba(14, 14, 14, 0.92) !important; + box-shadow: var(--shadow); +} + +.ps-gold-frame::before, +.ps-gold-frame::after { + display: none; +} + +.ps-gold-input { + background: #0d0d0d !important; + border: 1px solid var(--border) !important; + box-shadow: none; +} + +.ps-gold-input:focus { + border-color: var(--border-gold-soft) !important; + box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.12) !important; +} + +/* 主按钮:斜面浮雕 + 渐变描边 + 外发光 */ +.btn-primary { + position: relative; + isolation: isolate; + padding: 17px 24px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: #3D2800; + font-weight: 900; + font-size: 16px; + width: 100%; + letter-spacing: 0.1em; + text-transform: uppercase; + text-shadow: + 0 1px 0 rgba(255, 252, 235, 0.95), + 0 -1px 0 rgba(120, 85, 15, 0.35); + transition: transform 0.12s ease; + z-index: 0; +} + +.btn-primary::before { + content: ''; + position: absolute; + inset: -3px; + border-radius: calc(var(--radius-sm) + 3px); + background: linear-gradient( + 180deg, + #FFFCE8 0%, + #F7E08A 12%, + #D4AF37 35%, + #8B6914 55%, + #5C4A12 72%, + #E8C040 90%, + #FFF8DC 100% + ); + z-index: -2; + box-shadow: + 0 0 14px rgba(255, 210, 70, 0.55), + 0 0 28px rgba(212, 175, 55, 0.28), + 0 6px 14px rgba(0, 0, 0, 0.55); +} + +.btn-primary::after { + content: ''; + position: absolute; + inset: 2px; + border-radius: calc(var(--radius-sm) - 1px); + background: linear-gradient( + 180deg, + #FFFBE8 0%, + #FFE566 6%, + #F0D050 22%, + #D4AF37 48%, + #B8860B 72%, + #8B6914 100% + ); + z-index: -1; + box-shadow: + inset 0 2px 0 rgba(255, 255, 255, 0.95), + inset 0 4px 10px rgba(255, 240, 180, 0.45), + inset 0 -2px 0 rgba(90, 65, 12, 0.85), + inset 0 -6px 14px rgba(45, 32, 6, 0.5); +} + +.btn-primary:active:not(:disabled) { + transform: translateY(2px) scale(0.985); +} + +.btn-primary:active:not(:disabled)::after { + background: linear-gradient( + 180deg, + #E8C040 0%, + #C9962A 40%, + #8B6914 100% + ); + box-shadow: + inset 0 4px 10px rgba(35, 24, 4, 0.65), + inset 0 1px 0 rgba(255, 255, 255, 0.35); +} + +.btn-primary:active:not(:disabled)::before { + box-shadow: + 0 0 10px rgba(255, 200, 50, 0.35), + 0 2px 6px rgba(0, 0, 0, 0.5); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + filter: saturate(0.4); +} + +html, +body, +#app { + height: 100%; +} + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #0f1419; - color: #e8eaed; - min-height: 100vh; -} -:root { - --primary: #00a826; - --primary-dark: #008a1f; - --bg-card: #1a2332; - --bg-hover: #243044; - --text-muted: #8b95a5; - --border: #2d3a4d; - --danger: #ff4444; + background: + radial-gradient(ellipse 120% 60% at 50% -10%, rgba(212, 175, 55, 0.14), transparent 55%), + var(--bg-body); + color: var(--text); + overflow: hidden; + -webkit-font-smoothing: antialiased; } + a { color: inherit; text-decoration: none; } + button { cursor: pointer; border: none; font-family: inherit; } + input { font-family: inherit; - background: var(--bg-card); + background: #0D0D0D; border: 1px solid var(--border); - color: #fff; - padding: 10px 12px; - border-radius: 6px; + color: var(--text); + padding: 14px 16px; + border-radius: var(--radius-sm); width: 100%; + font-size: 15px; + font-weight: 500; + transition: border-color 0.2s, box-shadow 0.2s; } -.btn-primary { - background: var(--primary); - color: #fff; - padding: 12px 24px; - border-radius: 6px; - font-weight: 600; - width: 100%; + +input:focus { + outline: none; + border-color: var(--border-gold-soft); } -.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active { + -webkit-text-fill-color: var(--text); + caret-color: var(--text); + box-shadow: 0 0 0 1000px #0a0a0a inset; + border: 1px solid var(--border); + transition: background-color 99999s ease-out 0s; +} + .odds-btn { - background: var(--bg-card); + background: #161616; border: 1px solid var(--border); - color: #fff; - padding: 8px 12px; - border-radius: 6px; + color: var(--text); + padding: 12px 14px; + border-radius: var(--radius-sm); text-align: center; - min-width: 70px; + min-width: 78px; + transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s; } -.odds-btn.selected { background: var(--primary); border-color: var(--primary); } -.odds-btn .label { font-size: 11px; color: var(--text-muted); } -.odds-btn .value { font-size: 15px; font-weight: 700; color: #ffd700; } + +.odds-btn:active { transform: scale(0.96); } + +.odds-btn.selected { + border: 1px solid var(--border-gold-soft); + background: rgba(212, 175, 55, 0.12); + box-shadow: none; +} + +.odds-btn.selected::before, +.odds-btn.selected::after { + display: none; +} + +.odds-btn.selected .label, +.odds-btn.selected .value { + position: relative; + z-index: 1; +} + +.odds-btn .label { + font-size: 11px; + color: var(--text-muted); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.odds-btn .value { + font-size: 18px; + font-weight: 800; + color: var(--primary-light); + text-shadow: 0 0 8px rgba(212, 175, 55, 0.5); + margin-top: 2px; +} + .card { background: var(--bg-card); - border-radius: 8px; - padding: 12px; - margin-bottom: 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + margin-bottom: 12px; + box-shadow: var(--shadow); +} + +.section-title { + font-size: 18px; + font-weight: 800; + margin-bottom: 14px; + padding: 4px 0 4px 12px; + border-left: 3px solid var(--border-gold); + color: var(--primary-light); + letter-spacing: 0.06em; + text-transform: uppercase; } diff --git a/apps/player/src/utils/correctScoreLayout.ts b/apps/player/src/utils/correctScoreLayout.ts new file mode 100644 index 0000000..cf18ab1 --- /dev/null +++ b/apps/player/src/utils/correctScoreLayout.ts @@ -0,0 +1,86 @@ +/** 与后台 settlement 模板顺序一致,用于列内排序 */ +export const FT_CORRECT_SCORE_ORDER = [ + 'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'SCORE_3_1', 'SCORE_3_2', + 'SCORE_4_0', 'SCORE_4_1', 'SCORE_4_2', 'SCORE_4_3', 'OTHER_HOME', + 'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW', + 'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'SCORE_1_3', 'SCORE_2_3', + 'SCORE_0_4', 'SCORE_1_4', 'SCORE_2_4', 'SCORE_3_4', 'OTHER_AWAY', +]; + +export const HT_CORRECT_SCORE_ORDER = [ + 'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'OTHER_HOME', + 'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'OTHER_DRAW', + 'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'OTHER_AWAY', +]; + +export type CsColumn = 'home' | 'draw' | 'away'; + +export interface CsSelection { + id: string; + selectionCode: string; + selectionName: string; + odds: string; + oddsVersion: string; + scoreDisplay: string; +} + +export function isCorrectScoreMarket(marketType: string) { + return marketType.includes('CORRECT_SCORE'); +} + +function orderForMarket(marketType: string) { + if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_ORDER; + return HT_CORRECT_SCORE_ORDER; +} + +export function parseScoreCode(code: string): { display: string; column: CsColumn } | null { + if (code === 'OTHER_HOME') return { display: '其它', column: 'home' }; + if (code === 'OTHER_DRAW') return { display: '其它', column: 'draw' }; + if (code === 'OTHER_AWAY') return { display: '其它', column: 'away' }; + const m = code.match(/^SCORE_(\d+)_(\d+)$/); + if (!m) return null; + const h = Number(m[1]); + const a = Number(m[2]); + if (h > a) return { display: `${h}:${a}`, column: 'home' }; + if (h < a) return { display: `${h}:${a}`, column: 'away' }; + return { display: `${h}:${a}`, column: 'draw' }; +} + +function sortByTemplate(items: CsSelection[], template: string[]) { + return [...items].sort((a, b) => { + const ia = template.indexOf(a.selectionCode); + const ib = template.indexOf(b.selectionCode); + return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib); + }); +} + +export function groupCorrectScoreSelections( + selections: Array<{ + id: string; + selectionCode: string; + selectionName: string; + odds: string; + oddsVersion: string; + }>, + marketType: string, +) { + const template = orderForMarket(marketType); + const home: CsSelection[] = []; + const draw: CsSelection[] = []; + const away: CsSelection[] = []; + + for (const sel of selections) { + const parsed = parseScoreCode(sel.selectionCode); + if (!parsed) continue; + const row: CsSelection = { ...sel, scoreDisplay: parsed.display }; + if (parsed.column === 'home') home.push(row); + else if (parsed.column === 'draw') draw.push(row); + else away.push(row); + } + + return { + home: sortByTemplate(home, template), + draw: sortByTemplate(draw, template), + away: sortByTemplate(away, template), + }; +} diff --git a/apps/player/src/utils/localeDisplay.js b/apps/player/src/utils/localeDisplay.js new file mode 100644 index 0000000..529b85a --- /dev/null +++ b/apps/player/src/utils/localeDisplay.js @@ -0,0 +1,2 @@ +// 兼容旧缓存:转发到 TypeScript 源文件 +export { getLocaleDisplay, parseAmount, sumAmounts, formatMoney } from './localeDisplay.ts'; diff --git a/apps/player/src/utils/localeDisplay.ts b/apps/player/src/utils/localeDisplay.ts new file mode 100644 index 0000000..0bbd73d --- /dev/null +++ b/apps/player/src/utils/localeDisplay.ts @@ -0,0 +1,55 @@ +export interface LocaleDisplay { + countryCode: 'cn' | 'us' | 'my'; + currency: string; + label: string; +} + +const LOCALE_DISPLAY: Record = { + 'zh-CN': { countryCode: 'cn', currency: 'CNY', label: '中文' }, + 'en-US': { countryCode: 'us', currency: 'USD', label: 'English' }, + 'ms-MY': { countryCode: 'my', currency: 'MYR', label: 'BM' }, +}; + +export function getLocaleDisplay(locale: string): LocaleDisplay { + return LOCALE_DISPLAY[locale] ?? LOCALE_DISPLAY['en-US']; +} + +export function parseAmount(amount: unknown): number { + if (amount == null) return 0; + if (typeof amount === 'number') return Number.isFinite(amount) ? amount : 0; + if (typeof amount === 'string') { + const n = Number(amount); + return Number.isFinite(n) ? n : 0; + } + if (typeof amount === 'object') { + const value = amount as { toString?: () => string; d?: number[] }; + if (typeof value.toString === 'function') { + const n = Number(value.toString()); + if (Number.isFinite(n)) return n; + } + if (Array.isArray(value.d) && value.d.length) { + const n = Number(value.d.join('')); + return Number.isFinite(n) ? n : 0; + } + } + return 0; +} + +export function sumAmounts(...amounts: unknown[]): number { + return amounts.reduce((sum, item) => sum + parseAmount(item), 0); +} + +export function formatMoney(amount: unknown, locale: string): string { + const value = parseAmount(amount); + const { currency } = getLocaleDisplay(locale); + try { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); + } catch { + return `${getLocaleDisplay(locale).currency} ${value.toFixed(2)}`; + } +} diff --git a/apps/player/src/utils/marketCatalog.ts b/apps/player/src/utils/marketCatalog.ts new file mode 100644 index 0000000..cb2061c --- /dev/null +++ b/apps/player/src/utils/marketCatalog.ts @@ -0,0 +1,33 @@ +/** 详情页展示顺序(与产品设计稿一致) */ +export const FEATURED_MARKET_TYPES = [ + 'FT_CORRECT_SCORE', + 'HT_CORRECT_SCORE', + 'SH_CORRECT_SCORE', +] as const; + +export const GRID_MARKET_TYPES = [ + 'FT_HANDICAP', + 'FT_OVER_UNDER', + 'FT_1X2', + 'FT_ODD_EVEN', + 'HT_HANDICAP', + 'HT_OVER_UNDER', + 'HT_1X2', +] as const; + +export type CatalogMarketType = + | (typeof FEATURED_MARKET_TYPES)[number] + | (typeof GRID_MARKET_TYPES)[number]; + +export const MARKET_I18N_KEY: Record = { + FT_CORRECT_SCORE: 'bet.market_cs', + HT_CORRECT_SCORE: 'bet.market_ht_cs', + SH_CORRECT_SCORE: 'bet.market_sh_cs', + FT_HANDICAP: 'bet.market_ft_handicap', + FT_OVER_UNDER: 'bet.market_ft_ou', + FT_1X2: 'bet.market_ft_1x2', + FT_ODD_EVEN: 'bet.market_ft_oe', + HT_HANDICAP: 'bet.market_ht_handicap', + HT_OVER_UNDER: 'bet.market_ht_ou', + HT_1X2: 'bet.market_ht_1x2', +}; diff --git a/apps/player/src/utils/parlayColumns.ts b/apps/player/src/utils/parlayColumns.ts new file mode 100644 index 0000000..e54662d --- /dev/null +++ b/apps/player/src/utils/parlayColumns.ts @@ -0,0 +1,23 @@ +/** 串关列表表头与玩法类型 */ +export const PARLAY_MARKET_TYPES = [ + { key: 'FT_HANDICAP', label: 'FT.HDP' }, + { key: 'FT_OVER_UNDER', label: 'FT.O/U' }, + { key: 'FT_1X2', label: 'FT.1X2' }, + { key: 'FT_ODD_EVEN', label: 'FT.O/E' }, + { key: 'HT_HANDICAP', label: '1H.HDP' }, + { key: 'HT_OVER_UNDER', label: '1H.O/U' }, + { key: 'HT_1X2', label: '1H.1X2' }, +] as const; + +export type ParlayMarketType = (typeof PARLAY_MARKET_TYPES)[number]['key']; + +/** 选项简称(串关格内展示) */ +export const SELECTION_SHORT: Record = { + HOME: '主', + AWAY: '客', + DRAW: '和', + OVER: '大', + UNDER: '小', + ODD: '单', + EVEN: '双', +}; diff --git a/apps/player/src/utils/teamFlag.ts b/apps/player/src/utils/teamFlag.ts new file mode 100644 index 0000000..61d5332 --- /dev/null +++ b/apps/player/src/utils/teamFlag.ts @@ -0,0 +1,94 @@ +/** 球队 code / 名称 → ISO 3166-1 alpha-2,用于 flagcdn 国旗图 */ +const CODE_TO_ISO: Record = { + MEX: 'mx', + USA: 'us', + CAN: 'ca', + BRA: 'br', + ARG: 'ar', + ENG: 'gb', + MUN: 'gb', + CHE: 'gb', + LIV: 'gb', + MCI: 'gb', + CZE: 'cz', + KOR: 'kr', + BIH: 'ba', + PAR: 'py', + RSA: 'za', + SUI: 'ch', + SCO: 'gb-sct', + TUR: 'tr', + CZE: 'cz', + BIH: 'ba', + FRA: 'fr', + ESP: 'es', + ENG: 'gb', + GER: 'de', + POR: 'pt', + NED: 'nl', + NOR: 'no', + BEL: 'be', + COL: 'co', + JPN: 'jp', + MAR: 'ma', + CRO: 'hr', + SEN: 'sn', +}; + +const NAME_TO_ISO: Record = { + Mexico: 'mx', + 'South Africa': 'za', + 'United States': 'us', + USA: 'us', + Canada: 'ca', + Brazil: 'br', + Switzerland: 'ch', + Scotland: 'gb-sct', + Turkey: 'tr', + 'South Korea': 'kr', + Paraguay: 'py', + 墨西哥: 'mx', + 美国: 'us', + 加拿大: 'ca', + 曼联: 'gb', + 切尔西: 'gb', + 墨西哥: 'mx', + 南非: 'za', + 捷克: 'cz', + 韩国: 'kr', + 波黑: 'ba', + 巴拉圭: 'py', + 瑞士: 'ch', + 巴西: 'br', + 苏格兰: 'gb-sct', + 土耳其: 'tr', + 法国: 'fr', + 阿根廷: 'ar', + 法国: 'fr', + 西班牙: 'es', + 英格兰: 'gb', + 德国: 'de', + 葡萄牙: 'pt', + 荷兰: 'nl', + 挪威: 'no', + 比利时: 'be', + 哥伦比亚: 'co', + 日本: 'jp', + 摩洛哥: 'ma', + 克罗地亚: 'hr', + 塞内加尔: 'sn', +}; + +export function teamFlagUrl(code?: string, name?: string): string { + const key = (code ?? '').toUpperCase(); + if (key && CODE_TO_ISO[key]) { + return `https://flagcdn.com/w40/${CODE_TO_ISO[key]}.png`; + } + if (name && NAME_TO_ISO[name]) { + return `https://flagcdn.com/w40/${NAME_TO_ISO[name]}.png`; + } + if (key.length === 2) { + return `https://flagcdn.com/w40/${key.toLowerCase()}.png`; + } + return ''; +} diff --git a/apps/player/src/views/FootballView.vue b/apps/player/src/views/FootballView.vue index 9f694f7..8eb3e0a 100644 --- a/apps/player/src/views/FootballView.vue +++ b/apps/player/src/views/FootballView.vue @@ -1,66 +1,293 @@ diff --git a/apps/player/src/views/HomeView.vue b/apps/player/src/views/HomeView.vue index 7acfafc..4eafcc7 100644 --- a/apps/player/src/views/HomeView.vue +++ b/apps/player/src/views/HomeView.vue @@ -1,10 +1,29 @@