@ddj-v2/shop 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.npmignore ADDED
@@ -0,0 +1,21 @@
1
+ # 排除所有隱藏檔案與資料夾
2
+ .*
3
+ !/.npmignore
4
+
5
+ # 排除 CI 與開發工具設定
6
+ .github/
7
+ .vscode/
8
+ node_modules/
9
+ *.log
10
+
11
+ # 排除測試與原始碼 (如果你的 build 結果在 dist/ 或 lib/)
12
+ test/
13
+ tests/
14
+ src/
15
+ __tests__/
16
+ jest.config.js
17
+ tsconfig.json
18
+
19
+ # 排除 semantic-release 可能產生的檔案
20
+ release.config.js
21
+ .releaserc
package/README.md ADDED
@@ -0,0 +1,266 @@
1
+ # shop
2
+
3
+ HydroOJ 硬幣與兌換商店外掛(繁體中文文件)。
4
+ 以 https://github.com/cqzym1985/my-hydro-plugins 為基礎並新增供其他套件接入的功能
5
+ ## 功能
6
+
7
+ - 硬幣發放與批次匯入。
8
+ - 商品管理與兌換。
9
+ - 支援商品 `objectId`(可重複綁定同一物件)。
10
+ - 支援無限供應(`num < 0`)。
11
+ - 商品 `description` 支援 Markdown,商城與兌換頁以原生 `|markdown|safe` 渲染。
12
+ - 可插拔購買邏輯(供其他套件接入)。
13
+ - 可註冊管理頁擴充入口(獨立頁面:`/shop/manage/entries`)。
14
+ - 已提供 runtime `shopBridge`,避免外掛間使用脆弱的相對路徑靜態匯入。
15
+
16
+ ## 路由(目前啟用)
17
+
18
+ - `coin_show` `/coin/show`
19
+ - `coin_inc` `/coin/inc`
20
+ - `coin_import` `/coin/import`
21
+ - `coin_bill` `/coin/bill`
22
+ - `coin_mall` `/coin/mall`
23
+ - `coin_myrecord` `/coin/myrecord`
24
+ - `coin_exchange` `/coin/exchange/:id`
25
+ - `coin_record` `/coin/record`
26
+ - `goods_add` `/goods/add`
27
+ - `goods_manage` `/goods/manage`
28
+ - `shop_manage_entries` `/shop/manage/entries`
29
+ - `goods_edit` `/goods/:id/edit`
30
+ - `uname_change` `/uname/change`
31
+ - `domain_coin_setting` `/domain/coin`
32
+
33
+ 註:`coin_gift` 路由在目前版本預設未啟用(程式內已註解)。
34
+
35
+ ## 對外 API
36
+
37
+ `shop/index.ts` 對外提供:
38
+
39
+ - `registerGoodsPurchaseModel(modelId, model)`
40
+ - `registerShopManageEntry(entry)`
41
+ - `getShopManageEntries()`
42
+ - `CoinModel`
43
+ - `GoodsModel`
44
+
45
+ ## 如何調用原生 method 發放硬幣
46
+
47
+ ### 推薦方式:使用 `CoinModel.inc`(會寫入發放紀錄)
48
+
49
+ `CoinModel.inc` 會同時:
50
+
51
+ - 新增一筆硬幣帳單紀錄(`coin` collection)
52
+ - 調整使用者 `coin_now`
53
+ - 當 `asset = 1` 時,同步增加 `coin_all`
54
+
55
+ ```ts
56
+ import { CoinModel } from '../shop';
57
+
58
+ // 例:管理員給使用者 +20 硬幣
59
+ // 參數:userId, rootId, amount, text, asset, status?
60
+ await CoinModel.inc(targetUid, operatorUid, 20, '活動獎勵', 1);
61
+ ```
62
+
63
+ 參數說明:
64
+
65
+ - `userId`: 收款人 uid
66
+ - `rootId`: 操作者 uid(誰發放)
67
+ - `amount`: 正數加幣,負數扣幣
68
+ - `text`: 帳單說明
69
+ - `asset`: `1` 代表會計入 `coin_all`,`0` 只變動 `coin_now`
70
+ - `status`(選填): 可用於綁定商品 ID 或其他狀態碼
71
+
72
+ ### 進階方式:直接調用 Hydro 原生 `UserModel.inc`
73
+
74
+ 若你只想調整餘額、不要寫入硬幣帳單,可直接使用 Hydro 原生方法:
75
+
76
+ ```ts
77
+ import { UserModel } from 'hydrooj';
78
+
79
+ await UserModel.inc(targetUid, 'coin_now', 20);
80
+ ```
81
+
82
+ 注意:這種做法不會留下 `coin_bill` 可查的發放紀錄,通常不建議拿來做正式發幣流程。
83
+
84
+ 另外,執行時也會提供:
85
+
86
+ - `global.Hydro.shopBridge`
87
+ - `ctx.provide('shop_bridge', shopBridge)`
88
+
89
+ `shopBridge` 內容:
90
+
91
+ - `goodsModel`
92
+ - `registerGoodsPurchaseModel`
93
+ - `registerShopManageEntry`
94
+
95
+ ## 建議接入方式(runtime bridge)
96
+
97
+ ```ts
98
+ interface ShopBridge {
99
+ goodsModel: {
100
+ add: (
101
+ name: string,
102
+ price: number,
103
+ num: number,
104
+ objectId?: string,
105
+ goodsId?: number,
106
+ purchaseModelId?: string,
107
+ data?: Record<string, unknown>,
108
+ description?: string,
109
+ ) => Promise<number | string>;
110
+ };
111
+ registerGoodsPurchaseModel: (
112
+ modelId: string,
113
+ model: {
114
+ purchase: (
115
+ uid: number,
116
+ goods: any,
117
+ amount: number,
118
+ ) => Promise<boolean | { success: boolean; message?: string }> | (boolean | { success: boolean; message?: string });
119
+ }
120
+ ) => void;
121
+ registerShopManageEntry: (entry: { key: string; title: string; href: string }) => void;
122
+ }
123
+
124
+ function getShopBridge(): ShopBridge | null {
125
+ return (global.Hydro as any)?.shopBridge || null;
126
+ }
127
+
128
+ const shopBridge = getShopBridge();
129
+ if (shopBridge) {
130
+ shopBridge.registerGoodsPurchaseModel('example_model', {
131
+ async purchase(uid, goods, amount) {
132
+ // 成功
133
+ return true;
134
+ // 或失敗(帶訊息)
135
+ // return { success: false, message: '你已擁有此商品' };
136
+ },
137
+ });
138
+
139
+ shopBridge.registerShopManageEntry({
140
+ key: 'example_manage',
141
+ title: '外掛管理入口',
142
+ href: '/example/manage',
143
+ });
144
+ }
145
+ ```
146
+
147
+ ## registerShopManageEntry 的 href 建議寫法
148
+
149
+ 下面提供一套可重複使用的最小模板,適合用在你自己的外掛管理頁。
150
+
151
+ ### 1) 先註冊管理入口(key, title, href)
152
+
153
+ shopBridge.registerShopManageEntry({
154
+ key: 'example_manage',
155
+ title: 'Example 管理',
156
+ href: '/example/manage',
157
+ });
158
+
159
+ 重點:
160
+
161
+ - key 要全域唯一,建議用與`ctx.Route`的相同(例如 example_manage)。
162
+ - href 請使用固定路徑,不要帶動態參數,方便管理頁入口穩定顯示。
163
+ - title 建議用清楚動詞,例如 新增、發佈、同步、設定。
164
+
165
+ ### 2) 對應 href 的 Handler 範本
166
+
167
+ import { Context, Handler, PERM, Types, param } from 'hydrooj';
168
+
169
+ class ExampleManageHandler extends Handler {
170
+ async get() {
171
+ this.checkPerm(PERM.PERM_SET_PERM);
172
+ this.response.template = 'example_manage.html';
173
+ this.response.body = {
174
+ page_name: 'example_manage',
175
+ message: '',
176
+ };
177
+ }
178
+
179
+ @param('name', Types.String)
180
+ async post(domainId: string, name: string) {
181
+ this.checkPerm(PERM.PERM_SET_PERM);
182
+
183
+ // TODO: 在這裡放你的業務邏輯
184
+
185
+ this.response.template = 'example_manage.html';
186
+ this.response.body = {
187
+ page_name: 'example_manage',
188
+ message: `已完成:${name}`,
189
+ };
190
+ }
191
+ }
192
+
193
+ export function applyExample(ctx: Context) {
194
+ ctx.Route('example_manage', '/example/manage', ExampleManageHandler, PERM.PERM_SET_PERM);
195
+ }
196
+
197
+ ### 3) 可重複使用的 HTML 模板範本
198
+
199
+ {% extends "coin_base.html" %}
200
+ {% block coin_content %}
201
+ <div class="section">
202
+ <div class="section__header">
203
+ <h1 class="section__title">{{ _('Example 管理') }}</h1>
204
+ </div>
205
+ <div class="section__body">
206
+ {% if message %}
207
+ <blockquote class="note typo">
208
+ <p>{{ message }}</p>
209
+ </blockquote>
210
+ {% endif %}
211
+
212
+ <form method="post">
213
+ {{ form.form_text({
214
+ label: '名稱',
215
+ name: 'name',
216
+ required: true
217
+ }) }}
218
+
219
+ <button type="submit" class="rounded primary button">{{ _('提交') }}</button>
220
+ </form>
221
+ </div>
222
+ </div>
223
+ {% endblock %}
224
+
225
+ ### 4) 命名與落地建議
226
+
227
+ - Route name、page_name、manage entry key 建議統一同一前綴,便於維護。
228
+ - 模板建議放在外掛自己的 templates 目錄,避免和其他外掛重名。
229
+ - 若頁面是資料建立型流程,成功後可保留在原頁並顯示 message;
230
+ 若是清單型流程,建議 redirect 到清單頁。
231
+
232
+ ### 5) 常見錯誤
233
+
234
+ - href 打錯(例如寫成 herf)導致入口可見但無法進頁。
235
+ - Route 權限比入口預期高,造成點得進去但被拒絕。
236
+ - page_name 未設定,導致側欄 active 樣式不正確。
237
+
238
+ ## 商品資料欄位
239
+
240
+ `GoodsModel` 主要欄位:
241
+
242
+ - `_id: number` 商品 ID
243
+ - `objectId?: string` 物件 ID(可重複)
244
+ - `name: string` 商品名稱
245
+ - `description?: string` 商品描述(Markdown)
246
+ - `price: number` 商品價格
247
+ - `num: number` 庫存(`-1` 或任何 `< 0` 代表無限)
248
+ - `purchaseModelId?: string` 購買處理器 ID
249
+ - `data?: Record<string, unknown>` 擴充資料
250
+
251
+ ## 外掛整合範例(徽章)
252
+
253
+ 徽章外掛可在發佈商品時:
254
+
255
+ - `name` 使用 `badge.title`
256
+ - `description` 使用 `badge.content`
257
+ - `purchaseModelId` 使用 `badge_purchase`
258
+
259
+ 如此可讓商城直接以 Markdown 顯示徽章說明內容。
260
+
261
+ ## 注意事項
262
+
263
+ - 兌換有限庫存商品時會先扣庫存;若外掛處理器失敗,系統會自動回補庫存。
264
+ - 無限供應商品(`num < 0`)不會扣庫存。
265
+ - 購買處理器可回傳 `false` 或 `{ success: false, message }` 拒絕兌換;若有 `message` 會直接顯示給使用者。
266
+ - 取消訂單流程在目前版本已停用(`coin_myrecord` 的 `POST` 會回覆功能已停用)。
@@ -0,0 +1,230 @@
1
+ import { $, addPage, NamedPage, UserSelectAutoComplete, Notification, delay, i18n, url, request, ConfirmDialog, tpl } from '@hydrooj/ui-default'
2
+
3
+ addPage(new NamedPage(['coin_inc', 'coin_gift'], () => {
4
+ UserSelectAutoComplete.getOrConstruct($('[name="uidOrName"]'), {
5
+ clearDefaultValue: false,
6
+ });
7
+ }));
8
+
9
+ addPage (new NamedPage('coin_inc', () => {
10
+ $(document).on('click', '[type="submit"]', async (ev) => {
11
+ ev.preventDefault();
12
+
13
+ const $form = $(ev.currentTarget).closest('form');
14
+ try {
15
+ const res = await request.post('', {
16
+ uidOrName: $form.find('[name="uidOrName"]').val(),
17
+ amount: $form.find('[name="amount"]').val(),
18
+ text: $form.find('[name="text"]').val(),
19
+ });
20
+ if (res.url) {
21
+ Notification.success(i18n('硬幣發放成功'));
22
+ await delay(1000);
23
+ window.location.href = res.url;
24
+ }
25
+ } catch (e) {
26
+ Notification.error(e.message);
27
+ }
28
+ });
29
+ }));
30
+
31
+ addPage (new NamedPage('coin_import', () => {
32
+ async function post(draft) {
33
+ try {
34
+ const res = await request.post('', {
35
+ coins: $('[name="coins"]').val(),
36
+ draft,
37
+ });
38
+ if (!draft) {
39
+ if (res.url) window.location.href = res.url;
40
+ else if (res.error) throw new Error(res.error?.message || res.error);
41
+ else {
42
+ Notification.success(i18n('Updated {0} coin records.', res.coins.length));
43
+ await delay(2000);
44
+ window.location.reload();
45
+ }
46
+ } else {
47
+ $('[name="messages"]').text(res.messages.join('\n'));
48
+ }
49
+ } catch (e) {
50
+ Notification.error(e.message);
51
+ }
52
+ }
53
+
54
+ $('[name="preview"]').on('click', () => post(true));
55
+ $('[name="submit"]').on('click', () => post(false));
56
+ }));
57
+
58
+ addPage (new NamedPage('goods_add', () => {
59
+ $(document).on('click', '[type="submit"]', async (ev) => {
60
+ ev.preventDefault();
61
+
62
+ const $form = $(ev.currentTarget).closest('form');
63
+ try {
64
+ const res = await request.post('', {
65
+ objectId: $form.find('[name="objectId"]').val(),
66
+ name: $form.find('[name="name"]').val(),
67
+ description: $form.find('[name="description"]').val(),
68
+ price: $form.find('[name="price"]').val(),
69
+ num: $form.find('[name="num"]').val(),
70
+ });
71
+ if (res.success) {
72
+ Notification.success(i18n('新增商品成功'));
73
+ await delay(1000);
74
+ window.location.reload();
75
+ }
76
+ } catch (e) {
77
+ Notification.error(e.message);
78
+ }
79
+ });
80
+ }));
81
+
82
+ addPage(new NamedPage('goods_edit', () => {
83
+ $(document).on('click', '[name="operation"][value="update"]', async (ev) => {
84
+ ev.preventDefault();
85
+
86
+ const $form = $(ev.currentTarget).closest('form');
87
+ try {
88
+ const res = await request.post('', {
89
+ operation: 'update',
90
+ id: $form.find('[name="id"]').val(),
91
+ objectId: $form.find('[name="objectId"]').val(),
92
+ name: $form.find('[name="name"]').val(),
93
+ description: $form.find('[name="description"]').val(),
94
+ price: $form.find('[name="price"]').val(),
95
+ num: $form.find('[name="num"]').val(),
96
+ });
97
+ if (res.url) {
98
+ window.location.href = res.url;
99
+ }
100
+ } catch (e) {
101
+ Notification.error(e.message);
102
+ }
103
+ });
104
+
105
+ $(document).on('click', '[name="operation"][value="delete"]', async (ev) => {
106
+ ev.preventDefault();
107
+ const message = '確認刪除此商品嗎?刪除後將無法恢復。';
108
+ const action = await new ConfirmDialog({
109
+ $body: tpl`
110
+ <div class="typo">
111
+ <p>${i18n(message)}</p>
112
+ </div>`,
113
+ }).open();
114
+ if (action !== 'yes') return;
115
+
116
+ const $form = $(ev.currentTarget).closest('form');
117
+ try {
118
+ const res = await request.post('', {
119
+ operation: 'delete',
120
+ id: $form.find('[name="id"]').val(),
121
+ });
122
+ if (res.url) {
123
+ window.location.href = res.url;
124
+ }
125
+ } catch (e) {
126
+ Notification.error(e.message);
127
+ }
128
+ });
129
+ }));
130
+
131
+ addPage (new NamedPage('coin_exchange', () => {
132
+ $(document).on('click', '[type="submit"]', async (ev) => {
133
+ ev.preventDefault();
134
+
135
+ const $form = $(ev.currentTarget).closest('form');
136
+ try {
137
+ const res = await request.post('', {
138
+ id: $form.find('[name="id"]').val(),
139
+ num: $form.find('[name="num"]').val(),
140
+ });
141
+ if (res.url) {
142
+ Notification.success(i18n('兌換商品成功'));
143
+ await delay(1000);
144
+ window.location.href = res.url;
145
+ }
146
+ } catch (e) {
147
+ Notification.error(e.message);
148
+ }
149
+ });
150
+ }));
151
+
152
+ addPage(new NamedPage('coin_record', () => {
153
+ $(document).on('click', '[type="submit"]', async (ev) => {
154
+ ev.preventDefault();
155
+ const message = '確定要兌換該訂單嗎?';
156
+ const action = await new ConfirmDialog({
157
+ $body: tpl`
158
+ <div class="typo">
159
+ <p>${i18n(message)}</p>
160
+ </div>`,
161
+ }).open();
162
+ if (action !== 'yes') return;
163
+
164
+ const $form = $(ev.currentTarget).closest('form');
165
+ try {
166
+ const res = await request.post('', {
167
+ id: $form.find('[name="id"]').val(),
168
+ });
169
+ if (res.success) {
170
+ Notification.success(i18n('兌換成功'));
171
+ await delay(1000);
172
+ window.location.reload();
173
+ }
174
+ } catch (e) {
175
+ Notification.error(e.message);
176
+ }
177
+ });
178
+ }));
179
+
180
+ addPage (new NamedPage('coin_gift', () => {
181
+ $(document).on('click', '[type="submit"]', async (ev) => {
182
+ ev.preventDefault();
183
+
184
+ const $form = $(ev.currentTarget).closest('form');
185
+ try {
186
+ const res = await request.post('', {
187
+ password: $form.find('[name="password"]').val(),
188
+ uidOrName: $form.find('[name="uidOrName"]').val(),
189
+ amount: $form.find('[name="amount"]').val(),
190
+ });
191
+ if (res.success) {
192
+ Notification.success(i18n('贈送硬幣成功'));
193
+ await delay(1000);
194
+ window.location.reload();
195
+ }
196
+ } catch (e) {
197
+ Notification.error(e.message);
198
+ }
199
+ });
200
+ }));
201
+
202
+ addPage (new NamedPage('uname_change', () => {
203
+ $(document).on('click', '[type="submit"]', async (ev) => {
204
+ ev.preventDefault();
205
+
206
+ const $form = $(ev.currentTarget).closest('form');
207
+ const operation = $(ev.currentTarget).attr('value');
208
+ try {
209
+ const res = await request.post('', {
210
+ operation: operation,
211
+ password: $form.find('[name="password"]').val(),
212
+ uidOrName: $form.find('[name="uidOrName"]').val(),
213
+ newUname: $form.find('[name="newUname"]').val(),
214
+ });
215
+ if (res.url) {
216
+ Notification.success(i18n('修改使用者名稱成功'));
217
+ await delay(1000);
218
+ window.location.href = res.url;
219
+ }
220
+ } catch (e) {
221
+ Notification.error(e.message);
222
+ }
223
+ });
224
+ }));
225
+
226
+ // 添加修改使用者名稱的按鈕
227
+ addPage(new NamedPage('home_account', () => {
228
+ $('.section__title#setting_info').closest('.section__header')
229
+ .append('<div class="section__tools"><a class="button rounded" href="../../uname/change">修改使用者名稱</a></div>');
230
+ }));